From bd4f072dc66eff4764e39fed0303ac1a6f79c4da Mon Sep 17 00:00:00 2001 From: "Alexandro T. Netto" Date: Tue, 20 Oct 2026 10:48:56 -0300 Subject: [PATCH 1/4] fix(relayer): answer simulated vote and delegation reverts with 409 SIMULATION_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. --- .changeset/relayer-simulation-revert-409.md | 6 + apps/relayer/e2e/relay-delegate.test.ts | 8 +- apps/relayer/e2e/relay-vote.test.ts | 13 +- .../relayer/src/controllers/relay-delegate.ts | 7 ++ apps/relayer/src/controllers/relay-vote.ts | 7 ++ .../src/services/chain/governor-gateway.ts | 22 +--- apps/relayer/src/services/chain/revert.ts | 31 +++++ apps/relayer/src/services/relay.test.ts | 118 +++++++++++++++++- apps/relayer/src/services/relay.ts | 66 ++++++---- 9 files changed, 222 insertions(+), 56 deletions(-) create mode 100644 .changeset/relayer-simulation-revert-409.md create mode 100644 apps/relayer/src/services/chain/revert.ts diff --git a/.changeset/relayer-simulation-revert-409.md b/.changeset/relayer-simulation-revert-409.md new file mode 100644 index 0000000000..e65bf8caf5 --- /dev/null +++ b/.changeset/relayer-simulation-revert-409.md @@ -0,0 +1,6 @@ +--- +"@anticapture/relayer": patch +"@anticapture/dashboard": patch +--- + +Answer a gasless vote or delegation that would revert on-chain (a vote already cast, a used nonce) with 409 SIMULATION_FAILED instead of 500 INTERNAL, tell the voter they already voted, and reload the page however the voting modal is closed after a successful vote diff --git a/apps/relayer/e2e/relay-delegate.test.ts b/apps/relayer/e2e/relay-delegate.test.ts index e570d9e695..ee001515d8 100644 --- a/apps/relayer/e2e/relay-delegate.test.ts +++ b/apps/relayer/e2e/relay-delegate.test.ts @@ -169,8 +169,8 @@ describe("POST /relay/delegate", () => { body: JSON.stringify(body), }); - expect(res.status).toBe(500); - expect(await res.json()).toMatchObject({ code: "INTERNAL" }); + expect(res.status).toBe(409); + expect(await res.json()).toMatchObject({ code: "SIMULATION_FAILED" }); }); it("should reject delegation with expired signature", async () => { @@ -187,7 +187,7 @@ describe("POST /relay/delegate", () => { body: JSON.stringify(body), }); - expect(res.status).toBe(500); - expect(await res.json()).toMatchObject({ code: "INTERNAL" }); + expect(res.status).toBe(409); + expect(await res.json()).toMatchObject({ code: "SIMULATION_FAILED" }); }); }); diff --git a/apps/relayer/e2e/relay-vote.test.ts b/apps/relayer/e2e/relay-vote.test.ts index fcfcf57ea0..5d623ea1ca 100644 --- a/apps/relayer/e2e/relay-vote.test.ts +++ b/apps/relayer/e2e/relay-vote.test.ts @@ -162,8 +162,8 @@ describe("POST /relay/vote", () => { body: JSON.stringify(body), }); - expect(res.status).toBe(500); - expect(await res.json()).toMatchObject({ code: "INTERNAL" }); + expect(res.status).toBe(409); + expect(await res.json()).toMatchObject({ code: "SIMULATION_FAILED" }); }); it("should reject a second vote from the same address on the same proposal", async () => { @@ -191,7 +191,12 @@ describe("POST /relay/vote", () => { body: JSON.stringify(body), }); - expect(secondRes.status).toBe(500); - expect(await secondRes.json()).toMatchObject({ code: "INTERNAL" }); + expect(secondRes.status).toBe(409); + const secondBody = (await secondRes.json()) as { + code: string; + error: string; + }; + expect(secondBody.code).toBe("SIMULATION_FAILED"); + expect(secondBody.error).toContain("vote already cast"); }); }); diff --git a/apps/relayer/src/controllers/relay-delegate.ts b/apps/relayer/src/controllers/relay-delegate.ts index 6cde916e23..3387508741 100644 --- a/apps/relayer/src/controllers/relay-delegate.ts +++ b/apps/relayer/src/controllers/relay-delegate.ts @@ -37,6 +37,13 @@ export function relayDelegate(app: Hono, relayService: RelayService) { "application/json": { schema: ErrorResponseSchema }, }, }, + 409: { + description: + "The delegation would revert on-chain (used nonce, expired signature), so it was not sent", + content: { + "application/json": { schema: ErrorResponseSchema }, + }, + }, 429: { description: "Rate limited", content: { diff --git a/apps/relayer/src/controllers/relay-vote.ts b/apps/relayer/src/controllers/relay-vote.ts index 30f9535a7d..af24595950 100644 --- a/apps/relayer/src/controllers/relay-vote.ts +++ b/apps/relayer/src/controllers/relay-vote.ts @@ -37,6 +37,13 @@ export function relayVote(app: Hono, relayService: RelayService) { "application/json": { schema: ErrorResponseSchema }, }, }, + 409: { + description: + "The vote would revert on-chain (already cast, proposal not active), so it was not sent", + content: { + "application/json": { schema: ErrorResponseSchema }, + }, + }, 429: { description: "Rate limited", content: { diff --git a/apps/relayer/src/services/chain/governor-gateway.ts b/apps/relayer/src/services/chain/governor-gateway.ts index 1c553ccd3b..e46289188f 100644 --- a/apps/relayer/src/services/chain/governor-gateway.ts +++ b/apps/relayer/src/services/chain/governor-gateway.ts @@ -1,8 +1,5 @@ import { Address, - BaseError, - ContractFunctionRevertedError, - ExecutionRevertedError, Hash, Hex, WaitForTransactionReceiptTimeoutError, @@ -12,6 +9,7 @@ import { import { governorAbi, ProposalState } from "@/abi/governor"; import type { ChainReader } from "./chain-reader"; +import { findRevert, revertReason } from "./revert"; /** queue()/execute() argument tuple, with the description already hashed. */ export interface EnactmentCall { @@ -77,24 +75,6 @@ export type GovernorChainReader = ChainReader & // circuit-breaker failure for a transaction that was already broadcast. const RECEIPT_TIMEOUT_MS = 15_000; -function findRevert(err: unknown) { - if (!(err instanceof BaseError)) return null; - return err.walk( - (e) => - e instanceof ContractFunctionRevertedError || - e instanceof ExecutionRevertedError, - ) as ContractFunctionRevertedError | ExecutionRevertedError | null; -} - -function revertReason( - revert: ContractFunctionRevertedError | ExecutionRevertedError, -): string { - if (revert instanceof ContractFunctionRevertedError) { - return revert.reason ?? revert.data?.errorName ?? revert.shortMessage; - } - return revert.shortMessage; -} - export class ViemGovernorGateway implements GovernorGateway { constructor( private client: GovernorChainReader, diff --git a/apps/relayer/src/services/chain/revert.ts b/apps/relayer/src/services/chain/revert.ts new file mode 100644 index 0000000000..03a16ce7e9 --- /dev/null +++ b/apps/relayer/src/services/chain/revert.ts @@ -0,0 +1,31 @@ +import { + BaseError, + ContractFunctionRevertedError, + ExecutionRevertedError, +} from "viem"; + +/** + * The contract revert inside a viem error, or null when the call failed for + * another reason (transport, timeout, a malformed response). + */ +export function findRevert(err: unknown) { + if (!(err instanceof BaseError)) return null; + return err.walk( + (e) => + e instanceof ContractFunctionRevertedError || + e instanceof ExecutionRevertedError, + ) as ContractFunctionRevertedError | ExecutionRevertedError | null; +} + +/** + * Only the short revert reason: full RPC error messages can embed the + * provider URL, which may hold an API key, so they must never reach callers. + */ +export function revertReason( + revert: ContractFunctionRevertedError | ExecutionRevertedError, +): string { + if (revert instanceof ContractFunctionRevertedError) { + return revert.reason ?? revert.data?.errorName ?? revert.shortMessage; + } + return revert.shortMessage; +} diff --git a/apps/relayer/src/services/relay.test.ts b/apps/relayer/src/services/relay.test.ts index 2ec49aab94..ce2277f02d 100644 --- a/apps/relayer/src/services/relay.test.ts +++ b/apps/relayer/src/services/relay.test.ts @@ -1,6 +1,16 @@ import { describe, it, expect } from "vitest"; -import { getAddress, type Hash, type Hex } from "viem"; +import { + ContractFunctionExecutionError, + ContractFunctionRevertedError, + getAddress, + HttpRequestError, + type Hash, + type Hex, +} from "viem"; +import { governorAbi } from "@/abi/governor"; +import { erc20VotesAbi } from "@/abi/token"; +import { RelayError } from "@/errors"; import { RelayService } from "./relay"; import type { RelayerSigner } from "@/signer/types"; import type { ISignatureVerifier } from "./guards/signature-verifier"; @@ -58,17 +68,42 @@ function createStubRateLimiter(): IRateLimiter { }; } -function createStubChainReader(): ChainReader { +function createStubChainReader( + simulate: () => Promise = async () => ({ + request: {}, + result: undefined, + }), +): ChainReader { return { getBalance: async () => 0n, readContract: (async () => undefined) as ChainReader["readContract"], - simulateContract: (async () => ({ - request: {}, - result: undefined, - })) as unknown as ChainReader["simulateContract"], + simulateContract: simulate as unknown as ChainReader["simulateContract"], }; } +/** What viem's simulateContract throws when the contract reverts. */ +function contractRevert( + abi: typeof governorAbi | typeof erc20VotesAbi, + functionName: string, + args: unknown[], + reason: string, +) { + return new ContractFunctionExecutionError( + new ContractFunctionRevertedError({ abi, functionName, message: reason }), + { abi, functionName, args, contractAddress: GOVERNOR }, + ); +} + +/** Captures what the service threw, so its fields can be asserted on. */ +async function caught(promise: Promise): Promise { + try { + await promise; + } catch (err) { + return err; + } + throw new Error("expected the promise to reject"); +} + function createService( overrides: { signer?: RelayerSigner; @@ -140,6 +175,48 @@ describe("RelayService", () => { "minimum voting power", ); }); + + // A replayed signature is the common case: the wallet signs the same + // Ballot deterministically, so a second click resends identical bytes. + it("answers 409 SIMULATION_FAILED, without sending, when the vote would revert", async () => { + let sent = false; + const service = createService({ + signer: createStubSigner({ + sendTransaction: async () => { + sent = true; + return TX_HASH; + }, + }), + chainReader: createStubChainReader(async () => { + throw contractRevert( + governorAbi, + "castVoteBySig", + [1n, 1, 27, SAMPLE_R, SAMPLE_S], + "GovernorVotingSimple: vote already cast", + ); + }), + }); + + const err = await caught(service.relayVote(voteParams)); + + expect(err).toBeInstanceOf(RelayError); + expect(err).toMatchObject({ code: "SIMULATION_FAILED", status: 409 }); + expect((err as RelayError).message).toContain( + "GovernorVotingSimple: vote already cast", + ); + expect(sent).toBe(false); + }); + + it("lets a simulation that could not run surface as an unhandled error", async () => { + const transportError = new HttpRequestError({ url: "http://rpc" }); + const service = createService({ + chainReader: createStubChainReader(async () => { + throw transportError; + }), + }); + + await expect(service.relayVote(voteParams)).rejects.toBe(transportError); + }); }); describe("relayDelegation", () => { @@ -200,5 +277,34 @@ describe("RelayService", () => { "minimum voting power", ); }); + + it("answers 409 SIMULATION_FAILED, without sending, when the delegation would revert", async () => { + let sent = false; + const service = createService({ + signer: createStubSigner({ + sendTransaction: async () => { + sent = true; + return TX_HASH; + }, + }), + chainReader: createStubChainReader(async () => { + throw contractRevert( + erc20VotesAbi, + "delegateBySig", + [VOTER, 0n, 0n, 27, SAMPLE_R, SAMPLE_S], + "ERC20Votes: invalid nonce", + ); + }), + }); + + const err = await caught(service.relayDelegation(delegateParams)); + + expect(err).toBeInstanceOf(RelayError); + expect(err).toMatchObject({ code: "SIMULATION_FAILED", status: 409 }); + expect((err as RelayError).message).toContain( + "ERC20Votes: invalid nonce", + ); + expect(sent).toBe(false); + }); }); }); diff --git a/apps/relayer/src/services/relay.ts b/apps/relayer/src/services/relay.ts index a7a50dd8bc..bba189c25a 100644 --- a/apps/relayer/src/services/relay.ts +++ b/apps/relayer/src/services/relay.ts @@ -15,6 +15,7 @@ import { RelayerSigner } from "@/signer/types"; import type { ChainReader } from "./chain/chain-reader"; import type { IChainStateService } from "./chain/chain-state"; +import { findRevert, revertReason } from "./chain/revert"; import type { ISignatureVerifier } from "./guards/signature-verifier"; export interface RelayVoteParams { @@ -40,6 +41,21 @@ export interface RelayResult { signer: Address; } +/** + * A simulation that reverted is the contract refusing the request before + * anything is broadcast (vote already cast, proposal not active, used nonce, + * expired signature), so it answers 409 SIMULATION_FAILED. Without this it + * reached the global handler as 500 INTERNAL, which Gateful counts against + * the relayer's circuit breaker. A simulation that could not run at all + * (RPC down) is still rethrown as is. + */ +function simulationError(functionName: string, err: unknown): unknown { + const revert = findRevert(err); + return revert + ? Errors.SIMULATION_FAILED(functionName, revertReason(revert)) + : err; +} + export class RelayService { constructor( private signer: RelayerSigner, @@ -67,13 +83,17 @@ export class RelayService { await this.rateLimiter.assertWithinLimit(voter, "vote"); // 4. Simulate the call. Covers: proposal state, hasVoted, signature validity. - await this.publicClient.simulateContract({ - address: this.governorAddress, - abi: governorAbi, - functionName: "castVoteBySig", - args: [params.proposalId, params.support, params.v, params.r, params.s], - account: await this.signer.getAddress(), - }); + try { + await this.publicClient.simulateContract({ + address: this.governorAddress, + abi: governorAbi, + functionName: "castVoteBySig", + args: [params.proposalId, params.support, params.v, params.r, params.s], + account: await this.signer.getAddress(), + }); + } catch (err) { + throw simulationError("castVoteBySig", err); + } // 5. Submit transaction const data = encodeFunctionData({ @@ -102,20 +122,24 @@ export class RelayService { await this.rateLimiter.assertWithinLimit(delegator, "delegation"); // 4. Simulate the call. Covers: nonce match, signature validity, expiry. - await this.publicClient.simulateContract({ - address: this.tokenAddress, - abi: erc20VotesAbi, - functionName: "delegateBySig", - args: [ - params.delegatee, - params.nonce, - params.expiry, - params.v, - params.r, - params.s, - ], - account: await this.signer.getAddress(), - }); + try { + await this.publicClient.simulateContract({ + address: this.tokenAddress, + abi: erc20VotesAbi, + functionName: "delegateBySig", + args: [ + params.delegatee, + params.nonce, + params.expiry, + params.v, + params.r, + params.s, + ], + account: await this.signer.getAddress(), + }); + } catch (err) { + throw simulationError("delegateBySig", err); + } // 5. Submit transaction const data = encodeFunctionData({ From 0b3185984583d33b6dffa497cccf557c7c541007 Mon Sep 17 00:00:00 2001 From: "Alexandro T. Netto" Date: Tue, 20 Oct 2026 10:48:58 -0300 Subject: [PATCH 2/4] fix(dashboard): say the vote is already counted and reload on every modal 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. --- .../components/modals/VotingModal.tsx | 26 +++++++------- .../shared/utils/gaslessRelayerError.test.ts | 34 +++++++++++++++++++ .../shared/utils/gaslessRelayerError.ts | 24 ++++++++++++- 3 files changed, 71 insertions(+), 13 deletions(-) diff --git a/apps/dashboard/features/governance/components/modals/VotingModal.tsx b/apps/dashboard/features/governance/components/modals/VotingModal.tsx index 3a4a4b24eb..82232ebad6 100644 --- a/apps/dashboard/features/governance/components/modals/VotingModal.tsx +++ b/apps/dashboard/features/governance/components/modals/VotingModal.tsx @@ -1,7 +1,7 @@ "use client"; import { Check, User2Icon, X } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import type { Address } from "viem"; import { formatUnits } from "viem"; import { useAccount, useWalletClient } from "wagmi"; @@ -52,6 +52,14 @@ export const VotingModal = ({ const [transactionhash, setTransactionhash] = useState(""); const [isSuccess, setIsSuccess] = useState(false); + // Every way out of the modal reloads after a successful vote, so the page + // stops offering "Vote": a second click re-signs the same ballot byte for + // byte, and the governor rejects it as already cast. + const handleClose = useCallback(() => { + onClose(); + if (isSuccess) window.location.reload(); + }, [onClose, isSuccess]); + const { isMobile } = useScreenSize(); // Tornado Cash's delegated vote path is binary only, with no abstain. @@ -210,7 +218,7 @@ export const VotingModal = ({ if (isMobile) return; const handleEscape = (event: KeyboardEvent) => { if (event.key === "Escape") { - onClose(); + handleClose(); } }; if (isOpen) { @@ -219,7 +227,7 @@ export const VotingModal = ({ return () => { document.removeEventListener("keydown", handleEscape); }; - }, [isOpen, onClose, isMobile]); + }, [isOpen, handleClose, isMobile]); const handleSubmit = async () => { if (!address || !chain || !walletClient) return; @@ -273,10 +281,7 @@ export const VotingModal = ({ + +); diff --git a/apps/dashboard/features/governance/components/modals/VotingModal.tsx b/apps/dashboard/features/governance/components/modals/VotingModal.tsx index 692550c25e..d57d50e540 100644 --- a/apps/dashboard/features/governance/components/modals/VotingModal.tsx +++ b/apps/dashboard/features/governance/components/modals/VotingModal.tsx @@ -11,6 +11,7 @@ import { getRateLimitQueryKey } from "@anticapture/client/hooks"; import type { GetRateLimitPathParamsDaoEnumKey } from "@anticapture/client"; import { LoadingComponent } from "@/features/governance/components/modals/LoadingContent"; +import { VoteAlreadyCastContent } from "@/features/governance/components/modals/VoteAlreadyCastContent"; import { VotePendingContent } from "@/features/governance/components/modals/VotePendingContent"; import { VoteSuccessContent } from "@/features/governance/components/modals/VoteSuccessContent"; import { VoteOption } from "@/features/governance/components/proposal-overview/VoteOption"; @@ -18,11 +19,11 @@ import { useVoteSubmission } from "@/features/governance/hooks/useVoteSubmission import type { ProposalDetails } from "@/features/governance/types"; import { relayGaslessVote } from "@/features/governance/utils/relayGaslessVote"; import { showCustomToast } from "@/features/governance/utils/showCustomToast"; -import { canSubmitAgain } from "@/features/governance/utils/submissionState"; import { readSubmission } from "@/features/governance/utils/submissionStore"; import { buildTornDelegatedVoteFrom } from "@/features/governance/utils/tornDelegatedVote"; import { voteOnProposal } from "@/features/governance/utils/voteOnProposal"; import { + canStartVote, claimVote, getVoteScreen, settlementForGaslessVote, @@ -86,6 +87,10 @@ export const VotingModal = ({ const [vote, setVote] = useState(""); const [comment, setComment] = useState(""); const [transactionhash, setTransactionhash] = useState(""); + // A vote that landed from this open of the modal. Where votes can be + // changed the store offers the ballot again, so this keeps the success + // screen up until the modal is closed. + const [hasLandedHere, setHasLandedHere] = useState(false); const { isMobile } = useScreenSize(); @@ -156,12 +161,18 @@ export const VotingModal = ({ const publicClient = usePublicClient({ chainId: daoChain.id }); const queryClient = useQueryClient(); + const canChangeVote = daoConfig.daoOverview.rules?.changeVote ?? false; const { key: voteKey, submission, - support: submittedSupport, + details: voteDetails, } = useVoteSubmission(daoId, proposal?.id, address); - const screen = getVoteScreen(submission); + const screen = hasLandedHere + ? "landed" + : getVoteScreen(submission, { + canChangeVote, + alreadyVoted: voteDetails.alreadyVoted, + }); const { delegators: tornDelegators, loading: isLoadingTornDelegators, @@ -233,11 +244,17 @@ export const VotingModal = ({ // used to put the ballot back while the relayer already had the vote. useEffect(() => { if (!isOpen) return; - if (voteKey !== null && !canSubmitAgain(readSubmission(voteKey))) return; + setHasLandedHere(false); + if ( + voteKey !== null && + !canStartVote(readSubmission(voteKey), canChangeVote) + ) { + return; + } setVote(""); setComment(""); setTransactionhash(""); - }, [isOpen, voteKey]); + }, [isOpen, voteKey, canChangeVote]); // Prevent body scroll when desktop modal is open useEffect(() => { @@ -289,7 +306,14 @@ export const VotingModal = ({ // Taken before the first await, so a double click or a reopened modal // finds the vote claimed and sends nothing. - if (!claimVote(voteKey, isGasless ? "gasless" : "wallet", voteNumber)) { + if ( + !claimVote( + voteKey, + isGasless ? "gasless" : "wallet", + voteNumber, + canChangeVote, + ) + ) { return; } setTransactionhash(""); @@ -316,12 +340,6 @@ export const VotingModal = ({ ), }); } - if (outcome.status === "already-voted") { - showCustomToast( - "You have already voted on this proposal.", - "success", - ); - } if (outcome.status === "reverted") { showCustomToast( "Your vote was mined but reverted on-chain.", @@ -365,6 +383,13 @@ export const VotingModal = ({ } } finally { settleVote(voteKey, settlement); + if ( + settlement.kind === "done" && + settlement.outcome === "landed" && + !settlement.alreadyVoted + ) { + setHasLandedHere(true); + } } }; @@ -405,6 +430,8 @@ export const VotingModal = ({ {/* Content */} {screen === "landed" ? ( + ) : screen === "already-voted" ? ( + ) : screen === "pending" ? ( + isSubmittedVoteIndexed(onchainVoteSubmission, latestVote), }); const { votingPower, rawVotingPower, votes } = accountPower; @@ -363,7 +368,12 @@ export const ProposalSection = ({ votes?.items[0]?.support != null ? Number(votes.items[0].support) : undefined; - const isOnchainVoteIndexed = indexedSupport !== undefined; + // Indexed means the submitted transaction itself: a changed vote leaves the + // previous one in the API until the new one is indexed. + const isOnchainVoteIndexed = isSubmittedVoteIndexed( + onchainVoteSubmission, + votes?.items[0], + ); // Once a submitted on-chain vote is indexed, the tally and the votes table // are refetched so they include it without a page reload. @@ -504,9 +514,14 @@ export const ProposalSection = ({ ); } - const supportValue = - indexedSupport ?? - submittedVoteSupport(onchainVoteSubmission, onchainVoteSupport); + // Until the submitted vote is indexed, the landed choice stands in for the + // indexed one, which is absent or still the vote it replaced. + const supportValue = isOnchainVoteIndexed + ? indexedSupport + : (submittedVoteSupport( + onchainVoteSubmission, + onchainVoteDetails.support, + ) ?? indexedSupport); return ( boolean; } +const anyVoteIndexed: NonNullable = ( + latestVote, +) => latestVote !== undefined; + const EMPTY_ACCOUNT_POWER_DATA: AccountPowerData = { accountPower: null, votingPower: "0", @@ -54,6 +63,7 @@ export const useAccountPower = ({ proposalId, decimals, votePollIntervalMs = false, + isVoteIndexed = anyVoteIndexed, }: UseAccountPowerParams): UseAccountPowerResult => { const votingPowerDaoKey = daoId.toLowerCase() as VotingPowerByAccountIdPathParamsDaoEnumKey; @@ -82,7 +92,7 @@ export const useAccountPower = ({ query: { enabled: !!address && !!proposalId, refetchInterval: (query) => - votePollIntervalMs && !query.state.data?.items.length + votePollIntervalMs && !isVoteIndexed(query.state.data?.items[0]) ? votePollIntervalMs : false, }, diff --git a/apps/dashboard/features/governance/hooks/useVoteSubmission.ts b/apps/dashboard/features/governance/hooks/useVoteSubmission.ts index 265e5c2299..5eece0e071 100644 --- a/apps/dashboard/features/governance/hooks/useVoteSubmission.ts +++ b/apps/dashboard/features/governance/hooks/useVoteSubmission.ts @@ -10,16 +10,16 @@ import { subscribeToSubmission, } from "@/features/governance/utils/submissionStore"; import { - readVoteSupport, + readVoteDetails, voteSubmissionKey, + type VoteDetails, } from "@/features/governance/utils/voteSubmission"; interface UseVoteSubmissionResult { /** Null until there is both a voter and a proposal to key the vote by. */ key: string | null; submission: SubmissionState; - /** The choice that was submitted, when one was. */ - support: number | null; + details: VoteDetails; } /** @@ -49,6 +49,8 @@ export const useVoteSubmission = ( return { key, submission, - support: key ? readVoteSupport(key) : null, + details: key + ? readVoteDetails(key) + : { support: null, alreadyVoted: false }, }; }; diff --git a/apps/dashboard/features/governance/utils/voteSubmission.test.ts b/apps/dashboard/features/governance/utils/voteSubmission.test.ts index 9361bc4414..864dc322ad 100644 --- a/apps/dashboard/features/governance/utils/voteSubmission.test.ts +++ b/apps/dashboard/features/governance/utils/voteSubmission.test.ts @@ -2,7 +2,8 @@ * The voting modal cannot be rendered here (node environment, no DOM), so * the decisions it takes are covered against these functions instead: the * claim that blocks a second submission, the screen a reopened modal shows, - * and what each relay outcome leaves in the store. + * what each relay outcome leaves in the store, and when the page may stop + * waiting for the indexer. * * The store is module-level, so every test uses a key of its own. */ @@ -10,11 +11,13 @@ import type { Address, Hash } from "viem"; import type { GaslessVoteOutcome } from "@/features/governance/utils/relayGaslessVote"; +import type { SubmissionState } from "@/features/governance/utils/submissionState"; import { readSubmission } from "@/features/governance/utils/submissionStore"; import { claimVote, getVoteScreen, - readVoteSupport, + isSubmittedVoteIndexed, + readVoteDetails, settleVote, settlementForGaslessVote, settlementForWalletVote, @@ -24,12 +27,22 @@ import { import { DaoIdEnum } from "@/shared/types/daos"; const hash: Hash = `0x${"ab".repeat(32)}`; +const otherHash: Hash = `0x${"cd".repeat(32)}`; const voter: Address = "0x76A6D08b82034b397E7e09dAe4377C18F132BbB8"; let keyCount = 0; const freshKey = () => voteSubmissionKey(DaoIdEnum.ENS, `proposal-${keyCount++}`, voter); +const landed: SubmissionState = { + kind: "done", + mode: "gasless", + outcome: "landed", + hash, +}; + +const fixedVote = { canChangeVote: false, alreadyVoted: false }; + describe("voteSubmissionKey", () => { it("keys one voter on one proposal, whatever the address casing", () => { expect(voteSubmissionKey(DaoIdEnum.ENS, "1", voter)).toBe( @@ -49,7 +62,7 @@ describe("claimVote", () => { expect(claimVote(key, "gasless", 1)).toBe(false); expect(claimVote(key, "wallet", 0)).toBe(false); expect(readSubmission(key)).toEqual({ kind: "in-flight", mode: "gasless" }); - expect(readVoteSupport(key)).toBe(1); + expect(readVoteDetails(key)).toEqual({ support: 1, alreadyVoted: false }); }); it("allows a new attempt only after one that changed nothing", () => { @@ -58,16 +71,34 @@ describe("claimVote", () => { settleVote(key, settlementForWalletVote(null)); expect(claimVote(key, "gasless", 0)).toBe(true); - expect(readVoteSupport(key)).toBe(0); + expect(readVoteDetails(key).support).toBe(0); }); - it("keeps a landed vote closed", () => { + it("keeps a landed vote closed where the governor fixes the first vote", () => { const key = freshKey(); claimVote(key, "gasless", 1); settleVote(key, settlementForGaslessVote({ status: "success", hash })); expect(claimVote(key, "gasless", 1)).toBe(false); }); + + it("lets a landed vote be changed where the DAO allows it", () => { + const key = freshKey(); + claimVote(key, "wallet", 1, true); + settleVote(key, settlementForWalletVote(hash)); + + expect(claimVote(key, "wallet", 0, true)).toBe(true); + expect(readVoteDetails(key).support).toBe(0); + }); + + it("never lets a change of vote race a submission still out or in doubt", () => { + const key = freshKey(); + claimVote(key, "wallet", 1, true); + expect(claimVote(key, "wallet", 0, true)).toBe(false); + + settleVote(key, { kind: "ambiguous", hash: null }); + expect(claimVote(key, "wallet", 0, true)).toBe(false); + }); }); describe("settleVote", () => { @@ -76,12 +107,7 @@ describe("settleVote", () => { claimVote(key, "gasless", 1); settleVote(key, settlementForGaslessVote({ status: "success", hash })); - expect(readSubmission(key)).toEqual({ - kind: "done", - mode: "gasless", - outcome: "landed", - hash, - }); + expect(readSubmission(key)).toEqual(landed); }); it("ignores a settle with no run in flight", () => { @@ -90,6 +116,22 @@ describe("settleVote", () => { expect(readSubmission(key)).toEqual({ kind: "idle" }); }); + + // The ballot on screen was never cast, so its choice must not be shown as + // the vote the governor holds. + it("forgets the attempted choice when the governor already held a vote", () => { + const key = freshKey(); + claimVote(key, "gasless", 0); + settleVote( + key, + settlementForGaslessVote({ status: "already-voted", hash: null }), + ); + + expect(readVoteDetails(key)).toEqual({ support: null, alreadyVoted: true }); + expect( + submittedVoteSupport(readSubmission(key), readVoteDetails(key).support), + ).toBeUndefined(); + }); }); describe("settlementForGaslessVote", () => { @@ -102,7 +144,7 @@ describe("settlementForGaslessVote", () => { ], [ { status: "already-voted", hash: null }, - { kind: "done", outcome: "landed", hash: null }, + { kind: "done", outcome: "landed", hash: null, alreadyVoted: true }, ], [ { status: "reverted", hash }, @@ -125,43 +167,43 @@ describe("settlementForGaslessVote", () => { describe("getVoteScreen", () => { it("shows the ballot only when a submission may start", () => { - expect(getVoteScreen({ kind: "idle" })).toBe("ballot"); + expect(getVoteScreen({ kind: "idle" }, fixedVote)).toBe("ballot"); expect( - getVoteScreen({ - kind: "done", - mode: "gasless", - outcome: "failed", - hash: null, - }), + getVoteScreen( + { kind: "done", mode: "gasless", outcome: "failed", hash: null }, + fixedVote, + ), ).toBe("ballot"); }); it("mirrors a submission that is out, landed or in doubt after a reopen", () => { - expect(getVoteScreen({ kind: "in-flight", mode: "gasless" })).toBe( - "in-flight", - ); expect( - getVoteScreen({ - kind: "done", - mode: "gasless", - outcome: "landed", - hash, - }), - ).toBe("landed"); - expect(getVoteScreen({ kind: "ambiguous", mode: "gasless", hash })).toBe( - "pending", - ); + getVoteScreen({ kind: "in-flight", mode: "gasless" }, fixedVote), + ).toBe("in-flight"); + expect(getVoteScreen(landed, fixedVote)).toBe("landed"); + expect( + getVoteScreen({ kind: "ambiguous", mode: "gasless", hash }, fixedVote), + ).toBe("pending"); + }); + + it("says so, with no way to vote, when the governor already held a vote", () => { + expect( + getVoteScreen( + { kind: "done", mode: "gasless", outcome: "landed", hash: null }, + { canChangeVote: false, alreadyVoted: true }, + ), + ).toBe("already-voted"); + }); + + it("offers the ballot again for a landed vote where votes can be changed", () => { + expect( + getVoteScreen(landed, { canChangeVote: true, alreadyVoted: false }), + ).toBe("ballot"); }); }); describe("submittedVoteSupport", () => { it("names the vote only once it is known to be on-chain", () => { - const landed = { - kind: "done" as const, - mode: "gasless" as const, - outcome: "landed" as const, - hash, - }; expect(submittedVoteSupport(landed, 1)).toBe(1); expect( submittedVoteSupport({ kind: "ambiguous", mode: "gasless", hash }, 1), @@ -172,3 +214,37 @@ describe("submittedVoteSupport", () => { expect(submittedVoteSupport(landed, null)).toBeUndefined(); }); }); + +describe("isSubmittedVoteIndexed", () => { + it("has nothing to wait for without a submission that may be on-chain", () => { + expect(isSubmittedVoteIndexed({ kind: "idle" }, undefined)).toBe(true); + expect( + isSubmittedVoteIndexed({ kind: "in-flight", mode: "wallet" }, undefined), + ).toBe(true); + }); + + // A changed vote: the API still returns the previous one until the new + // transaction is indexed, so "a vote exists" is not enough. + it("waits for the submitted transaction itself, not any earlier vote", () => { + expect(isSubmittedVoteIndexed(landed, { transactionHash: otherHash })).toBe( + false, + ); + expect( + isSubmittedVoteIndexed(landed, { transactionHash: hash.toUpperCase() }), + ).toBe(true); + expect(isSubmittedVoteIndexed(landed, undefined)).toBe(false); + }); + + it("settles for any indexed vote when the transaction is unknown", () => { + const alreadyVoted: SubmissionState = { + kind: "done", + mode: "gasless", + outcome: "landed", + hash: null, + }; + expect(isSubmittedVoteIndexed(alreadyVoted, undefined)).toBe(false); + expect( + isSubmittedVoteIndexed(alreadyVoted, { transactionHash: otherHash }), + ).toBe(true); + }); +}); diff --git a/apps/dashboard/features/governance/utils/voteSubmission.ts b/apps/dashboard/features/governance/utils/voteSubmission.ts index 49515fcd39..8d0ecd41f9 100644 --- a/apps/dashboard/features/governance/utils/voteSubmission.ts +++ b/apps/dashboard/features/governance/utils/voteSubmission.ts @@ -3,6 +3,7 @@ import type { Address, Hash } from "viem"; import type { GaslessVoteOutcome } from "@/features/governance/utils/relayGaslessVote"; import { canSubmitAgain, + needsProposalWatch, type ActionMode, type SubmissionState, } from "@/features/governance/utils/submissionState"; @@ -26,14 +27,41 @@ export const voteSubmissionKey = ( ): string => submissionKey(daoId, proposalId, `vote:${voter.toLowerCase()}`); /** - * The choice that was submitted, so the page can say "You voted For" before - * the indexer has caught up. Kept beside the store rather than in it, since - * queue and execute have no equivalent. + * What only a vote has beside the shared submission state, kept next to the + * store rather than in it since queue and execute have no equivalent. + * + * - `support`: the choice that was submitted, so the page can say "You voted + * For" before the indexer catches up. Null when no ballot was cast. + * - `alreadyVoted`: the governor already held a vote from this address, so the + * attempted ballot was never cast and its choice says nothing about the + * vote on record. */ -const supportByKey = new Map(); +export interface VoteDetails { + support: number | null; + alreadyVoted: boolean; +} -export const readVoteSupport = (key: string): number | null => - supportByKey.get(key) ?? null; +const NO_VOTE_DETAILS: VoteDetails = { support: null, alreadyVoted: false }; + +const detailsByKey = new Map(); + +export const readVoteDetails = (key: string): VoteDetails => + detailsByKey.get(key) ?? NO_VOTE_DETAILS; + +/** + * Whether a new ballot may be submitted. A vote that is out or in doubt never + * allows one. A landed vote allows one only where the governor lets a voter + * change their vote (UNI, TORN); everywhere else a second ballot could only + * replay the first. + */ +export const canStartVote = ( + submission: SubmissionState, + canChangeVote: boolean, +): boolean => + canSubmitAgain(submission) || + (canChangeVote && + submission.kind === "done" && + submission.outcome === "landed"); /** * Claims the vote for one submission. Synchronous on purpose: it runs before @@ -44,22 +72,39 @@ export const claimVote = ( key: string, mode: ActionMode, support: number, + canChangeVote = false, ): boolean => { - if (!canSubmitAgain(readSubmission(key))) return false; - supportByKey.set(key, support); + if (!canStartVote(readSubmission(key), canChangeVote)) return false; + detailsByKey.set(key, { support, alreadyVoted: false }); writeSubmission(key, { kind: "in-flight", mode }); return true; }; /** Where a run leaves the vote, without the mode the claim already holds. */ export type VoteSettlement = - | { kind: "done"; outcome: "landed" | "failed"; hash: Hash | null } + | { + kind: "done"; + outcome: "landed" | "failed"; + hash: Hash | null; + /** No ballot was cast: the governor already held a vote. */ + alreadyVoted?: true; + } | { kind: "ambiguous"; hash: Hash | null }; /** Settles the run in flight; a settle with nothing claimed is ignored. */ export const settleVote = (key: string, settlement: VoteSettlement): void => { const current = readSubmission(key); if (current.kind !== "in-flight") return; + if (settlement.kind === "done" && settlement.alreadyVoted) { + detailsByKey.set(key, { support: null, alreadyVoted: true }); + writeSubmission(key, { + kind: "done", + mode: current.mode, + outcome: settlement.outcome, + hash: settlement.hash, + }); + return; + } writeSubmission(key, { ...settlement, mode: current.mode }); }; @@ -74,8 +119,14 @@ export const settlementForGaslessVote = ( ): VoteSettlement => { switch (outcome.status) { case "success": - case "already-voted": return { kind: "done", outcome: "landed", hash: outcome.hash }; + case "already-voted": + return { + kind: "done", + outcome: "landed", + hash: null, + alreadyVoted: true, + }; case "reverted": return { kind: "done", outcome: "failed", hash: outcome.hash }; case "unconfirmed": @@ -91,16 +142,28 @@ export const settlementForWalletVote = (hash: Hash | null): VoteSettlement => : { kind: "done", outcome: "failed", hash: null }; /** - * What the voting modal shows for a submission. Only an untouched vote, or - * one whose last attempt changed nothing, gets the ballot; every other state - * is mirrored on any open, so no screen offers a second submission. + * 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. */ -export type VoteScreen = "ballot" | "in-flight" | "landed" | "pending"; +export type VoteScreen = + | "ballot" + | "in-flight" + | "landed" + | "pending" + | "already-voted"; -export const getVoteScreen = (submission: SubmissionState): VoteScreen => { - if (canSubmitAgain(submission)) return "ballot"; +export const getVoteScreen = ( + submission: SubmissionState, + { + canChangeVote, + alreadyVoted, + }: { canChangeVote: boolean; alreadyVoted: boolean }, +): VoteScreen => { if (submission.kind === "in-flight") return "in-flight"; if (submission.kind === "ambiguous") return "pending"; + if (alreadyVoted && !canSubmitAgain(submission)) return "already-voted"; + if (canStartVote(submission, canChangeVote)) return "ballot"; return "landed"; }; @@ -117,3 +180,22 @@ export const submittedVoteSupport = ( support !== null ? support : undefined; + +/** + * Whether the API has caught up with the submitted vote. With the transaction + * known, only that transaction counts: a changed vote leaves the previous one + * indexed until the new one lands. Without it (the governor already held a + * vote), any indexed vote is the one on record. + */ +export const isSubmittedVoteIndexed = ( + submission: SubmissionState, + latestIndexedVote: { transactionHash: string } | undefined, +): boolean => { + if (!needsProposalWatch(submission)) return true; + if (!latestIndexedVote) return false; + if (!submission.hash) return true; + return ( + latestIndexedVote.transactionHash.toLowerCase() === + submission.hash.toLowerCase() + ); +}; diff --git a/apps/dashboard/features/holders-and-delegates/delegate/DelegationModal.tsx b/apps/dashboard/features/holders-and-delegates/delegate/DelegationModal.tsx index b72899a2cb..fc7fd5d896 100644 --- a/apps/dashboard/features/holders-and-delegates/delegate/DelegationModal.tsx +++ b/apps/dashboard/features/holders-and-delegates/delegate/DelegationModal.tsx @@ -1,11 +1,15 @@ "use client"; +import { useQueryClient } from "@tanstack/react-query"; import { Check, Hourglass, PenLine } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import type { Address } from "viem"; import { formatUnits } from "viem"; import { useAccount, useReadContract, useWalletClient } from "wagmi"; +import { getRateLimitQueryKey } from "@anticapture/client/hooks"; +import type { GetRateLimitPathParamsDaoEnumKey } from "@anticapture/client"; + import { delegateTo } from "@/features/holders-and-delegates/delegate/utils/delegateTo"; import { showCustomToast } from "@/features/governance/utils/showCustomToast"; import { @@ -54,6 +58,7 @@ export const DelegationModal = ({ onSuccess, }: DelegationModalProps) => { const { address: userAddress } = useAccount(); + const queryClient = useQueryClient(); const [step, setStep] = useState("waiting-signature"); const [error, setError] = useState(null); @@ -89,6 +94,7 @@ export const DelegationModal = ({ setError(null); setStep("waiting-signature"); + const isGasless = isGaslessEligible && daoConfig?.gaslessRelayer === true; try { await delegateTo( tokenAddress, @@ -110,7 +116,7 @@ export const DelegationModal = ({ return; } - if (isGaslessEligible && daoConfig?.gaslessRelayer) { + if (isGasless) { const message = mapRelayerError(err, { operation: "delegate", minVotingPower, @@ -125,6 +131,18 @@ export const DelegationModal = ({ setError(message); } setStep("error"); + } finally { + // The relayer counts each delegation against a monthly allowance, and + // the cached count is stale for a minute: without this, a delegation + // right after the last free one still goes to the relayer and gets 429. + if (isGasless) { + void queryClient.invalidateQueries({ + queryKey: getRateLimitQueryKey( + daoId.toLowerCase() as GetRateLimitPathParamsDaoEnumKey, + userAddress, + ), + }); + } } }, [ userAddress, @@ -139,6 +157,7 @@ export const DelegationModal = ({ decimals, minVotingPower, isGaslessEligible, + queryClient, ]); useEffect(() => { diff --git a/apps/dashboard/shared/utils/gaslessRelayerError.test.ts b/apps/dashboard/shared/utils/gaslessRelayerError.test.ts index a9d3fe65c1..32d529a686 100644 --- a/apps/dashboard/shared/utils/gaslessRelayerError.test.ts +++ b/apps/dashboard/shared/utils/gaslessRelayerError.test.ts @@ -174,9 +174,7 @@ describe("mapRelayerError", () => { ), { ...context, operation: "delegate" }, ), - ).toBe( - "This delegation would revert on-chain, so it was not sent. No gas was spent.", - ); + ).toBe("This delegation couldn't be completed, so nothing was sent."); }); it("falls back to the generic message", () => { diff --git a/apps/dashboard/shared/utils/gaslessRelayerError.ts b/apps/dashboard/shared/utils/gaslessRelayerError.ts index 15bc880261..b06ad63a47 100644 --- a/apps/dashboard/shared/utils/gaslessRelayerError.ts +++ b/apps/dashboard/shared/utils/gaslessRelayerError.ts @@ -206,7 +206,7 @@ export const mapRelayerError = ( return "You have already voted on this proposal."; } const noun = context.operation === "vote" ? "vote" : "delegation"; - return `This ${noun} would revert on-chain, so it was not sent. No gas was spent.`; + return `This ${noun} couldn't be completed, so nothing was sent.`; } if (code === INSUFFICIENT_VOTING_POWER) {