Repository navigation
fix(relayer,dashboard): relay a gasless vote once; no 500 or error toast after it lands - #2171
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.
|
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. |
|
🚅 Deployed to the anticapture-pr-2171 environment in anticapture-infra
|
🎨 UI Review
Voting modal — error copy
Mobile
Desktop
Generated by Claude Code |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0b31859845
ℹ️ 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".
| "@anticapture/relayer": patch | ||
| "@anticapture/dashboard": patch |
There was a problem hiding this comment.
Add the required Gateful changeset
The new documented 409 responses in the relay controllers change the relayer OpenAPI contract that mergeRelayerPaths exposes through Gateful, but this changeset bumps only the relayer and dashboard. Add an @anticapture/gateful entry so the aggregated API contract is versioned and released consistently.
AGENTS.md reference: AGENTS.md:L121-L124
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 913ba03: the changeset now bumps @anticapture/gateful too, since the relay routes it exposes document the new 409.
| const handleClose = useCallback(() => { | ||
| onClose(); | ||
| if (isSuccess) window.location.reload(); | ||
| }, [onClose, isSuccess]); |
There was a problem hiding this comment.
Route success actions through the reload handler
This handler is not actually used by every success-state exit: the success branch still passes the raw onClose callback to VoteSuccessContent, whose two links invoke it directly. For example, Ctrl/Cmd-clicking either link opens the destination in a new tab while closing the modal in the current tab without reloading, leaving the stale Vote action available and allowing the same signature to be replayed; pass handleClose into the success content as well.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Superseded by f36bb0a. The modal no longer reloads at all: the vote's state lives in the page-lifetime submission store, so every exit (success links included, even a Cmd-click) leaves the page showing the landed vote, and the ballot cannot be submitted again.
…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.
|
🔍 Vercel preview: https://anticapture-6kj76wypc-ful.vercel.app |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f36bb0ae85
ℹ️ 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".
| export const getVoteScreen = (submission: SubmissionState): VoteScreen => { | ||
| if (canSubmitAgain(submission)) return "ballot"; | ||
| if (submission.kind === "in-flight") return "in-flight"; | ||
| if (submission.kind === "ambiguous") return "pending"; | ||
| return "landed"; |
There was a problem hiding this comment.
Allow another ballot when the DAO supports changing votes
For UNI and TORN, where daoOverview.rules.changeVote enables a “Change your vote” action, any successful vote in the current page session leaves this submission permanently mapped to "landed". Nothing clears that state after the updated vote is indexed, so clicking “Change your vote” only reopens the success screen and cannot submit another ballot until the user reloads the page.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 913ba03. canStartVote lets a landed vote start a new ballot where rules.changeVote is true (UNI, TORN). An in-flight or ambiguous vote still blocks it. The modal keeps the success screen for the run that just landed and shows the ballot on the next open.
| refetchInterval: (query) => | ||
| votePollIntervalMs && !query.state.data?.items.length | ||
| ? votePollIntervalMs | ||
| : false, |
There was a problem hiding this comment.
Keep polling until a changed vote is indexed
When a UNI or TORN voter changes an existing vote, this query already contains the old vote, so items.length is nonzero and the polling interval is disabled immediately. The proposal effect consequently treats that old item as indexed and performs only an early invalidation that can race the indexer, leaving the old choice and tally displayed until an unrelated refetch or page reload; the stop condition needs to distinguish the newly submitted vote from the pre-existing one.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 913ba03. isSubmittedVoteIndexed treats the vote as indexed only when the latest indexed vote's transactionHash is the submitted one, so polling and the tally refresh wait for the changed vote rather than stopping on the one it replaced. With no known hash (already voted), any indexed vote counts.
| const { data, isLoading } = useGetRateLimit( | ||
| toDaoKey(daoId) as GetRateLimitPathParamsDaoEnumKey, | ||
| address ?? zeroAddress, | ||
| { query: { enabled } }, | ||
| { query: { enabled, staleTime: RELAYER_QUERY_STALE_MS } }, | ||
| ); |
There was a problem hiding this comment.
Invalidate the cached delegation allowance after relaying
After a successful gasless delegation, the relayer has decremented the address's delegation allowance, but DelegationModal only refetches the on-chain delegate and never invalidates this query. With the new one-minute staleTime, a user who immediately delegates again—especially after using their final allowance—still sees the action as free and is automatically sent through the relayer, where it fails with 429 instead of falling back to the wallet until this cache becomes stale.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 913ba03: DelegationModal invalidates the rate-limit query after every gasless delegation attempt, so the allowance is refetched before the next one.
| switch (outcome.status) { | ||
| case "success": | ||
| case "already-voted": | ||
| return { kind: "done", outcome: "landed", hash: outcome.hash }; |
There was a problem hiding this comment.
Do not attribute the selected choice to an existing vote
When hasVoted or the relayer reports already-voted, no new ballot was cast and the code does not know the support of the existing on-chain vote. Nevertheless, this maps the outcome to a landed submission while retaining the just-selected support from claimVote, so submittedVoteSupport can make the header say, for example, “You voted Against” even though the pre-existing vote was For; keep the action suppressed without exposing the attempted choice as the recorded one until indexing supplies the actual support.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 913ba03. An already-voted outcome clears the attempted choice (alreadyVoted: true, support: null), so the header shows nothing until the indexer supplies the real vote, and the modal shows a dedicated already-voted screen.
- 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.
|
Thanks for the UI review. Addressed in 913ba03 and f36bb0a:
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 913ba03a8c
ℹ️ 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".
| const reader = { | ||
| readContract, | ||
| waitForTransactionReceipt, | ||
| } as unknown as VoteChainReader; |
There was a problem hiding this comment.
Remove the unsafe casts from the vote test doubles
Type makeReader and the nearby makeSigner test double directly against their interfaces instead of coercing incomplete objects through unknown. These casts bypass the compiler, so changes to viem's overloaded methods or the production wrapper interfaces can leave these tests compiling while exercising an incompatible fake, and the repository explicitly prohibits casts through unknown.
AGENTS.md reference: AGENTS.md:L133-L133
Useful? React with 👍 / 👎.
| request: {}, | ||
| result: undefined, | ||
| })) as unknown as ChainReader["simulateContract"], | ||
| simulateContract: simulate as unknown as ChainReader["simulateContract"], |
There was a problem hiding this comment.
Type the simulated chain reader without an unknown cast
Replace this as unknown as coercion with a correctly typed ChainReader stub. The cast suppresses validation of the simulated method's parameters and result, allowing the relay tests to keep compiling when the real interface changes even though the fake no longer models it, and it violates the repository's explicit ban on casting through unknown.
AGENTS.md reference: AGENTS.md:L133-L133
Useful? React with 👍 / 👎.
| if (outcome.hash) { | ||
| void queryClient.invalidateQueries({ | ||
| queryKey: getRateLimitQueryKey( | ||
| daoId.toLowerCase() as GetRateLimitPathParamsDaoEnumKey, | ||
| address, | ||
| ), | ||
| }); | ||
| } |
There was a problem hiding this comment.
Invalidate the vote allowance after every relay attempt
When the relayer returns already-voted, another SIMULATION_FAILED, or an ambiguous failure, there is no hash (or the call throws), so this branch skips invalidation. The relayer has already called assertWithinLimit before simulation, and that operation increments the monthly count; consequently, an attempt that consumes the user's final slot can leave the new 60-second cache reporting another proposal as free, only for its submission to fail with 429. Invalidate the rate-limit query whenever a signed request was made, not only when a hash was returned.
Useful? React with 👍 / 👎.
What happened (2026-10-20, ens.gov.blockful.io)
Gateful HTTP logs for
POST /ens/relay/vote:0x06ce9a462cbcb608289a71005b48de0a4d3e768281eb5713225e4fa23c35e12d(voter0x76A6…BbB8, FOR on "Activate ENSv2"). The indexer already has it.walletClient.extend(publicActions), so it went through the user's wallet provider for a transaction that wallet never sent. That wait failed.catchinvoteOnProposaltreated the failure like a relay error and showed "Something went wrong. Try again later". The modal put the ballot back.Ballotdeterministically, so each click replayed the same signature. The relayer's simulation reverted with "vote already cast", which it answered as an unhandled 500. Gateful counts those against therelayer:enscircuit breaker.The modal also had other replay paths:
isLoading/isSuccess, and Cancel stayed visible during submission, so closing mid-request and reopening showed the ballot while the first call was still in flight.Fix
Relayer
relayVote/relayDelegationnow return the existingSIMULATION_FAILED409, not 500. An RPC failure is still a 500.services/chain/revert.ts.Dashboard: one relay call per vote
relayGaslessVote, modeled onrelayGovernanceAction:hasVotedon-chain before asking for a signature. If the vote already exists, it doesn't sign or relay.usePublicClient({ chainId })for the DAO's chain, never the wallet.unconfirmed.unknown.already-voted.submissionStore, the one queue/execute already use, keyed by DAO, proposal and voter:claimVotebefore the firstawaitblocks double clicks.VotePendingContentscreen for unconfirmed/unknown, with the explorer link and no retry.window.location.reload()the modal used to do.staleTime. Every observer mount used to refetch them, which shows in the relayer logs as the same three GETs two or three times per second. The rate limit is invalidated after a relayed vote.voteOnProposalnow handles wallet votes only. A reverted wallet vote is no longer shown as a success.Tests
anvil_minesetup (45,819 blocks) against a public RPC before any relayer code runs. Please confirm it in CI.relayGaslessVote.test.tscover the incident (200, then a failed receipt wait →unconfirmed, one relay call), plus thehasVotedshort-circuit, ambiguous failures and "already cast".voteSubmission.test.tscover the claim, close/reopen mirroring and the outcome → state mapping.Note
Medium Risk
Changes core gasless governance voting and relayer error semantics; behavior is heavily tested but affects vote submission, indexing UX, and circuit-breaker-sensitive 5xx vs 409 handling.
Overview
Fixes gasless voting replay and false failures after a successful relay by tightening relayer errors and reshaping the dashboard vote flow.
Relayer: On-chain simulation failures for
castVoteBySig/delegateBySig(duplicate vote, bad nonce, expired sig, etc.) now return 409SIMULATION_FAILEDinstead of 500, with 409 documented on the relay vote/delegate routes. Revert parsing is shared viarevert.ts.Dashboard: Gasless voting moves to
relayGaslessVote: optionalhasVotedbefore signing, a single relay call, and receipt confirmation on the DAO chain (usePublicClient), not the wallet. Ambiguous relay failures and receipt timeouts become pending/unconfirmed outcomes—no error toast and no ballot replay.VotingModaluses a page-lifetimesubmissionStore(claimVote/settleVote) plus new already voted and pending screens so state survives close/reopen. The proposal page shows You voted from landed submission, polls until the submitted tx is indexed, and refetches tally/votes.voteOnProposalis wallet-only; relayer config queries get 60s staleTime; rate limits invalidate after relayed vote/delegation.mapRelayerErrorhandlesSIMULATION_FAILED(including “already voted”).Reviewed by Cursor Bugbot for commit 913ba03. Configure here.