- {!isLoading && showTornDelegatorsError && (
+ {showTornDelegatorsError && (
Could not load your delegators, so voting is disabled.{" "}
)}
- {!isLoading && showTornDelegatorsLoading && (
+ {showTornDelegatorsLoading && (
Loading your delegators...
@@ -407,7 +559,6 @@ export const VotingModal = ({
data-ph-source="gov_fe"
data-umami-event="vote_submit"
disabled={submitDisabled}
- loading={isLoading}
onClick={handleSubmit}
>
Submit
@@ -436,10 +587,7 @@ export const VotingModal = ({
{/* Backdrop with blur */}
{
- onClose();
- if (isSuccess) window.location.reload();
- }}
+ onClick={onClose}
aria-hidden="true"
/>
diff --git a/apps/dashboard/features/governance/components/proposal-overview/ProposalHeader.tsx b/apps/dashboard/features/governance/components/proposal-overview/ProposalHeader.tsx
index ce00d588d4..5c12af4fdc 100644
--- a/apps/dashboard/features/governance/components/proposal-overview/ProposalHeader.tsx
+++ b/apps/dashboard/features/governance/components/proposal-overview/ProposalHeader.tsx
@@ -1,6 +1,5 @@
"use client";
-import type { VotesByProposalIdQueryResponse } from "@anticapture/client";
import { ArrowRight, ChevronRight } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
@@ -30,7 +29,8 @@ interface ProposalHeaderProps {
setIsQueueModalOpen: (isOpen: boolean) => void;
setIsExecuteModalOpen: (isOpen: boolean) => void;
votingPower: string;
- votes: VotesByProposalIdQueryResponse | null;
+ /** The connected account's vote: indexed, or submitted and landed. */
+ supportValue: number | undefined;
address: string | undefined;
proposalStatus: string;
snapshotLink?: string | null;
@@ -243,7 +243,7 @@ export const GaslessBadge = () => (
export const ProposalHeader = ({
daoId,
votingPower,
- votes,
+ supportValue,
setIsVotingModalOpen,
setIsQueueModalOpen,
setIsExecuteModalOpen,
@@ -265,10 +265,6 @@ export const ProposalHeader = ({
"tab",
parseAsStringEnum
(allowedTabs).withDefault("description"),
);
- const supportValue =
- votes?.items[0]?.support != null
- ? Number(votes.items[0].support)
- : undefined;
const proposalListHref = getDaoGovernanceListPath({
daoId: daoId.toUpperCase() as DaoIdEnum,
pathname,
diff --git a/apps/dashboard/features/governance/components/proposal-overview/ProposalSection.tsx b/apps/dashboard/features/governance/components/proposal-overview/ProposalSection.tsx
index 41172f927c..c18c9c861d 100644
--- a/apps/dashboard/features/governance/components/proposal-overview/ProposalSection.tsx
+++ b/apps/dashboard/features/governance/components/proposal-overview/ProposalSection.tsx
@@ -1,9 +1,14 @@
"use client";
-import type { VotesOffchainByProposalIdPathParamsDaoEnumKey } from "@anticapture/client";
+import type {
+ ProposalPathParamsDaoEnumKey,
+ VotesOffchainByProposalIdPathParamsDaoEnumKey,
+} from "@anticapture/client";
import {
offchainProposalByIdQueryKey,
+ proposalQueryKey,
useVotesOffchainByProposalId,
+ votesByProposalIdQueryKey,
votesOffchainByProposalIdQueryKey,
} from "@anticapture/client/hooks";
import { useQueryClient } from "@tanstack/react-query";
@@ -35,12 +40,18 @@ import { useOffchainProposalPrivacy } from "@/features/governance/hooks/useOffch
import { useOffchainVoteIndexing } from "@/features/governance/hooks/useOffchainVoteIndexing";
import { useCalldataReviews } from "@/features/governance/hooks/useCalldataReview";
import { useProposal } from "@/features/governance/hooks/useProposal";
+import { useVoteSubmission } from "@/features/governance/hooks/useVoteSubmission";
import type {
ProposalDetails,
ProposalViewData,
} from "@/features/governance/types";
import { isProposalNotFoundError } from "@/features/governance/utils/proposalErrors";
import { canRelayGovernanceAction } from "@/features/governance/utils/relayGovernanceAction";
+import { needsProposalWatch } from "@/features/governance/utils/submissionState";
+import {
+ isSubmittedVoteIndexed,
+ submittedVoteSupport,
+} from "@/features/governance/utils/voteSubmission";
import {
getOffchainProposalStatusView,
normalizeChoices,
@@ -63,6 +74,13 @@ import { DaoIdEnum } from "@/shared/types/daos";
/** How often to re-ask the API for a vote Snapshot has already accepted. */
const VOTE_INDEXING_POLL_MS = 5_000;
+/**
+ * How long to keep asking for an on-chain vote that was submitted but is not
+ * indexed yet. Long enough for a slow block and the indexer, short enough
+ * that a vote that never landed does not poll for the life of the tab.
+ */
+const ONCHAIN_VOTE_WATCH_MS = 120_000;
+
/**
* Identity of the indexed vote a submission replaces. Snapshot votes carry no
* id, and a revote overwrites the previous one, so "the API returned a vote" is
@@ -161,11 +179,37 @@ export const ProposalSection = ({
const offchainDaoKey =
daoEnum.toLowerCase() as VotesOffchainByProposalIdPathParamsDaoEnumKey;
+ // The connected wallet's on-chain vote as the voting modal left it. It
+ // outlives the modal, so the page stops offering "Vote" the moment a vote is
+ // out, instead of when the indexer catches up.
+ const {
+ key: onchainVoteKey,
+ submission: onchainVoteSubmission,
+ details: onchainVoteDetails,
+ } = useVoteSubmission(daoEnum, isOffchain ? undefined : proposalId, address);
+ const isWatchingOnchainVote = needsProposalWatch(onchainVoteSubmission);
+ const [isOnchainVoteWatchOver, setIsOnchainVoteWatchOver] = useState(false);
+ useEffect(() => {
+ setIsOnchainVoteWatchOver(false);
+ if (!isWatchingOnchainVote) return;
+ const timer = setTimeout(
+ () => setIsOnchainVoteWatchOver(true),
+ ONCHAIN_VOTE_WATCH_MS,
+ );
+ return () => clearTimeout(timer);
+ }, [isWatchingOnchainVote, onchainVoteKey]);
+
const { data: accountPower } = useAccountPower({
address: address ?? "",
daoId: daoEnum,
proposalId: isOffchain ? "" : proposalId,
decimals,
+ votePollIntervalMs:
+ isWatchingOnchainVote && !isOnchainVoteWatchOver
+ ? VOTE_INDEXING_POLL_MS
+ : false,
+ isVoteIndexed: (latestVote) =>
+ isSubmittedVoteIndexed(onchainVoteSubmission, latestVote),
});
const { votingPower, rawVotingPower, votes } = accountPower;
@@ -320,6 +364,37 @@ export const ProposalSection = ({
const queryClient = useQueryClient();
+ const indexedSupport =
+ votes?.items[0]?.support != null
+ ? Number(votes.items[0].support)
+ : 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.
+ useEffect(() => {
+ if (isOffchain || !isWatchingOnchainVote || !isOnchainVoteIndexed) return;
+ const daoKey = daoEnum.toLowerCase() as ProposalPathParamsDaoEnumKey;
+ void queryClient.invalidateQueries({
+ queryKey: proposalQueryKey(daoKey, proposalId),
+ });
+ void queryClient.invalidateQueries({
+ queryKey: votesByProposalIdQueryKey(daoKey, proposalId),
+ });
+ }, [
+ isOffchain,
+ isWatchingOnchainVote,
+ isOnchainVoteIndexed,
+ queryClient,
+ daoEnum,
+ proposalId,
+ ]);
+
// The tally is refetched again the moment the vote lands: the invalidation at
// signature time races the indexer and usually loses.
useEffect(() => {
@@ -439,10 +514,14 @@ export const ProposalSection = ({
);
}
- const supportValue =
- votes?.items[0]?.support != null
- ? Number(votes.items[0].support)
- : undefined;
+ // 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",
@@ -48,6 +62,8 @@ export const useAccountPower = ({
daoId,
proposalId,
decimals,
+ votePollIntervalMs = false,
+ isVoteIndexed = anyVoteIndexed,
}: UseAccountPowerParams): UseAccountPowerResult => {
const votingPowerDaoKey =
daoId.toLowerCase() as VotingPowerByAccountIdPathParamsDaoEnumKey;
@@ -75,6 +91,10 @@ export const useAccountPower = ({
{
query: {
enabled: !!address && !!proposalId,
+ refetchInterval: (query) =>
+ 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
new file mode 100644
index 0000000000..5eece0e071
--- /dev/null
+++ b/apps/dashboard/features/governance/hooks/useVoteSubmission.ts
@@ -0,0 +1,56 @@
+import { useCallback, useSyncExternalStore } from "react";
+import type { Address } from "viem";
+
+import {
+ IDLE_SUBMISSION,
+ type SubmissionState,
+} from "@/features/governance/utils/submissionState";
+import {
+ readSubmission,
+ subscribeToSubmission,
+} from "@/features/governance/utils/submissionStore";
+import {
+ 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;
+ details: VoteDetails;
+}
+
+/**
+ * The connected wallet's vote on one proposal, read from the page-lifetime
+ * store, so the modal and the proposal page agree on it through any close,
+ * reopen or remount.
+ */
+export const useVoteSubmission = (
+ daoId: string,
+ proposalId: string | undefined,
+ voter: Address | undefined,
+): UseVoteSubmissionResult => {
+ const key =
+ voter && proposalId ? voteSubmissionKey(daoId, proposalId, voter) : null;
+
+ const subscribe = useCallback(
+ (onChange: () => void) =>
+ key ? subscribeToSubmission(key, onChange) : () => {},
+ [key],
+ );
+ const read = useCallback(
+ () => (key ? readSubmission(key) : IDLE_SUBMISSION),
+ [key],
+ );
+ const submission = useSyncExternalStore(subscribe, read, read);
+
+ return {
+ key,
+ submission,
+ details: key
+ ? readVoteDetails(key)
+ : { support: null, alreadyVoted: false },
+ };
+};
diff --git a/apps/dashboard/features/governance/utils/relayGaslessVote.test.ts b/apps/dashboard/features/governance/utils/relayGaslessVote.test.ts
new file mode 100644
index 0000000000..0bbabd0168
--- /dev/null
+++ b/apps/dashboard/features/governance/utils/relayGaslessVote.test.ts
@@ -0,0 +1,250 @@
+import type { Account, Address, Hash, Hex } from "viem";
+
+import { relayVote } from "@anticapture/client";
+
+import {
+ relayGaslessVote,
+ type BallotSigner,
+ type VoteChainReader,
+} from "@/features/governance/utils/relayGaslessVote";
+import daoConfigByDaoId from "@/shared/dao-config";
+import { DaoIdEnum } from "@/shared/types/daos";
+
+jest.mock("@anticapture/client", () => ({
+ relayVote: jest.fn(),
+}));
+
+const mockedRelayVote = relayVote as jest.MockedFunction;
+
+const voter: Address = "0x76A6D08b82034b397E7e09dAe4377C18F132BbB8";
+const account: Account = { address: voter, type: "json-rpc" };
+const transactionHash: Hash = `0x${"ab".repeat(32)}`;
+const proposalId =
+ "51915723803355532053585313225201979004629663092454892570706465301184496475762";
+// A real 65-byte signature shape: r, s, then v = 0x1c (28).
+const signature: Hex = `0x${"d7".repeat(32)}${"67".repeat(32)}1c`;
+
+const ensGovernor = daoConfigByDaoId[DaoIdEnum.ENS].daoOverview.contracts
+ .governor as Address;
+
+const relayerError = (status: number, code?: string, message?: string) =>
+ Object.assign(new Error("Request failed"), {
+ status,
+ response: {
+ status,
+ statusText: "",
+ headers: new Headers(),
+ data: code ? { code, error: message ?? code } : { error: "boom" },
+ },
+ });
+
+/**
+ * The chain reader the flow is given: `hasVoted` and `name` answered by
+ * function name, plus the receipt wait. Typed against the real interface so
+ * a change to it surfaces here as a type error.
+ */
+const makeReader = ({
+ hasVoted = false,
+ hasVotedError,
+ receipt = Promise.resolve({ status: "success" as const }),
+}: {
+ hasVoted?: boolean;
+ hasVotedError?: Error;
+ receipt?: Promise<{ status: "success" | "reverted" }>;
+} = {}) => {
+ const readContract = jest.fn(
+ async ({ functionName }: { functionName: string }) => {
+ if (functionName === "hasVoted") {
+ if (hasVotedError) throw hasVotedError;
+ return hasVoted;
+ }
+ if (functionName === "name") return "ENS Governor";
+ throw new Error(`unexpected read ${functionName}`);
+ },
+ );
+ const waitForTransactionReceipt = jest.fn(() => receipt);
+ const reader = {
+ readContract,
+ waitForTransactionReceipt,
+ } as unknown as VoteChainReader;
+ return { reader, readContract, waitForTransactionReceipt };
+};
+
+const makeSigner = (sign: () => Promise = async () => signature) => {
+ const signTypedData = jest.fn(sign);
+ const signer = { signTypedData } as unknown as BallotSigner;
+ return { signer, signTypedData };
+};
+
+const run = (
+ reader: VoteChainReader,
+ signer: BallotSigner,
+ onTxSubmitted: (hash: Hash) => void = () => {},
+) =>
+ relayGaslessVote({
+ daoId: DaoIdEnum.ENS,
+ proposalId,
+ support: 1,
+ account,
+ signer,
+ publicClient: reader,
+ onTxSubmitted,
+ });
+
+describe("relayGaslessVote", () => {
+ beforeEach(() => {
+ mockedRelayVote.mockReset();
+ jest.spyOn(console, "error").mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ it("asks for no signature and makes no relay call when the vote is already on-chain", async () => {
+ const { reader, readContract } = makeReader({ hasVoted: true });
+ const { signer, signTypedData } = makeSigner();
+
+ const outcome = await run(reader, signer);
+
+ expect(outcome).toEqual({ status: "already-voted", hash: null });
+ expect(readContract).toHaveBeenCalledWith(
+ expect.objectContaining({
+ address: ensGovernor,
+ functionName: "hasVoted",
+ args: [BigInt(proposalId), voter],
+ }),
+ );
+ expect(signTypedData).not.toHaveBeenCalled();
+ expect(mockedRelayVote).not.toHaveBeenCalled();
+ });
+
+ it("signs the ballot, relays it exactly once and confirms on the DAO chain", async () => {
+ const { reader, waitForTransactionReceipt } = makeReader();
+ const { signer, signTypedData } = makeSigner();
+ const onTxSubmitted = jest.fn();
+ mockedRelayVote.mockResolvedValue({ transactionHash, voter });
+
+ const outcome = await run(reader, signer, onTxSubmitted);
+
+ expect(outcome).toEqual({ status: "success", hash: transactionHash });
+ expect(signTypedData).toHaveBeenCalledWith(
+ expect.objectContaining({
+ account,
+ domain: {
+ name: "ENS Governor",
+ version: "1",
+ chainId: 1,
+ verifyingContract: ensGovernor,
+ },
+ primaryType: "Ballot",
+ message: { proposalId: BigInt(proposalId), support: 1 },
+ }),
+ );
+ expect(mockedRelayVote).toHaveBeenCalledTimes(1);
+ expect(mockedRelayVote).toHaveBeenCalledWith("ens", {
+ proposalId,
+ support: 1,
+ r: `0x${"d7".repeat(32)}`,
+ s: `0x${"67".repeat(32)}`,
+ v: 28,
+ });
+ expect(onTxSubmitted).toHaveBeenCalledWith(transactionHash);
+ expect(waitForTransactionReceipt).toHaveBeenCalledWith({
+ hash: transactionHash,
+ });
+ });
+
+ // The production incident: the relay answered 200, then the receipt wait
+ // failed and the screen offered the same vote again.
+ it("reports a relayed vote whose receipt wait fails as unconfirmed, not as an error", async () => {
+ const { reader } = makeReader({
+ receipt: Promise.reject(new Error("wallet RPC refused the call")),
+ });
+ const { signer } = makeSigner();
+ mockedRelayVote.mockResolvedValue({ transactionHash, voter });
+
+ const outcome = await run(reader, signer);
+
+ expect(outcome).toEqual({ status: "unconfirmed", hash: transactionHash });
+ expect(mockedRelayVote).toHaveBeenCalledTimes(1);
+ });
+
+ it("reports a mined revert as reverted", async () => {
+ const { reader } = makeReader({
+ receipt: Promise.resolve({ status: "reverted" }),
+ });
+ const { signer } = makeSigner();
+ mockedRelayVote.mockResolvedValue({ transactionHash, voter });
+
+ await expect(run(reader, signer)).resolves.toEqual({
+ status: "reverted",
+ hash: transactionHash,
+ });
+ });
+
+ it.each([
+ ["a 5xx without a relayer code", relayerError(500)],
+ ["a crash wearing the error contract", relayerError(500, "INTERNAL")],
+ ["a transport failure", new TypeError("Failed to fetch")],
+ ])(
+ "reports %s as unknown, since the vote may already be on its way",
+ async (_label, error) => {
+ const { reader } = makeReader();
+ const { signer } = makeSigner();
+ mockedRelayVote.mockRejectedValue(error);
+
+ await expect(run(reader, signer)).resolves.toEqual({
+ status: "unknown",
+ hash: null,
+ });
+ },
+ );
+
+ it("reads the governor's 'already cast' refusal as a vote that exists", async () => {
+ const { reader } = makeReader();
+ const { signer } = makeSigner();
+ mockedRelayVote.mockRejectedValue(
+ relayerError(
+ 409,
+ "SIMULATION_FAILED",
+ "Simulation of castVoteBySig() reverted: GovernorVotingSimple: vote already cast",
+ ),
+ );
+
+ await expect(run(reader, signer)).resolves.toEqual({
+ status: "already-voted",
+ hash: null,
+ });
+ });
+
+ it("rethrows a refusal made before anything was sent, so the voter can retry", async () => {
+ const { reader } = makeReader();
+ const { signer } = makeSigner();
+ const refusal = relayerError(400, "INSUFFICIENT_VOTING_POWER");
+ mockedRelayVote.mockRejectedValue(refusal);
+
+ await expect(run(reader, signer)).rejects.toBe(refusal);
+ });
+
+ it("rethrows a signature the user rejected, without calling the relayer", async () => {
+ const { reader } = makeReader();
+ const rejection = new Error("User rejected the request.");
+ const { signer } = makeSigner(() => Promise.reject(rejection));
+
+ await expect(run(reader, signer)).rejects.toBe(rejection);
+ expect(mockedRelayVote).not.toHaveBeenCalled();
+ });
+
+ it("still lets the voter sign when the on-chain hasVoted check cannot run", async () => {
+ const { reader } = makeReader({ hasVotedError: new Error("rpc down") });
+ const { signer, signTypedData } = makeSigner();
+ mockedRelayVote.mockResolvedValue({ transactionHash, voter });
+
+ await expect(run(reader, signer)).resolves.toEqual({
+ status: "success",
+ hash: transactionHash,
+ });
+ expect(signTypedData).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/apps/dashboard/features/governance/utils/relayGaslessVote.ts b/apps/dashboard/features/governance/utils/relayGaslessVote.ts
new file mode 100644
index 0000000000..d02a9ac583
--- /dev/null
+++ b/apps/dashboard/features/governance/utils/relayGaslessVote.ts
@@ -0,0 +1,187 @@
+import { parseSignature } from "viem";
+import type { Account, Address, Hash, PublicClient, WalletClient } from "viem";
+
+import { relayVote } from "@anticapture/client";
+import type { RelayVotePathParamsDaoEnumKey } from "@anticapture/client";
+
+import daoConfigByDaoId from "@/shared/dao-config";
+import type { DaoIdEnum } from "@/shared/types/daos";
+import {
+ classifyRelayerFailure,
+ isAlreadyVotedRejection,
+} from "@/shared/utils/gaslessRelayerError";
+
+const GovernorBallotAbi = [
+ {
+ inputs: [],
+ name: "name",
+ outputs: [{ internalType: "string", name: "", type: "string" }],
+ stateMutability: "view",
+ type: "function",
+ },
+ {
+ inputs: [
+ { internalType: "uint256", name: "proposalId", type: "uint256" },
+ { internalType: "address", name: "account", type: "address" },
+ ],
+ name: "hasVoted",
+ outputs: [{ internalType: "bool", name: "", type: "bool" }],
+ stateMutability: "view",
+ type: "function",
+ },
+] as const;
+
+const BALLOT_TYPES = {
+ Ballot: [
+ { name: "proposalId", type: "uint256" },
+ { name: "support", type: "uint8" },
+ ],
+} as const;
+
+/**
+ * Reads and the receipt wait go to a client for the DAO's own chain. The
+ * wallet never sent the relayed transaction, and its provider may sit on
+ * another chain or refuse the call, which used to surface as a failed vote
+ * after the relayer had already broadcast it.
+ */
+export type VoteChainReader = Pick<
+ PublicClient,
+ "readContract" | "waitForTransactionReceipt"
+>;
+
+/** The only wallet capability the gasless path needs. */
+export type BallotSigner = Pick;
+
+/**
+ * What the dashboard knows about a gasless vote once the call settles.
+ *
+ * - "success" / "reverted": the receipt was read on the DAO's chain.
+ * - "unconfirmed": the relayer returned a hash but the receipt check failed
+ * or timed out. The vote is on its way, not failed.
+ * - "already-voted": the governor already holds a vote from this address,
+ * seen before signing or reported by the relayer's simulation.
+ * - "unknown": the relay request failed without a relayer rejection code
+ * (network error, gateway timeout, plain 5xx). The relayer may already have
+ * broadcast the vote, so this must not be shown as a failure or retried.
+ */
+export type GaslessVoteOutcome =
+ | { status: "success" | "reverted" | "unconfirmed"; hash: Hash }
+ | { status: "already-voted" | "unknown"; hash: null };
+
+type RelayGaslessVoteParams = {
+ daoId: DaoIdEnum;
+ proposalId: string;
+ support: number;
+ account: Account;
+ signer: BallotSigner;
+ publicClient: VoteChainReader;
+ onTxSubmitted: (hash: Hash) => void;
+};
+
+/**
+ * A read that cannot run says nothing, so it lets the vote through: the
+ * relayer simulates before sending and refuses a duplicate on its own.
+ */
+const hasVotedOnChain = async (
+ publicClient: VoteChainReader,
+ governor: Address,
+ proposalId: string,
+ voter: Address,
+): Promise => {
+ try {
+ return await publicClient.readContract({
+ abi: GovernorBallotAbi,
+ address: governor,
+ functionName: "hasVoted",
+ args: [BigInt(proposalId), voter],
+ });
+ } catch (error) {
+ console.error(error);
+ return false;
+ }
+};
+
+/**
+ * Casts a vote through the relayer, which pays the gas, with exactly one
+ * relay call per run.
+ *
+ * Rejects only when nothing was sent: the user declined the signature, or the
+ * relayer refused before broadcasting. Anything after the relayer may have
+ * broadcast is reported as an outcome instead, so the caller never shows a
+ * landed vote as a failure and never offers the same ballot again.
+ */
+export const relayGaslessVote = async ({
+ daoId,
+ proposalId,
+ support,
+ account,
+ signer,
+ publicClient,
+ onTxSubmitted,
+}: RelayGaslessVoteParams): Promise => {
+ const { daoOverview } = daoConfigByDaoId[daoId];
+ const governor = daoOverview.contracts.governor;
+ if (!governor) throw new Error("DAO governance address not found");
+
+ // A wallet signs the same ballot byte for byte, so a page that missed the
+ // first vote would only replay it. Asking the chain first costs no signature.
+ if (
+ await hasVotedOnChain(publicClient, governor, proposalId, account.address)
+ ) {
+ return { status: "already-voted", hash: null };
+ }
+
+ const name = await publicClient.readContract({
+ abi: GovernorBallotAbi,
+ address: governor,
+ functionName: "name",
+ });
+
+ const signature = await signer.signTypedData({
+ account,
+ domain: {
+ name,
+ version: "1",
+ chainId: daoOverview.chain.id,
+ verifyingContract: governor,
+ },
+ types: BALLOT_TYPES,
+ primaryType: "Ballot",
+ message: { proposalId: BigInt(proposalId), support },
+ });
+
+ const { r, s, v } = parseSignature(signature);
+ if (v === undefined) throw new Error("Signature missing v");
+
+ let hash: Hash;
+ try {
+ const response = await relayVote(
+ daoId.toLowerCase() as RelayVotePathParamsDaoEnumKey,
+ { proposalId, support, r, s, v: Number(v) },
+ );
+ hash = response.transactionHash as Hash;
+ } catch (error) {
+ if (isAlreadyVotedRejection(error)) {
+ return { status: "already-voted", hash: null };
+ }
+ // A refusal before broadcasting is final and safe to retry, so it keeps
+ // the caller's error path. Only an answer that says nothing about whether
+ // the vote went out becomes "unknown".
+ if (classifyRelayerFailure(error) !== "ambiguous") throw error;
+ console.error(error);
+ return { status: "unknown", hash: null };
+ }
+
+ onTxSubmitted(hash);
+
+ try {
+ const receipt = await publicClient.waitForTransactionReceipt({ hash });
+ return {
+ hash,
+ status: receipt.status === "reverted" ? "reverted" : "success",
+ };
+ } catch (error) {
+ console.error(error);
+ return { hash, status: "unconfirmed" };
+ }
+};
diff --git a/apps/dashboard/features/governance/utils/voteOnProposal.test.ts b/apps/dashboard/features/governance/utils/voteOnProposal.test.ts
index a5584e5eb0..88d38dc285 100644
--- a/apps/dashboard/features/governance/utils/voteOnProposal.test.ts
+++ b/apps/dashboard/features/governance/utils/voteOnProposal.test.ts
@@ -141,8 +141,6 @@ describe("voteOnProposal", () => {
walletClient,
setTransactionhash,
undefined,
- undefined,
- false,
delegatorAddresses,
);
@@ -176,8 +174,6 @@ describe("voteOnProposal", () => {
walletClient,
jest.fn(),
undefined,
- undefined,
- false,
[],
);
diff --git a/apps/dashboard/features/governance/utils/voteOnProposal.ts b/apps/dashboard/features/governance/utils/voteOnProposal.ts
index c275744bd0..6dbc6ba87b 100644
--- a/apps/dashboard/features/governance/utils/voteOnProposal.ts
+++ b/apps/dashboard/features/governance/utils/voteOnProposal.ts
@@ -1,4 +1,4 @@
-import { parseSignature, publicActions } from "viem";
+import { publicActions } from "viem";
import type {
Account,
Address,
@@ -8,16 +8,10 @@ import type {
WalletClient,
} from "viem";
-import { relayVote } from "@anticapture/client";
-import type { RelayVotePathParamsDaoEnumKey } from "@anticapture/client";
-
import { showCustomToast } from "@/features/governance/utils/showCustomToast";
import daoConfigByDaoId from "@/shared/dao-config";
import { DaoIdEnum } from "@/shared/types/daos";
-import {
- isUserRejection,
- mapRelayerError,
-} from "@/shared/utils/gaslessRelayerError";
+import { isUserRejection } from "@/shared/utils/gaslessRelayerError";
const LinearVotingStrategyAbi = [
{
@@ -59,23 +53,6 @@ const GovernorCastVoteAbi = [
},
] as const;
-const GovernorNameAbi = [
- {
- inputs: [],
- name: "name",
- outputs: [{ internalType: "string", name: "", type: "string" }],
- stateMutability: "view",
- type: "function",
- },
-] as const;
-
-const BALLOT_TYPES = {
- Ballot: [
- { name: "proposalId", type: "uint256" },
- { name: "support", type: "uint8" },
- ],
-} as const;
-
type VoteClient = WalletClient & PublicActions & WalletActions;
type VoteParams = {
@@ -207,61 +184,11 @@ const ozGovernorVoteHandler =
return client.writeContract(request);
};
-const gaslessVoteHandler =
- (daoId: DaoIdEnum): VoteHandler =>
- async (client, params) => {
- const config = daoConfigByDaoId[daoId];
- const governor = config.daoOverview.contracts.governor;
- if (!governor) throw new Error("DAO governance address not found");
-
- const name = await client.readContract({
- abi: GovernorNameAbi,
- address: governor,
- functionName: "name",
- });
-
- const signature = await client.signTypedData({
- account: params.account,
- domain: {
- name,
- version: "1",
- chainId: config.daoOverview.chain.id,
- verifyingContract: governor,
- },
- types: BALLOT_TYPES,
- primaryType: "Ballot",
- message: {
- proposalId: BigInt(params.proposalId),
- support: params.voteNumber,
- },
- });
-
- const { r, s, v } = parseSignature(signature);
- if (v === undefined) throw new Error("Signature missing v");
-
- const response = await relayVote(
- daoId.toLowerCase() as RelayVotePathParamsDaoEnumKey,
- {
- proposalId: params.proposalId,
- support: params.voteNumber,
- r,
- s,
- v: Number(v),
- },
- );
- return response.transactionHash as `0x${string}`;
- };
-
-function getVoteHandler(
- daoId: DaoIdEnum,
- useGasless: boolean,
- hasComment: boolean,
-): VoteHandler {
- // Gasless relay uses castVoteBySig which doesn't support vote reasons.
- // Fall back to direct vote when a comment is present so it isn't silently dropped.
- if (useGasless && daoConfigByDaoId[daoId].gaslessRelayer && !hasComment) {
- return gaslessVoteHandler(daoId);
- }
+/**
+ * Wallet votes only. Gasless votes go through relayGaslessVote, which waits on
+ * the DAO's chain rather than the wallet's provider.
+ */
+function getVoteHandler(daoId: DaoIdEnum): VoteHandler {
switch (daoId) {
case DaoIdEnum.SHU:
return azoriusVoteHandler(daoId);
@@ -281,8 +208,6 @@ export const voteOnProposal = async (
walletClient: WalletClient,
setTransactionhash: (hash: string) => void,
comment?: string,
- minVotingPower: bigint | null = null,
- useGasless: boolean = false,
delegatedVoteAddresses?: Address[],
) => {
const client = walletClient.extend(publicActions);
@@ -290,7 +215,7 @@ export const voteOnProposal = async (
try {
const trimmedComment = comment?.trim() || undefined;
- const handler = getVoteHandler(daoId, useGasless, !!trimmedComment);
+ const handler = getVoteHandler(daoId);
const hash = await handler(client as VoteClient, {
proposalId,
voteNumber,
@@ -308,18 +233,7 @@ export const voteOnProposal = async (
console.error(error);
if (isUserRejection(error)) return null;
- const config = daoConfigByDaoId[daoId];
- if (useGasless && config.gaslessRelayer) {
- const message = mapRelayerError(error, {
- operation: "vote",
- minVotingPower,
- decimals: config.decimals,
- symbol: config.name,
- });
- showCustomToast(message, "error");
- } else {
- showCustomToast("Failed to vote", "error");
- }
+ showCustomToast("Failed to vote", "error");
return null;
}
};
diff --git a/apps/dashboard/features/governance/utils/voteSubmission.test.ts b/apps/dashboard/features/governance/utils/voteSubmission.test.ts
new file mode 100644
index 0000000000..864dc322ad
--- /dev/null
+++ b/apps/dashboard/features/governance/utils/voteSubmission.test.ts
@@ -0,0 +1,250 @@
+/**
+ * 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,
+ * 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.
+ */
+
+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,
+ isSubmittedVoteIndexed,
+ readVoteDetails,
+ settleVote,
+ settlementForGaslessVote,
+ settlementForWalletVote,
+ submittedVoteSupport,
+ voteSubmissionKey,
+} from "@/features/governance/utils/voteSubmission";
+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(
+ voteSubmissionKey(DaoIdEnum.ENS, "1", voter.toLowerCase() as Address),
+ );
+ expect(voteSubmissionKey(DaoIdEnum.ENS, "1", voter)).not.toBe(
+ voteSubmissionKey(DaoIdEnum.ENS, "2", voter),
+ );
+ });
+});
+
+describe("claimVote", () => {
+ it("lets exactly one submission start, however fast the second click", () => {
+ const key = freshKey();
+
+ expect(claimVote(key, "gasless", 1)).toBe(true);
+ expect(claimVote(key, "gasless", 1)).toBe(false);
+ expect(claimVote(key, "wallet", 0)).toBe(false);
+ expect(readSubmission(key)).toEqual({ kind: "in-flight", mode: "gasless" });
+ expect(readVoteDetails(key)).toEqual({ support: 1, alreadyVoted: false });
+ });
+
+ it("allows a new attempt only after one that changed nothing", () => {
+ const key = freshKey();
+ claimVote(key, "gasless", 1);
+ settleVote(key, settlementForWalletVote(null));
+
+ expect(claimVote(key, "gasless", 0)).toBe(true);
+ expect(readVoteDetails(key).support).toBe(0);
+ });
+
+ 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", () => {
+ it("records the outcome of the run that claimed the vote", () => {
+ const key = freshKey();
+ claimVote(key, "gasless", 1);
+ settleVote(key, settlementForGaslessVote({ status: "success", hash }));
+
+ expect(readSubmission(key)).toEqual(landed);
+ });
+
+ it("ignores a settle with no run in flight", () => {
+ const key = freshKey();
+ settleVote(key, settlementForGaslessVote({ status: "success", hash }));
+
+ 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", () => {
+ const cases: Array<
+ [GaslessVoteOutcome, ReturnType]
+ > = [
+ [
+ { status: "success", hash },
+ { kind: "done", outcome: "landed", hash },
+ ],
+ [
+ { status: "already-voted", hash: null },
+ { kind: "done", outcome: "landed", hash: null, alreadyVoted: true },
+ ],
+ [
+ { status: "reverted", hash },
+ { kind: "done", outcome: "failed", hash },
+ ],
+ [
+ { status: "unconfirmed", hash },
+ { kind: "ambiguous", hash },
+ ],
+ [
+ { status: "unknown", hash: null },
+ { kind: "ambiguous", hash: null },
+ ],
+ ];
+
+ it.each(cases)("maps %o to %o", (outcome, expected) => {
+ expect(settlementForGaslessVote(outcome)).toEqual(expected);
+ });
+});
+
+describe("getVoteScreen", () => {
+ it("shows the ballot only when a submission may start", () => {
+ expect(getVoteScreen({ kind: "idle" }, fixedVote)).toBe("ballot");
+ expect(
+ 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" }, 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", () => {
+ expect(submittedVoteSupport(landed, 1)).toBe(1);
+ expect(
+ submittedVoteSupport({ kind: "ambiguous", mode: "gasless", hash }, 1),
+ ).toBeUndefined();
+ expect(
+ submittedVoteSupport({ kind: "in-flight", mode: "gasless" }, 1),
+ ).toBeUndefined();
+ 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
new file mode 100644
index 0000000000..8d0ecd41f9
--- /dev/null
+++ b/apps/dashboard/features/governance/utils/voteSubmission.ts
@@ -0,0 +1,201 @@
+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";
+import {
+ readSubmission,
+ submissionKey,
+ writeSubmission,
+} from "@/features/governance/utils/submissionStore";
+
+/**
+ * A vote's submission lives in the same page-lifetime store as queue and
+ * execute, keyed by voter as well, since one tab can switch wallets. Before
+ * this the modal held its own state and reset it on every open, so closing
+ * mid-request or after an error put the ballot back on screen and the next
+ * click replayed the same signature at the relayer.
+ */
+export const voteSubmissionKey = (
+ daoId: string,
+ proposalId: string,
+ voter: Address,
+): string => submissionKey(daoId, proposalId, `vote:${voter.toLowerCase()}`);
+
+/**
+ * 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.
+ */
+export interface VoteDetails {
+ support: number | null;
+ alreadyVoted: boolean;
+}
+
+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
+ * the first await, so a double click or a reopened modal finds the claim
+ * already taken and sends nothing.
+ */
+export const claimVote = (
+ key: string,
+ mode: ActionMode,
+ support: number,
+ canChangeVote = false,
+): boolean => {
+ 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;
+ /** 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 });
+};
+
+/**
+ * "already-voted" counts as landed: the governor holds a vote from this
+ * address, and offering the ballot again could only replay it. A hash whose
+ * receipt could not be read, or a relay answer that says nothing, may still
+ * land, so neither frees the vote for another attempt.
+ */
+export const settlementForGaslessVote = (
+ outcome: GaslessVoteOutcome,
+): VoteSettlement => {
+ switch (outcome.status) {
+ case "success":
+ 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":
+ case "unknown":
+ return { kind: "ambiguous", hash: outcome.hash };
+ }
+};
+
+/** 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 };
+
+/**
+ * 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"
+ | "already-voted";
+
+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";
+};
+
+/**
+ * The submitted choice, once the vote is known to be on-chain. An ambiguous
+ * submission may never land, so it does not claim a vote on the page.
+ */
+export const submittedVoteSupport = (
+ submission: SubmissionState,
+ support: number | null,
+): number | undefined =>
+ submission.kind === "done" &&
+ submission.outcome === "landed" &&
+ 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/hooks/useGaslessRelayer.ts b/apps/dashboard/shared/hooks/useGaslessRelayer.ts
index de39b71fa8..79ac9c871b 100644
--- a/apps/dashboard/shared/hooks/useGaslessRelayer.ts
+++ b/apps/dashboard/shared/hooks/useGaslessRelayer.ts
@@ -29,6 +29,16 @@ interface UseRelayerConfigResult {
const toDaoKey = (daoId: DaoIdEnum) => daoId.toLowerCase();
+/**
+ * The relayer's balance, config and an address's rate limit change at most
+ * once per relayed transaction. The balance hook alone is mounted by the
+ * config, rate-limit and eligibility hooks and by every enactment modal, and
+ * with no stale time each new observer refetched: the relayer logged the same
+ * three GETs two or three times within one second. A vote invalidates the
+ * rate limit itself once it is relayed.
+ */
+const RELAYER_QUERY_STALE_MS = 60_000;
+
interface UseRelayerBalanceResult {
hasEnoughBalance: boolean | null;
isLoading: boolean;
@@ -41,7 +51,7 @@ export const useRelayerBalance = (
const { data, isLoading } = useGetRelayerBalance(
toDaoKey(daoId) as GetRelayerBalancePathParamsDaoEnumKey,
- { query: { enabled } },
+ { query: { enabled, staleTime: RELAYER_QUERY_STALE_MS } },
);
return {
@@ -81,7 +91,7 @@ export const useRelayerConfig = (daoId: DaoIdEnum): UseRelayerConfigResult => {
const { data, isLoading } = useGetConfig(
toDaoKey(daoId) as GetConfigPathParamsDaoEnumKey,
- { query: { enabled } },
+ { query: { enabled, staleTime: RELAYER_QUERY_STALE_MS } },
);
const minVotingPower = useMemo(() => {
@@ -124,7 +134,7 @@ export const useRelayerRateLimit = (
const { data, isLoading } = useGetRateLimit(
toDaoKey(daoId) as GetRateLimitPathParamsDaoEnumKey,
address ?? zeroAddress,
- { query: { enabled } },
+ { query: { enabled, staleTime: RELAYER_QUERY_STALE_MS } },
);
return {
diff --git a/apps/dashboard/shared/utils/gaslessRelayerError.test.ts b/apps/dashboard/shared/utils/gaslessRelayerError.test.ts
index 9a95f1df1d..32d529a686 100644
--- a/apps/dashboard/shared/utils/gaslessRelayerError.test.ts
+++ b/apps/dashboard/shared/utils/gaslessRelayerError.test.ts
@@ -145,6 +145,38 @@ describe("mapRelayerError", () => {
);
});
+ it("says the vote is already counted when the governor reports it", () => {
+ for (const reason of [
+ "GovernorVotingSimple: vote already cast",
+ "GovernorBravo::castVoteInternal: voter already voted",
+ "GovernorAlreadyCastVote",
+ ]) {
+ expect(
+ mapRelayerError(
+ relayerError(
+ 409,
+ "SIMULATION_FAILED",
+ `Simulation of castVoteBySig() reverted: ${reason}`,
+ ),
+ context,
+ ),
+ ).toBe("You have already voted on this proposal.");
+ }
+ });
+
+ it("says any other simulated revert was not sent", () => {
+ expect(
+ mapRelayerError(
+ relayerError(
+ 409,
+ "SIMULATION_FAILED",
+ "Simulation of delegateBySig() reverted: ERC20Votes: invalid nonce",
+ ),
+ { ...context, operation: "delegate" },
+ ),
+ ).toBe("This delegation couldn't be completed, so nothing was sent.");
+ });
+
it("falls back to the generic message", () => {
expect(mapRelayerError(new Error("network"), context)).toMatch(
/something went wrong/i,
diff --git a/apps/dashboard/shared/utils/gaslessRelayerError.ts b/apps/dashboard/shared/utils/gaslessRelayerError.ts
index 47c16bff29..b06ad63a47 100644
--- a/apps/dashboard/shared/utils/gaslessRelayerError.ts
+++ b/apps/dashboard/shared/utils/gaslessRelayerError.ts
@@ -9,6 +9,14 @@ import type {
const INSUFFICIENT_VOTING_POWER = "INSUFFICIENT_VOTING_POWER";
const RATE_LIMITED = "RATE_LIMITED";
+const SIMULATION_FAILED = "SIMULATION_FAILED";
+
+/**
+ * How each governor family words a second vote from the same address:
+ * OpenZeppelin 4 ("vote already cast"), Compound Bravo ("voter already
+ * voted") and OpenZeppelin 5's custom error (GovernorAlreadyCastVote).
+ */
+const ALREADY_VOTED_REASON = /already\s*(cast|voted)|AlreadyCastVote/i;
const GENERIC_MESSAGE =
"Something went wrong with your operation. Try again later";
@@ -170,6 +178,15 @@ export const mapRelayerEnactmentError = (
return `The relayer could not ${action} this proposal. You can retry or use your own wallet.`;
};
+/**
+ * The governor refused the vote because this address already voted, which
+ * means a vote exists on-chain: a signature replay, or a stale page.
+ */
+export const isAlreadyVotedRejection = (error: unknown): boolean => {
+ const { code, message } = readRelayerError(error);
+ return code === SIMULATION_FAILED && ALREADY_VOTED_REASON.test(message ?? "");
+};
+
export const mapRelayerError = (
error: unknown,
context: {
@@ -181,6 +198,17 @@ export const mapRelayerError = (
): string => {
const { code, status } = readRelayerError(error);
+ // The relayer simulates before sending, so a revert here means nothing was
+ // broadcast. A second click re-signs the same ballot byte for byte, which is
+ // the common way to land here after the first vote already counted.
+ if (code === SIMULATION_FAILED) {
+ if (context.operation === "vote" && isAlreadyVotedRejection(error)) {
+ return "You have already voted on this proposal.";
+ }
+ const noun = context.operation === "vote" ? "vote" : "delegation";
+ return `This ${noun} couldn't be completed, so nothing was sent.`;
+ }
+
if (code === INSUFFICIENT_VOTING_POWER) {
if (context.minVotingPower === null) {
return `You don't have sufficient voting power to ${context.operation}.`;
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({