Repository navigation
Dev - #2175
Dev#2175alextnetto wants to merge 5 commits into
Conversation
…IMULATION_FAILED castVoteBySig and delegateBySig simulations that reverted reached the global handler as 500 INTERNAL. A second click on Vote re-signs the same ballot byte for byte, so after a successful gasless vote the replay reverted with "vote already cast" and the user got a 500, which Gateful also counts against the relayer circuit breaker. Reverts now map to the existing SIMULATION_FAILED (409) contract that the dashboard already reads as pre-broadcast. A simulation that could not run (RPC down) still surfaces as 500. The revert helpers move from the governor gateway to services/chain/revert.ts so both paths share them.
…odal close Map the relayer's SIMULATION_FAILED to clear copy (already voted, or would revert and was not sent) instead of "Something went wrong. Try again later", which invited the same failing retry. Escape and the mobile drawer closed the voting modal without the reload the X button and backdrop already did after a successful vote, so the page kept offering Vote and a second click replayed the same signature.
…vote as failed Production, 2026-10-20: the first relay call answered 200 in 357 ms and the vote was mined, but the dashboard then waited for the receipt through the user's wallet provider. That wait failed, the shared catch showed "Something went wrong", the ballot came back, and two more clicks replayed the same signature at the relayer. - relayGaslessVote (modelled on relayGovernanceAction): reads hasVoted before asking for a signature, relays exactly once, and confirms the transaction on a public client for the DAO's chain. A failed receipt wait is "unconfirmed", an ambiguous relay failure is "unknown", and the relayer's "vote already cast" is a vote that exists. None of them is an error toast. - The vote's submission lives in the page-lifetime submission store used by queue/execute, keyed by DAO, proposal and voter. A synchronous claim blocks double clicks, and closing, reopening or remounting the modal mirrors the in-flight, landed or pending vote instead of resetting to the ballot. - The proposal page shows "You voted" as soon as the vote landed and polls the account's vote until it is indexed (two minutes at most), then refetches the tally and votes table. This replaces the page reload. - Relayer balance, config and rate-limit queries get a 60 s stale time; each observer mount used to refetch them, two or three times per second. - voteOnProposal now covers wallet votes only.
- Votes can be changed again on UNI and TORN: a landed vote reopens the ballot where the DAO allows changing votes, while an in-flight or ambiguous one still blocks a second submission. - "Indexed" now means the submitted transaction itself, so polling keeps going while the API still returns the vote a change replaced. - An "already voted" answer no longer shows the attempted choice as the vote on record; the modal ends on its own screen with a Close button. - A gasless delegation invalidates the cached delegation allowance, so the next one does not hit the relayer's 429 on a stale count. - Plain-language copy for a relay the governor would refuse. - Gateful changeset: the relay routes it exposes now document the 409.
fix(relayer,dashboard): relay a gasless vote once; no 500 or error toast after it lands
|
The latest updates on your projects. Learn more about Vercel for GitHub.
1 Skipped Deployment
|
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
🎨 UI Review
Voting modal — "Your vote was sent" (
|
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Wallet vote wait treated as retryable
- Wallet votes that broadcast but fail receipt wait now settle as ambiguous using the captured hash, so the ballot is not offered for retry.
Or push these changes by commenting:
@cursor push fd242874b6
Preview (fd242874b6)
diff --git a/apps/dashboard/features/governance/components/modals/VotingModal.tsx b/apps/dashboard/features/governance/components/modals/VotingModal.tsx
--- a/apps/dashboard/features/governance/components/modals/VotingModal.tsx
+++ b/apps/dashboard/features/governance/components/modals/VotingModal.tsx
@@ -3,7 +3,7 @@
import { useQueryClient } from "@tanstack/react-query";
import { Check, User2Icon, X } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
-import type { Address } from "viem";
+import type { Address, Hash } from "viem";
import { formatUnits } from "viem";
import { useAccount, usePublicClient, useWalletClient } from "wagmi";
@@ -347,6 +347,7 @@
);
}
} else {
+ let broadcastHash: Hash | null = null;
const receipt = await voteOnProposal(
vote,
proposal.id,
@@ -354,7 +355,10 @@
chain,
daoId,
walletClient,
- setTransactionhash,
+ (hash) => {
+ setTransactionhash(hash);
+ if (hash) broadcastHash = hash as Hash;
+ },
comment,
tornDelegatedVoteAddresses,
);
@@ -362,7 +366,13 @@
showCustomToast("Your vote transaction reverted on-chain.", "error");
}
settlement = settlementForWalletVote(
- receipt?.status === "success" ? receipt.transactionHash : null,
+ receipt
+ ? {
+ status: receipt.status,
+ transactionHash: receipt.transactionHash,
+ }
+ : null,
+ broadcastHash,
);
}
} catch (error) {
diff --git a/apps/dashboard/features/governance/utils/voteSubmission.test.ts b/apps/dashboard/features/governance/utils/voteSubmission.test.ts
--- a/apps/dashboard/features/governance/utils/voteSubmission.test.ts
+++ b/apps/dashboard/features/governance/utils/voteSubmission.test.ts
@@ -68,7 +68,7 @@
it("allows a new attempt only after one that changed nothing", () => {
const key = freshKey();
claimVote(key, "gasless", 1);
- settleVote(key, settlementForWalletVote(null));
+ settleVote(key, settlementForWalletVote(null, null));
expect(claimVote(key, "gasless", 0)).toBe(true);
expect(readVoteDetails(key).support).toBe(0);
@@ -85,7 +85,10 @@
it("lets a landed vote be changed where the DAO allows it", () => {
const key = freshKey();
claimVote(key, "wallet", 1, true);
- settleVote(key, settlementForWalletVote(hash));
+ settleVote(
+ key,
+ settlementForWalletVote({ status: "success", transactionHash: hash }),
+ );
expect(claimVote(key, "wallet", 0, true)).toBe(true);
expect(readVoteDetails(key).support).toBe(0);
@@ -134,6 +137,15 @@
});
});
+describe("settlementForWalletVote", () => {
+ it("keeps a broadcast vote in doubt when the receipt could not be read", () => {
+ expect(settlementForWalletVote(null, hash)).toEqual({
+ kind: "ambiguous",
+ hash,
+ });
+ });
+});
+
describe("settlementForGaslessVote", () => {
const cases: Array<
[GaslessVoteOutcome, ReturnType<typeof settlementForGaslessVote>]
diff --git a/apps/dashboard/features/governance/utils/voteSubmission.ts b/apps/dashboard/features/governance/utils/voteSubmission.ts
--- a/apps/dashboard/features/governance/utils/voteSubmission.ts
+++ b/apps/dashboard/features/governance/utils/voteSubmission.ts
@@ -135,13 +135,32 @@
}
};
-/** The wallet path reports its confirmed hash, or null when nothing landed. */
-export const settlementForWalletVote = (hash: Hash | null): VoteSettlement =>
- hash
- ? { kind: "done", outcome: "landed", hash }
- : { kind: "done", outcome: "failed", hash: null };
+type WalletVoteReceipt = {
+ status: "success" | "reverted";
+ transactionHash: Hash;
+};
/**
+ * The wallet path reports a read receipt, or a broadcast hash when the
+ * receipt could not be read. Only a failure with no broadcast is retryable.
+ */
+export const settlementForWalletVote = (
+ receipt: WalletVoteReceipt | null,
+ broadcastHash: Hash | null = null,
+): VoteSettlement => {
+ if (receipt?.status === "success") {
+ return { kind: "done", outcome: "landed", hash: receipt.transactionHash };
+ }
+ if (receipt?.status === "reverted") {
+ return { kind: "done", outcome: "failed", hash: receipt.transactionHash };
+ }
+ if (broadcastHash) {
+ return { kind: "ambiguous", hash: broadcastHash };
+ }
+ return { kind: "done", outcome: "failed", hash: null };
+};
+
+/**
* What the voting modal shows for a submission. Only a vote that may start
* again gets the ballot; every other state is mirrored on any open, so no
* screen offers a submission that could replay or race another.You can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit a2f82f1. Configure here.
| proposalId: BigInt(params.proposalId), | ||
| support: params.voteNumber, | ||
| }, | ||
| }); |
There was a problem hiding this comment.
Wallet vote wait treated as retryable
Medium Severity
voteOnProposal still returns null when waitForTransactionReceipt fails after writeContract has already broadcast the vote. The new settlement maps that null through settlementForWalletVote to a retryable failed outcome, so the ballot comes back even though the vote may already be on-chain.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit a2f82f1. Configure here.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a2f82f16b4
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (outcome.hash) { | ||
| void queryClient.invalidateQueries({ |
There was a problem hiding this comment.
Invalidate the quota after every gasless relay attempt
When the relayer rejects a vote during simulation or returns an ambiguous error without a hash, this invalidation is skipped. The relayer has already incremented the monthly allowance before simulation in RelayService.relayVote, while useRelayerRateLimit now caches the old value for 60 seconds; consequently, a voter whose last slot was consumed by a refused request remains marked gasless-eligible and subsequent proposals are incorrectly sent to the relayer only to receive 429 responses. Invalidate the rate-limit query after the relay request settles even when no hash is returned.
Useful? React with 👍 / 👎.
| useEffect(() => { | ||
| if (isOffchain || !isWatchingOnchainVote || !isOnchainVoteIndexed) return; | ||
| const daoKey = daoEnum.toLowerCase() as ProposalPathParamsDaoEnumKey; | ||
| void queryClient.invalidateQueries({ | ||
| queryKey: proposalQueryKey(daoKey, proposalId), |
There was a problem hiding this comment.
Clear the pending state once the vote is indexed
When receipt confirmation fails but the relayed transaction subsequently lands, isOnchainVoteIndexed eventually becomes true, yet this effect only invalidates queries and leaves the submission as ambiguous. Because getVoteScreen maps that state to pending, reopening the modal continues to claim the vote is unconfirmed for the rest of the page lifetime even though the API has indexed the exact transaction; it would also permanently block a later change on any changeable-vote DAO using this flow. Promote the stored submission to a landed state when the matching indexed vote is observed.
Useful? React with 👍 / 👎.
| const reader = { | ||
| readContract, | ||
| waitForTransactionReceipt, | ||
| } as unknown as VoteChainReader; |
There was a problem hiding this comment.
Replace the unsafe test-double casts
This new test double bypasses the VoteChainReader contract with as unknown as, and the signer immediately below uses the same pattern, so interface changes can silently leave these tests exercising an invalid fake instead of producing a type error. Define correctly typed implementations of the narrowed interfaces rather than suppressing their incompatibilities.
AGENTS.md reference: AGENTS.md:L127-L133
Useful? React with 👍 / 👎.



Release of #2171: a gasless vote is relayed once and is never reported as failed after it lands, and the relayer answers a replayed or refused vote or delegation with 409 SIMULATION_FAILED instead of 500.
Merge the Version Packages PR #2174 into dev first. Until then, the release-readiness check fails on the unconsumed changeset.
Note
Medium Risk
Touches core governance voting and relayer error semantics; behavior changes are intentional but affect every gasless vote/delegation and how clients interpret failed simulations.
Overview
Fixes gasless voting so a ballot is not replayed at the relayer and relayed votes are not shown as failed after they may have landed.
Relayer maps on-chain simulation reverts (duplicate vote, bad nonce, expired delegation sig, etc.) to 409
SIMULATION_FAILEDinstead of 500INTERNAL, with OpenAPI on the vote/delegate routes; nothing is broadcast when simulation fails.Dashboard moves gasless voting into
relayGaslessVote:hasVotedbefore signing, receipt confirmation on the DAO chain (not the wallet RPC), and outcomes like unconfirmed/unknown treated as pending rather than failure. A page-lifetime submission store (claimVote/settleVote, modal screens for pending/already-voted) stops double-clicks and reopening the modal from offering the ballot again; the proposal header reflects the submitted choice before the indexer catches up, with vote polling until the matching tx is indexed. User-facing errors recognize simulation “already voted”; relayer config/rate-limit queries get 60s stale time and invalidation after relayed vote/delegation.Wallet path stays in
voteOnProposalonly (gasless removed from there).Reviewed by Cursor Bugbot for commit a2f82f1. Configure here.