diff --git a/.changeset/dashboard-gasless-wallet-fallback.md b/.changeset/dashboard-gasless-wallet-fallback.md
new file mode 100644
index 0000000000..4d551c6722
--- /dev/null
+++ b/.changeset/dashboard-gasless-wallet-fallback.md
@@ -0,0 +1,5 @@
+---
+"@anticapture/dashboard": minor
+---
+
+Offer gasless votes only when the relayer's GET /relay/status answers available: true, and keep every other case on the voter's own wallet: the ballot says when gas-free voting is unavailable or a relayed attempt was refused and switches its button to "Vote with wallet", lets a voter opt out of the relayer at any time, and turns a relay the relayer never confirmed into a "Vote with my wallet instead" button once the chain shows no vote from that address. Delegation gets the same guarantee: a refused or unconfirmed gas-free delegation moves that account to its wallet with a "Delegate with my wallet" button. The fallback state follows the voter across account switches and remounts, a relayed vote whose hash never confirms can also fall back once the chain shows no vote and no pending relay, a wallet vote that finds the relayed vote already counted reports it as cast, and the relayer status refreshes every minute while a page is open
diff --git a/.changeset/gateful-relayer-status.md b/.changeset/gateful-relayer-status.md
new file mode 100644
index 0000000000..ba995c2ff3
--- /dev/null
+++ b/.changeset/gateful-relayer-status.md
@@ -0,0 +1,5 @@
+---
+"@anticapture/gateful": minor
+---
+
+Always answer GET /{dao}/relay/status with 200: pass the relayer's answer through, and answer on its behalf with available: false when it cannot, reporting RELAYER_NOT_CONFIGURED for a DAO without a relayer and RELAYER_UNREACHABLE when the relayer is down, times out, answers with anything but a valid status, or its circuit is open. The probe goes straight to the relayer, so it never trips or occupies the circuit breaker that real relay traffic depends on. It logs once when it starts answering for a relayer that cannot answer and once when that relayer is reachable again
diff --git a/.changeset/relayer-gasless-status.md b/.changeset/relayer-gasless-status.md
new file mode 100644
index 0000000000..a0a7e38b41
--- /dev/null
+++ b/.changeset/relayer-gasless-status.md
@@ -0,0 +1,5 @@
+---
+"@anticapture/relayer": minor
+---
+
+Add GET /relay/status, which answers whether gasless relaying can be used right now from every check the relayer can run on itself (pause switch, RPC reachability and chain, wallet balance, rate limiter, stuck pending transactions, repeated broadcast failures) and always answers 200 with the failed checks listed as reasons, and add the GASLESS_PAUSED kill switch, which refuses every relayed vote, delegation, queue and execute with 503 RELAYER_PAUSED before a signature is recovered or the chain is read, and keeps webhook-driven enactment from broadcasting. GASLESS_PAUSED accepts true/1/yes/on (any case) and refuses to start on a value it does not recognise, and the status is published on /metrics as relayer_gasless_available and relayer_gasless_unavailable_reason for the new GaslessRelayerUnavailable alert
diff --git a/apps/dashboard/features/governance/components/modals/VotePendingContent.tsx b/apps/dashboard/features/governance/components/modals/VotePendingContent.tsx
index f9f440462f..9443de24fc 100644
--- a/apps/dashboard/features/governance/components/modals/VotePendingContent.tsx
+++ b/apps/dashboard/features/governance/components/modals/VotePendingContent.tsx
@@ -1,23 +1,36 @@
-import { ExternalLink, Hourglass } from "lucide-react";
+import { ExternalLink, Hourglass, Wallet } from "lucide-react";
import type { Hash } from "viem";
import { InlineAlert } from "@/shared/components/design-system/alerts/inline-alert/InlineAlert";
+import { Button } from "@/shared/components/design-system/buttons/button/Button";
interface VotePendingContentProps {
hash: Hash | null;
explorerBaseUrl?: string;
+ /**
+ * Offered for a relayed vote the relayer never confirmed: checks the chain
+ * and, if no vote from this address is there and no relayed transaction is
+ * still waiting to be mined, hands the ballot to the wallet. Absent, the
+ * screen keeps its old wait-only behaviour.
+ */
+ onVoteWithWallet?: () => void;
+ isCheckingChain?: boolean;
}
const shortenHash = (hash: string) => `${hash.slice(0, 10)}…${hash.slice(-8)}`;
/**
- * A vote that may be on-chain but could not be confirmed here. It offers no
- * way to vote again: the wallet would sign the same ballot, and the relayer
- * would only be asked to replay it.
+ * A vote that may be on-chain but could not be confirmed here. A relayed one
+ * may also never land: the relayer may not have sent it, or its transaction
+ * may be dropped or revert. So the voter gets a way to cast the vote from
+ * their own wallet rather than a screen that waits forever on a service that
+ * is not answering; the chain is read before that way opens.
*/
export const VotePendingContent = ({
hash,
explorerBaseUrl,
+ onVoteWithWallet,
+ isCheckingChain = false,
}: VotePendingContentProps) => (
+ )}
>
)}
@@ -557,11 +739,15 @@ export const VotingModal = ({
)}
diff --git a/apps/dashboard/features/governance/hooks/useVoteSubmission.ts b/apps/dashboard/features/governance/hooks/useVoteSubmission.ts
index 5eece0e071..877d35da45 100644
--- a/apps/dashboard/features/governance/hooks/useVoteSubmission.ts
+++ b/apps/dashboard/features/governance/hooks/useVoteSubmission.ts
@@ -11,8 +11,11 @@ import {
} from "@/features/governance/utils/submissionStore";
import {
readVoteDetails,
+ readWalletFallback,
+ subscribeToWalletFallback,
voteSubmissionKey,
type VoteDetails,
+ type WalletFallback,
} from "@/features/governance/utils/voteSubmission";
interface UseVoteSubmissionResult {
@@ -20,6 +23,7 @@ interface UseVoteSubmissionResult {
key: string | null;
submission: SubmissionState;
details: VoteDetails;
+ walletFallback: WalletFallback;
}
/**
@@ -46,11 +50,19 @@ export const useVoteSubmission = (
);
const submission = useSyncExternalStore(subscribe, read, read);
+ const readFallback = useCallback(() => readWalletFallback(key), [key]);
+ const walletFallback = useSyncExternalStore(
+ subscribeToWalletFallback,
+ readFallback,
+ readFallback,
+ );
+
return {
key,
submission,
details: key
? readVoteDetails(key)
: { support: null, alreadyVoted: false },
+ walletFallback,
};
};
diff --git a/apps/dashboard/features/governance/utils/relayGaslessVote.test.ts b/apps/dashboard/features/governance/utils/relayGaslessVote.test.ts
index 0bbabd0168..befc5d802b 100644
--- a/apps/dashboard/features/governance/utils/relayGaslessVote.test.ts
+++ b/apps/dashboard/features/governance/utils/relayGaslessVote.test.ts
@@ -3,6 +3,7 @@ import type { Account, Address, Hash, Hex } from "viem";
import { relayVote } from "@anticapture/client";
import {
+ isRelayedVotePending,
relayGaslessVote,
type BallotSigner,
type VoteChainReader,
@@ -248,3 +249,47 @@ describe("relayGaslessVote", () => {
expect(signTypedData).toHaveBeenCalledTimes(1);
});
});
+
+describe("isRelayedVotePending", () => {
+ const withTransaction = (getTransaction: jest.Mock) =>
+ ({ getTransaction }) as unknown as Parameters<
+ typeof isRelayedVotePending
+ >[0];
+
+ beforeEach(() => {
+ jest.spyOn(console, "error").mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ it("holds the wallet back while the relayed transaction waits in the mempool", async () => {
+ const client = withTransaction(
+ jest.fn().mockResolvedValue({ blockNumber: null }),
+ );
+ await expect(isRelayedVotePending(client, transactionHash)).resolves.toBe(
+ true,
+ );
+ });
+
+ it("does not for a mined transaction: hasVoted answers that one", async () => {
+ const client = withTransaction(
+ jest.fn().mockResolvedValue({ blockNumber: BigInt(21_000_000) }),
+ );
+ await expect(isRelayedVotePending(client, transactionHash)).resolves.toBe(
+ false,
+ );
+ });
+
+ // A dropped transaction is unknown to the node, and a failed read says
+ // nothing; neither may keep the voter from their wallet.
+ it("does not for a transaction the node cannot find or a failed read", async () => {
+ const client = withTransaction(
+ jest.fn().mockRejectedValue(new Error("Transaction not found")),
+ );
+ await expect(isRelayedVotePending(client, transactionHash)).resolves.toBe(
+ false,
+ );
+ });
+});
diff --git a/apps/dashboard/features/governance/utils/relayGaslessVote.ts b/apps/dashboard/features/governance/utils/relayGaslessVote.ts
index d02a9ac583..b40113aec2 100644
--- a/apps/dashboard/features/governance/utils/relayGaslessVote.ts
+++ b/apps/dashboard/features/governance/utils/relayGaslessVote.ts
@@ -80,9 +80,10 @@ type RelayGaslessVoteParams = {
/**
* 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.
+ * relayer simulates before sending and refuses a duplicate on its own, and
+ * on the wallet fallback the governor refuses it just the same.
*/
-const hasVotedOnChain = async (
+export const hasVotedOnChain = async (
publicClient: VoteChainReader,
governor: Address,
proposalId: string,
@@ -101,6 +102,26 @@ const hasVotedOnChain = async (
}
};
+/**
+ * Whether a relayed transaction is still waiting to be mined. Only a node
+ * that knows the transaction and has no block for it says yes: a mined one
+ * is answered by `hasVoted`, and one the node has never seen or has dropped
+ * will not land. A read that fails says nothing, so it does not hold the
+ * voter back from their wallet.
+ */
+export const isRelayedVotePending = async (
+ publicClient: Pick,
+ hash: Hash,
+): Promise => {
+ try {
+ const transaction = await publicClient.getTransaction({ hash });
+ return transaction.blockNumber === null;
+ } catch (error) {
+ console.error(error);
+ return false;
+ }
+};
+
/**
* Casts a vote through the relayer, which pays the gas, with exactly one
* relay call per run.
diff --git a/apps/dashboard/features/governance/utils/votePayment.test.ts b/apps/dashboard/features/governance/utils/votePayment.test.ts
new file mode 100644
index 0000000000..3565540709
--- /dev/null
+++ b/apps/dashboard/features/governance/utils/votePayment.test.ts
@@ -0,0 +1,142 @@
+/**
+ * The one rule these tests hold the ballot to: whatever the relayer is doing,
+ * a voter who can vote is offered a way to, and the wallet is that way
+ * whenever the relayer cannot be trusted with the ballot.
+ */
+
+import {
+ canReturnToGasless,
+ getSubmitLabel,
+ getVotePayment,
+} from "@/features/governance/utils/votePayment";
+
+const healthy = {
+ daoHasRelayer: true,
+ isRelayerAvailable: true,
+ isEligible: true,
+ hasComment: false,
+ gaslessFailed: false,
+ prefersWallet: false,
+} as const;
+
+describe("getVotePayment", () => {
+ it("relays a plain vote when the relayer is available and the voter eligible", () => {
+ expect(getVotePayment(healthy)).toEqual({ mode: "gasless" });
+ });
+
+ it("uses the wallet, silently, on a DAO without a relayer", () => {
+ expect(getVotePayment({ ...healthy, daoHasRelayer: false })).toEqual({
+ mode: "wallet",
+ reason: "none",
+ });
+ });
+
+ it("uses the wallet and says so when the relayer reports itself unavailable", () => {
+ expect(getVotePayment({ ...healthy, isRelayerAvailable: false })).toEqual({
+ mode: "wallet",
+ reason: "relayer-unavailable",
+ });
+ });
+
+ // Eligibility is computed from the status too, so an unavailable relayer
+ // also reads as "not eligible"; the voter must still hear the real reason.
+ it("names the unavailable relayer even when eligibility follows it down", () => {
+ expect(
+ getVotePayment({
+ ...healthy,
+ isRelayerAvailable: false,
+ isEligible: false,
+ }),
+ ).toEqual({ mode: "wallet", reason: "relayer-unavailable" });
+ });
+
+ it("does not hold the ballot while the status is loading", () => {
+ expect(getVotePayment({ ...healthy, isRelayerAvailable: null })).toEqual({
+ mode: "wallet",
+ reason: "none",
+ });
+ });
+
+ it("uses the wallet without a warning for a voter the relayer would not sponsor", () => {
+ expect(getVotePayment({ ...healthy, isEligible: false })).toEqual({
+ mode: "wallet",
+ reason: "none",
+ });
+ });
+
+ it("keeps a failed relay on the wallet even if the status still says available", () => {
+ expect(getVotePayment({ ...healthy, gaslessFailed: true })).toEqual({
+ mode: "wallet",
+ reason: "gasless-failed",
+ });
+ });
+
+ it("sends a commented vote through the wallet", () => {
+ expect(getVotePayment({ ...healthy, hasComment: true })).toEqual({
+ mode: "wallet",
+ reason: "comment",
+ });
+ });
+
+ it("honours a voter who chose to pay gas", () => {
+ expect(getVotePayment({ ...healthy, prefersWallet: true })).toEqual({
+ mode: "wallet",
+ reason: "wallet-chosen",
+ });
+ });
+
+ it("never answers anything but gasless or wallet", () => {
+ const flags = [true, false];
+ for (const daoHasRelayer of flags)
+ for (const isRelayerAvailable of [true, false, null])
+ for (const isEligible of flags)
+ for (const hasComment of flags)
+ for (const gaslessFailed of flags)
+ for (const prefersWallet of flags) {
+ const payment = getVotePayment({
+ daoHasRelayer,
+ isRelayerAvailable,
+ isEligible,
+ hasComment,
+ gaslessFailed,
+ prefersWallet,
+ });
+ if (payment.mode === "gasless") {
+ expect(isRelayerAvailable).toBe(true);
+ expect(gaslessFailed).toBe(false);
+ } else {
+ expect(payment.mode).toBe("wallet");
+ }
+ }
+ });
+});
+
+describe("canReturnToGasless", () => {
+ it("offers the relayer back only to a voter who left it by choice", () => {
+ expect(
+ canReturnToGasless({ mode: "wallet", reason: "wallet-chosen" }),
+ ).toBe(true);
+ expect(
+ canReturnToGasless({ mode: "wallet", reason: "gasless-failed" }),
+ ).toBe(false);
+ expect(
+ canReturnToGasless({ mode: "wallet", reason: "relayer-unavailable" }),
+ ).toBe(false);
+ expect(canReturnToGasless({ mode: "gasless" })).toBe(false);
+ });
+});
+
+describe("getSubmitLabel", () => {
+ it("names the wallet on a DAO that also offers gas-free votes", () => {
+ expect(
+ getSubmitLabel({ mode: "wallet", reason: "relayer-unavailable" }, true),
+ ).toBe("Vote with wallet");
+ expect(getSubmitLabel({ mode: "gasless" }, true)).toBe("Submit gas-free");
+ });
+
+ it("keeps the plain label on a DAO without a relayer", () => {
+ expect(getSubmitLabel({ mode: "wallet", reason: "none" }, false)).toBe(
+ "Submit",
+ );
+ });
+});
diff --git a/apps/dashboard/features/governance/utils/votePayment.ts b/apps/dashboard/features/governance/utils/votePayment.ts
new file mode 100644
index 0000000000..9d838e4016
--- /dev/null
+++ b/apps/dashboard/features/governance/utils/votePayment.ts
@@ -0,0 +1,86 @@
+/**
+ * Who pays for a vote, and what the ballot says about it.
+ *
+ * The relayer is a convenience and the wallet is the guarantee: every state
+ * in which the relayer cannot be trusted with this ballot resolves to the
+ * wallet, and the ballot always says why, so a voter is never left with a
+ * Submit button that keeps failing on the same broken path.
+ *
+ * - "relayer-unavailable": the status endpoint did not answer `available:
+ * true` (paused, unfunded, RPC down, relayer unreachable, ...).
+ * - "gasless-failed": a relay attempt from this ballot was refused before
+ * anything was broadcast, or the relayer never confirmed it and the chain
+ * shows no vote. The relayer may be fine for others, but this voter has
+ * seen it fail, so offering it again would only fail again.
+ * - "wallet-chosen": the voter opted out of the relayer themselves.
+ * - "comment": `castVoteBySig` carries no reason, so a comment needs the
+ * wallet's `castVoteWithReason`.
+ * - "none": the DAO has no relayer, or this voter is not eligible for it
+ * (voting power, monthly limit). Nothing went wrong, so nothing is said.
+ */
+export type WalletVoteReason =
+ | "relayer-unavailable"
+ | "gasless-failed"
+ | "wallet-chosen"
+ | "comment"
+ | "none";
+
+export type VotePayment =
+ | { mode: "gasless" }
+ | { mode: "wallet"; reason: WalletVoteReason };
+
+interface VotePaymentInput {
+ /** The DAO is configured with a gasless relayer at all. */
+ daoHasRelayer: boolean;
+ /** The relayer status answer: null while it loads. */
+ isRelayerAvailable: boolean | null;
+ /** Voting power and monthly limit allow a relayed vote for this voter. */
+ isEligible: boolean;
+ hasComment: boolean;
+ gaslessFailed: boolean;
+ prefersWallet: boolean;
+}
+
+export const getVotePayment = ({
+ daoHasRelayer,
+ isRelayerAvailable,
+ isEligible,
+ hasComment,
+ gaslessFailed,
+ prefersWallet,
+}: VotePaymentInput): VotePayment => {
+ if (!daoHasRelayer) return { mode: "wallet", reason: "none" };
+ if (gaslessFailed) return { mode: "wallet", reason: "gasless-failed" };
+ if (isRelayerAvailable === false) {
+ return { mode: "wallet", reason: "relayer-unavailable" };
+ }
+ // Still loading, or not eligible: the wallet works either way, and holding
+ // the ballot until the relayer answers would make voting depend on it.
+ if (isRelayerAvailable === null || !isEligible) {
+ return { mode: "wallet", reason: "none" };
+ }
+ if (hasComment) return { mode: "wallet", reason: "comment" };
+ if (prefersWallet) return { mode: "wallet", reason: "wallet-chosen" };
+ return { mode: "gasless" };
+};
+
+/**
+ * Whether the voter can switch back to the relayer from where they are: only
+ * when they left it by choice. A failed or unavailable relayer is not
+ * offered again from the same ballot.
+ */
+export const canReturnToGasless = (payment: VotePayment): boolean =>
+ payment.mode === "wallet" && payment.reason === "wallet-chosen";
+
+/**
+ * The Submit label. A DAO with a relayer has two ways to vote, so the button
+ * names the one it will use whenever it is the wallet; a DAO without one
+ * keeps the plain label it always had.
+ */
+export const getSubmitLabel = (
+ payment: VotePayment,
+ daoHasRelayer: boolean,
+): string => {
+ if (payment.mode === "gasless") return "Submit gas-free";
+ return daoHasRelayer ? "Vote with wallet" : "Submit";
+};
diff --git a/apps/dashboard/features/governance/utils/voteSubmission.test.ts b/apps/dashboard/features/governance/utils/voteSubmission.test.ts
index 864dc322ad..e94d7403a2 100644
--- a/apps/dashboard/features/governance/utils/voteSubmission.test.ts
+++ b/apps/dashboard/features/governance/utils/voteSubmission.test.ts
@@ -16,12 +16,18 @@ import { readSubmission } from "@/features/governance/utils/submissionStore";
import {
claimVote,
getVoteScreen,
+ ALREADY_ON_CHAIN,
+ confirmLandedVote,
isSubmittedVoteIndexed,
readVoteDetails,
+ readWalletFallback,
+ releaseUnlandedVote,
settleVote,
settlementForGaslessVote,
settlementForWalletVote,
submittedVoteSupport,
+ subscribeToWalletFallback,
+ updateWalletFallback,
voteSubmissionKey,
} from "@/features/governance/utils/voteSubmission";
import { DaoIdEnum } from "@/shared/types/daos";
@@ -248,3 +254,139 @@ describe("isSubmittedVoteIndexed", () => {
).toBe(true);
});
});
+
+describe("releaseUnlandedVote", () => {
+ it("frees a relayed vote that came back with no hash", () => {
+ const key = freshKey();
+ claimVote(key, "gasless", 1);
+ settleVote(
+ key,
+ settlementForGaslessVote({ status: "unknown", hash: null }),
+ );
+ expect(getVoteScreen(readSubmission(key), fixedVote)).toBe("pending");
+
+ expect(releaseUnlandedVote(key)).toBe(true);
+ expect(getVoteScreen(readSubmission(key), fixedVote)).toBe("ballot");
+ expect(claimVote(key, "wallet", 1)).toBe(true);
+ });
+
+ // A hash proves the relayer sent it, not that it lands: a dropped or
+ // reverted relay must not strand the voter. The caller checks the chain.
+ it("frees a relayed vote whose hash never confirmed, keeping the hash", () => {
+ const key = freshKey();
+ claimVote(key, "gasless", 1);
+ settleVote(key, settlementForGaslessVote({ status: "unconfirmed", hash }));
+
+ expect(releaseUnlandedVote(key)).toBe(true);
+ expect(readSubmission(key)).toEqual({
+ kind: "done",
+ mode: "gasless",
+ outcome: "failed",
+ hash,
+ });
+ expect(getVoteScreen(readSubmission(key), fixedVote)).toBe("ballot");
+ });
+
+ it("leaves every other state alone", () => {
+ const inFlight = freshKey();
+ claimVote(inFlight, "gasless", 1);
+ expect(releaseUnlandedVote(inFlight)).toBe(false);
+ expect(readSubmission(inFlight).kind).toBe("in-flight");
+
+ const landedKey = freshKey();
+ claimVote(landedKey, "gasless", 1);
+ settleVote(
+ landedKey,
+ settlementForGaslessVote({ status: "success", hash }),
+ );
+ expect(releaseUnlandedVote(landedKey)).toBe(false);
+ expect(readSubmission(landedKey)).toMatchObject({ outcome: "landed" });
+
+ expect(releaseUnlandedVote(freshKey())).toBe(false);
+ });
+});
+
+describe("confirmLandedVote", () => {
+ it("turns a vote in doubt into a landed one once the chain shows it", () => {
+ const key = freshKey();
+ claimVote(key, "gasless", 1);
+ settleVote(key, settlementForGaslessVote({ status: "unconfirmed", hash }));
+
+ expect(confirmLandedVote(key)).toBe(true);
+ expect(getVoteScreen(readSubmission(key), fixedVote)).toBe("landed");
+ expect(isSubmittedVoteIndexed(readSubmission(key), undefined)).toBe(false);
+ expect(
+ isSubmittedVoteIndexed(readSubmission(key), { transactionHash: hash }),
+ ).toBe(true);
+ });
+
+ it("does nothing outside a vote in doubt", () => {
+ const key = freshKey();
+ claimVote(key, "gasless", 1);
+ expect(confirmLandedVote(key)).toBe(false);
+ expect(readSubmission(key).kind).toBe("in-flight");
+ });
+});
+
+describe("ALREADY_ON_CHAIN", () => {
+ // A wallet vote that found the relayed one already counted.
+ it("shows the vote on record and never offers the ballot again", () => {
+ const key = freshKey();
+ claimVote(key, "wallet", 0);
+ settleVote(key, ALREADY_ON_CHAIN);
+
+ expect(readVoteDetails(key)).toEqual({ support: null, alreadyVoted: true });
+ expect(
+ getVoteScreen(readSubmission(key), {
+ canChangeVote: false,
+ alreadyVoted: readVoteDetails(key).alreadyVoted,
+ }),
+ ).toBe("already-voted");
+ expect(claimVote(key, "wallet", 0)).toBe(false);
+ });
+});
+
+describe("wallet fallback", () => {
+ it("follows the voter, not the modal: another account starts clean", () => {
+ const first = voteSubmissionKey(DaoIdEnum.ENS, "fallback-1", voter);
+ const second = voteSubmissionKey(
+ DaoIdEnum.ENS,
+ "fallback-1",
+ "0x983110309620D911731Ac0932219af06091b6744",
+ );
+
+ updateWalletFallback(first, {
+ gaslessFailure: "The relayer is out of funds.",
+ });
+
+ expect(readWalletFallback(first)).toEqual({
+ gaslessFailure: "The relayer is out of funds.",
+ prefersWallet: false,
+ });
+ expect(readWalletFallback(second)).toEqual({
+ gaslessFailure: null,
+ prefersWallet: false,
+ });
+ expect(readWalletFallback(null)).toEqual({
+ gaslessFailure: null,
+ prefersWallet: false,
+ });
+ });
+
+ it("merges updates and notifies subscribers", () => {
+ const key = voteSubmissionKey(DaoIdEnum.ENS, "fallback-2", voter);
+ const onChange = jest.fn();
+ const unsubscribe = subscribeToWalletFallback(onChange);
+
+ updateWalletFallback(key, { prefersWallet: true });
+ updateWalletFallback(key, { gaslessFailure: "Gas-free voting is paused." });
+ unsubscribe();
+ updateWalletFallback(key, { prefersWallet: false });
+
+ expect(onChange).toHaveBeenCalledTimes(2);
+ expect(readWalletFallback(key)).toEqual({
+ gaslessFailure: "Gas-free voting is paused.",
+ prefersWallet: false,
+ });
+ });
+});
diff --git a/apps/dashboard/features/governance/utils/voteSubmission.ts b/apps/dashboard/features/governance/utils/voteSubmission.ts
index 8d0ecd41f9..3a09caa4ac 100644
--- a/apps/dashboard/features/governance/utils/voteSubmission.ts
+++ b/apps/dashboard/features/governance/utils/voteSubmission.ts
@@ -48,6 +48,48 @@ const detailsByKey = new Map();
export const readVoteDetails = (key: string): VoteDetails =>
detailsByKey.get(key) ?? NO_VOTE_DETAILS;
+/**
+ * How this voter's ballot is to be paid for, beyond what the relayer status
+ * says. Keyed like the submission, so it follows the voter rather than the
+ * modal: a second account in the same tab starts clean, and a remount of the
+ * proposal page does not offer the relayer that just refused this voter.
+ *
+ * - `gaslessFailure`: why the last relayed attempt did not go through. Once
+ * set, the ballot stays on the wallet for the life of the page, since the
+ * same relayer would most likely refuse the same voter the same way.
+ * - `prefersWallet`: the voter chose to pay gas while the relayer was on offer.
+ */
+export interface WalletFallback {
+ gaslessFailure: string | null;
+ prefersWallet: boolean;
+}
+
+const NO_WALLET_FALLBACK: WalletFallback = {
+ gaslessFailure: null,
+ prefersWallet: false,
+};
+
+const fallbackByKey = new Map();
+const fallbackListeners = new Set<() => void>();
+
+export const readWalletFallback = (key: string | null): WalletFallback =>
+ (key ? fallbackByKey.get(key) : undefined) ?? NO_WALLET_FALLBACK;
+
+export const updateWalletFallback = (
+ key: string,
+ patch: Partial,
+): void => {
+ fallbackByKey.set(key, { ...readWalletFallback(key), ...patch });
+ for (const listener of fallbackListeners) listener();
+};
+
+export const subscribeToWalletFallback = (onChange: () => void) => {
+ fallbackListeners.add(onChange);
+ return () => {
+ fallbackListeners.delete(onChange);
+ };
+};
+
/**
* 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
@@ -135,6 +177,65 @@ export const settlementForGaslessVote = (
}
};
+/**
+ * Frees a relayed vote the relayer never confirmed, once the chain shows no
+ * vote from this address, so the voter can cast it from their own wallet.
+ *
+ * The caller reads `hasVoted` first and, when the relayer returned a hash,
+ * checks that the transaction is not still waiting in the mempool: a hash
+ * proves the relayer sent it, not that it will land, and a dropped or
+ * reverted one would otherwise strand the voter on a screen that waits for
+ * it forever. The relayer may still broadcast late after this; the worst
+ * that leaves is two transactions for one ballot, and the governor counts
+ * only the first. The second reverts and costs its sender gas, which is
+ * cheaper than a voter who cannot vote, and the wallet path reads the chain
+ * again before and after it sends.
+ */
+export const releaseUnlandedVote = (key: string): boolean => {
+ const current = readSubmission(key);
+ if (current.kind !== "ambiguous" || current.mode !== "gasless") {
+ return false;
+ }
+ writeSubmission(key, {
+ kind: "done",
+ mode: "gasless",
+ outcome: "failed",
+ hash: current.hash,
+ });
+ return true;
+};
+
+/**
+ * Settles a vote whose outcome was in doubt once the chain shows a vote from
+ * this address: the relayed one landed after all. The submission becomes a
+ * landed vote, so the screen says so and the page keeps watching for it to
+ * be indexed, instead of offering the voter another way to cast it.
+ */
+export const confirmLandedVote = (key: string): boolean => {
+ const current = readSubmission(key);
+ if (current.kind !== "ambiguous") return false;
+ writeSubmission(key, {
+ kind: "done",
+ mode: current.mode,
+ outcome: "landed",
+ hash: current.hash,
+ });
+ return true;
+};
+
+/**
+ * A wallet vote that found the governor already holding a vote from this
+ * address: an earlier relayed one landed first. That vote is the one that
+ * counts, so the run settles as landed and the screen says the vote was
+ * already cast, rather than reporting a failure and offering the ballot again.
+ */
+export const ALREADY_ON_CHAIN: VoteSettlement = {
+ kind: "done",
+ outcome: "landed",
+ hash: null,
+ alreadyVoted: true,
+};
+
/** The wallet path reports its confirmed hash, or null when nothing landed. */
export const settlementForWalletVote = (hash: Hash | null): VoteSettlement =>
hash
diff --git a/apps/dashboard/features/holders-and-delegates/delegate/DelegationModal.tsx b/apps/dashboard/features/holders-and-delegates/delegate/DelegationModal.tsx
index fc7fd5d896..57e9403c36 100644
--- a/apps/dashboard/features/holders-and-delegates/delegate/DelegationModal.tsx
+++ b/apps/dashboard/features/holders-and-delegates/delegate/DelegationModal.tsx
@@ -1,22 +1,29 @@
"use client";
import { useQueryClient } from "@tanstack/react-query";
-import { Check, Hourglass, PenLine } from "lucide-react";
-import { useCallback, useEffect, useState } from "react";
+import { Check, Hourglass, PenLine, Wallet } from "lucide-react";
+import { useCallback, useEffect, useRef, 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 {
+ getRateLimitQueryKey,
+ getRelayerStatusQueryKey,
+} from "@anticapture/client/hooks";
+import type {
+ GetRateLimitPathParamsDaoEnumKey,
+ GetRelayerStatusPathParamsDaoEnumKey,
+} from "@anticapture/client";
import { delegateTo } from "@/features/holders-and-delegates/delegate/utils/delegateTo";
import { showCustomToast } from "@/features/governance/utils/showCustomToast";
import {
+ describeGaslessFailure,
isUserRejection,
- mapRelayerError,
} from "@/shared/utils/gaslessRelayerError";
import { InlineAlert } from "@/shared/components/design-system/alerts/inline-alert/InlineAlert";
+import { Button } from "@/shared/components/design-system/buttons/button/Button";
import { EnsAvatar } from "@/shared/components/design-system/avatars/ens-avatar/EnsAvatar";
import { Modal } from "@/shared/components/design-system/modal/Modal";
import { SpinIcon } from "@/shared/components/icons/SpinIcon";
@@ -61,6 +68,22 @@ export const DelegationModal = ({
const queryClient = useQueryClient();
const [step, setStep] = useState("waiting-signature");
const [error, setError] = useState(null);
+ // The account whose gas-free delegation the relayer last refused or never
+ // confirmed. That account delegates from its own wallet from then on, the
+ // same rule the ballot follows: the wallet is the guarantee, and offering
+ // the relayer that just failed would only fail again. Keyed by account so
+ // switching wallets starts clean.
+ const [gaslessFailedFor, setGaslessFailedFor] = useState(
+ null,
+ );
+ const gaslessFailed =
+ !!userAddress &&
+ gaslessFailedFor?.toLowerCase() === userAddress.toLowerCase();
+ // One delegation at a time. The effect below runs handleDelegate whenever
+ // its inputs change while the modal waits for a signature, and the relayer
+ // status now refreshes in the background, so without this a refresh during
+ // signing would ask the wallet for a second signature.
+ const inFlight = useRef(false);
const daoConfig = daoConfigByDaoId[daoId];
const tokenAddress = daoConfig?.daoOverview?.contracts?.token as
@@ -90,11 +113,14 @@ export const DelegationModal = ({
const handleDelegate = useCallback(async () => {
if (!userAddress || !walletClient || !tokenAddress) return;
if (step === "pending-tx" || step === "success") return;
+ if (inFlight.current) return;
+ inFlight.current = true;
setError(null);
setStep("waiting-signature");
- const isGasless = isGaslessEligible && daoConfig?.gaslessRelayer === true;
+ const isGasless =
+ isGaslessEligible && daoConfig?.gaslessRelayer === true && !gaslessFailed;
try {
await delegateTo(
tokenAddress,
@@ -104,7 +130,7 @@ export const DelegationModal = ({
() => setStep("pending-tx"),
chain,
daoId,
- isGaslessEligible,
+ isGasless,
);
setStep("success");
showCustomToast("Delegation successful!", "success");
@@ -117,14 +143,23 @@ export const DelegationModal = ({
}
if (isGasless) {
- const message = mapRelayerError(err, {
- operation: "delegate",
- minVotingPower,
- decimals,
- symbol: daoConfig.name,
+ // Any relayer failure moves this account to its wallet. Delegating
+ // again to the same delegate is idempotent, so even a relayed
+ // delegation that lands late costs the wallet path nothing but gas.
+ setGaslessFailedFor(userAddress);
+ setError(
+ `${describeGaslessFailure(err, {
+ operation: "delegate",
+ minVotingPower,
+ decimals,
+ symbol: daoConfig.name,
+ })} You can still delegate with your wallet and pay the network fee.`,
+ );
+ void queryClient.invalidateQueries({
+ queryKey: getRelayerStatusQueryKey(
+ daoId.toLowerCase() as GetRelayerStatusPathParamsDaoEnumKey,
+ ),
});
- showCustomToast(message, "error");
- setError(message);
} else {
const message =
err instanceof Error ? err.message : "Delegation failed.";
@@ -132,6 +167,7 @@ export const DelegationModal = ({
}
setStep("error");
} finally {
+ inFlight.current = false;
// 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.
@@ -157,6 +193,7 @@ export const DelegationModal = ({
decimals,
minVotingPower,
isGaslessEligible,
+ gaslessFailed,
queryClient,
]);
@@ -196,19 +233,21 @@ export const DelegationModal = ({
showCopyAddress={false}
/>
- {isGaslessEligible && delegationRemaining !== null && (
-
- )}
+ {isGaslessEligible &&
+ !gaslessFailed &&
+ delegationRemaining !== null && (
+
+ )}
{/* Stepper */}
@@ -230,6 +269,23 @@ export const DelegationModal = ({
label="Wait for the delegation to complete"
/>
+
+ {step === "error" && gaslessFailed && (
+
+
+
+ )}
);
};
diff --git a/apps/dashboard/shared/hooks/useGaslessRelayer.ts b/apps/dashboard/shared/hooks/useGaslessRelayer.ts
index 79ac9c871b..3176d0131a 100644
--- a/apps/dashboard/shared/hooks/useGaslessRelayer.ts
+++ b/apps/dashboard/shared/hooks/useGaslessRelayer.ts
@@ -5,14 +5,15 @@ import {
useAccountBalanceByAccountId,
useGetConfig,
useGetRateLimit,
- useGetRelayerBalance,
+ useGetRelayerStatus,
useVotingPowerByAccountId,
} from "@anticapture/client/hooks";
import type {
AccountBalanceByAccountIdPathParamsDaoEnumKey,
GetConfigPathParamsDaoEnumKey,
GetRateLimitPathParamsDaoEnumKey,
- GetRelayerBalancePathParamsDaoEnumKey,
+ GetRelayerStatusPathParamsDaoEnumKey,
+ RelayerStatusReason,
VotingPowerByAccountIdPathParamsDaoEnumKey,
} from "@anticapture/client";
@@ -30,33 +31,76 @@ 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.
+ * The relayer's config and an address's rate limit change at most once per
+ * relayed transaction. The status 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;
+/**
+ * How long a relayer status answer is trusted. Shorter than the config and
+ * rate-limit stale time: the status is what decides whether the ballot goes
+ * through the relayer at all, and a relayer that broke a minute ago should
+ * stop being offered well before the voter gives up on it.
+ */
+const RELAYER_STATUS_STALE_MS = 30_000;
+
+/**
+ * How often an open page asks again. Every observer of the status lives as
+ * long as the page (the voting and delegation modals stay mounted while
+ * closed), so the stale time alone never triggers a refetch: without this a
+ * relayer that broke after the page loaded kept being offered until the
+ * window lost and regained focus. Paused while the tab is in the background.
+ */
+const RELAYER_STATUS_REFRESH_MS = 60_000;
+
+interface UseRelayerStatusResult {
+ /**
+ * Whether the gasless path may be offered. Null while the first answer is
+ * on its way; false for a DAO without a relayer, for every reason the
+ * relayer or the gateway reports, and for a request that failed outright.
+ */
+ isAvailable: boolean | null;
+ reasons: RelayerStatusReason[];
isLoading: boolean;
}
-export const useRelayerBalance = (
- daoId: DaoIdEnum,
-): UseRelayerBalanceResult => {
+/**
+ * Whether the relayer can sponsor a transaction right now, from the one
+ * endpoint that checks everything it depends on: the pause switch, the RPC,
+ * the wallet balance, the rate limiter, stuck nonces and recent broadcast
+ * failures. Gateful answers on the relayer's behalf when it is down, so the
+ * gasless path is offered only on an explicit `available: true`. Anything
+ * else, including an error, leaves the voter on their own wallet, which is
+ * the path that never depends on this service.
+ */
+export const useRelayerStatus = (daoId: DaoIdEnum): UseRelayerStatusResult => {
const enabled = daoConfigByDaoId[daoId].gaslessRelayer === true;
- const { data, isLoading } = useGetRelayerBalance(
- toDaoKey(daoId) as GetRelayerBalancePathParamsDaoEnumKey,
- { query: { enabled, staleTime: RELAYER_QUERY_STALE_MS } },
+ const { data, isLoading, isError } = useGetRelayerStatus(
+ toDaoKey(daoId) as GetRelayerStatusPathParamsDaoEnumKey,
+ {
+ query: {
+ enabled,
+ staleTime: RELAYER_STATUS_STALE_MS,
+ refetchInterval: RELAYER_STATUS_REFRESH_MS,
+ // A failed status read already means "use the wallet"; retrying it
+ // would only hold the ballot on the slower path for longer.
+ retry: false,
+ },
+ },
);
+ if (!enabled) return { isAvailable: false, reasons: [], isLoading: false };
+ if (isError) return { isAvailable: false, reasons: [], isLoading: false };
+
return {
- hasEnoughBalance: data?.hasEnoughBalance ?? null,
- isLoading: enabled && isLoading,
+ isAvailable: data ? data.available === true : null,
+ reasons: data?.reasons ?? [],
+ isLoading,
};
};
@@ -69,25 +113,26 @@ interface UseGaslessEnactmentResult {
/**
* Queue and execute are permissionless and carry no signer, so the only
* things that gate the gasless path are the DAO having a relayer and that
- * relayer being funded. No voting power or per-address rate limit applies.
+ * relayer reporting itself available. No voting power or per-address rate
+ * limit applies.
*/
export const useGaslessEnactment = (
daoId: DaoIdEnum,
): UseGaslessEnactmentResult => {
const gaslessEnabled = daoConfigByDaoId[daoId].gaslessRelayer === true;
- const { hasEnoughBalance, isLoading } = useRelayerBalance(daoId);
+ const { isAvailable, isLoading } = useRelayerStatus(daoId);
return {
- isAvailable: gaslessEnabled && hasEnoughBalance === true,
+ isAvailable: gaslessEnabled && isAvailable === true,
isLoading: gaslessEnabled && isLoading,
};
};
export const useRelayerConfig = (daoId: DaoIdEnum): UseRelayerConfigResult => {
const gaslessEnabled = daoConfigByDaoId[daoId].gaslessRelayer === true;
- const { hasEnoughBalance, isLoading: balanceLoading } =
- useRelayerBalance(daoId);
- const enabled = gaslessEnabled && hasEnoughBalance === true;
+ const { isAvailable: isRelayerAvailable, isLoading: statusLoading } =
+ useRelayerStatus(daoId);
+ const enabled = gaslessEnabled && isRelayerAvailable === true;
const { data, isLoading } = useGetConfig(
toDaoKey(daoId) as GetConfigPathParamsDaoEnumKey,
@@ -108,7 +153,7 @@ export const useRelayerConfig = (daoId: DaoIdEnum): UseRelayerConfigResult => {
minVotingPower,
voteLimit: data?.limits?.vote ?? null,
delegationLimit: data?.limits?.delegation ?? null,
- isLoading: gaslessEnabled && (balanceLoading || (enabled && isLoading)),
+ isLoading: gaslessEnabled && (statusLoading || (enabled && isLoading)),
};
};
@@ -127,9 +172,9 @@ export const useRelayerRateLimit = (
): UseRelayerRateLimitResult => {
const gaslessEnabled =
daoConfigByDaoId[daoId].gaslessRelayer === true && !!address;
- const { hasEnoughBalance, isLoading: balanceLoading } =
- useRelayerBalance(daoId);
- const enabled = gaslessEnabled && hasEnoughBalance === true;
+ const { isAvailable: isRelayerAvailable, isLoading: statusLoading } =
+ useRelayerStatus(daoId);
+ const enabled = gaslessEnabled && isRelayerAvailable === true;
const { data, isLoading } = useGetRateLimit(
toDaoKey(daoId) as GetRateLimitPathParamsDaoEnumKey,
@@ -143,7 +188,7 @@ export const useRelayerRateLimit = (
voteLimit: data?.vote?.limit ?? null,
delegationLimit: data?.delegation?.limit ?? null,
resetsAt: data?.resetsAt ?? null,
- isLoading: gaslessEnabled && (balanceLoading || (enabled && isLoading)),
+ isLoading: gaslessEnabled && (statusLoading || (enabled && isLoading)),
};
};
@@ -160,8 +205,8 @@ export const useGaslessEligibility = (
): UseGaslessEligibilityResult => {
const gaslessEnabled = daoConfigByDaoId[daoId].gaslessRelayer === true;
- const { hasEnoughBalance, isLoading: balanceLoading } =
- useRelayerBalance(daoId);
+ const { isAvailable: isRelayerAvailable, isLoading: statusLoading } =
+ useRelayerStatus(daoId);
const { minVotingPower, isLoading: configLoading } = useRelayerConfig(daoId);
const {
voteRemaining,
@@ -169,7 +214,8 @@ export const useGaslessEligibility = (
isLoading: rateLimitLoading,
} = useRelayerRateLimit(daoId, address);
- const queryEnabled = gaslessEnabled && !!address && hasEnoughBalance === true;
+ const queryEnabled =
+ gaslessEnabled && !!address && isRelayerAvailable === true;
const isVote = operation === "vote";
@@ -197,14 +243,14 @@ export const useGaslessEligibility = (
const isLoading =
gaslessEnabled &&
!!address &&
- (balanceLoading ||
- (hasEnoughBalance === true &&
+ (statusLoading ||
+ (isRelayerAvailable === true &&
(configLoading || rateLimitLoading || eligibilityLoading)));
const isEligible =
gaslessEnabled &&
!isLoading &&
- hasEnoughBalance === true &&
+ isRelayerAvailable === true &&
rawEligibilityAmount !== null &&
minVotingPower !== null &&
BigInt(rawEligibilityAmount) >= minVotingPower &&
diff --git a/apps/dashboard/shared/services/posthog/ConditionalPostHog.tsx b/apps/dashboard/shared/services/posthog/ConditionalPostHog.tsx
index 953ca8a2e1..d239c2d2ea 100644
--- a/apps/dashboard/shared/services/posthog/ConditionalPostHog.tsx
+++ b/apps/dashboard/shared/services/posthog/ConditionalPostHog.tsx
@@ -41,6 +41,11 @@ const ConditionalPostHog = () => {
properties.dao = element.dataset.phDao;
}
+ // Optionally add how an action is paid for (gasless or wallet)
+ if (element.dataset.phPayment) {
+ properties.payment = element.dataset.phPayment;
+ }
+
windowWithPostHog.posthog.capture(
element.dataset.phEvent || "",
properties,
diff --git a/apps/dashboard/shared/utils/gaslessRelayerError.test.ts b/apps/dashboard/shared/utils/gaslessRelayerError.test.ts
index 32d529a686..58cd999887 100644
--- a/apps/dashboard/shared/utils/gaslessRelayerError.test.ts
+++ b/apps/dashboard/shared/utils/gaslessRelayerError.test.ts
@@ -1,5 +1,6 @@
import {
classifyRelayerFailure,
+ describeGaslessFailure,
getRelayerErrorCode,
getRelayerRevertedHash,
mapRelayerEnactmentError,
@@ -280,3 +281,81 @@ describe("getRelayerRevertedHash", () => {
).toBeNull();
});
});
+
+describe("describeGaslessFailure", () => {
+ const vote = {
+ operation: "vote" as const,
+ minVotingPower: null,
+ decimals: 18,
+ symbol: "ENS",
+ };
+ const delegate = { ...vote, operation: "delegate" as const };
+
+ it("names the relayer's own state rather than telling the user to wait", () => {
+ expect(
+ describeGaslessFailure(relayerError(503, "RELAYER_LOW_BALANCE"), vote),
+ ).toBe("The relayer is out of funds.");
+ expect(
+ describeGaslessFailure(relayerError(503, "RELAYER_PAUSED"), vote),
+ ).toBe("Gas-free voting is paused.");
+ expect(
+ describeGaslessFailure(relayerError(503, "RELAYER_PAUSED"), delegate),
+ ).toBe("Gas-free delegation is paused.");
+ });
+
+ // These sit right before "You can still vote with your wallet", so they
+ // must be about the gas-free allowance, not about voting itself.
+ it("frames the relayer's eligibility refusals as gas-free limits", () => {
+ expect(
+ describeGaslessFailure(relayerError(429, "RATE_LIMITED"), vote),
+ ).toBe("You've used all your gas-free operations for this month.");
+ expect(
+ describeGaslessFailure(relayerError(400, "INSUFFICIENT_VOTING_POWER"), {
+ ...vote,
+ minVotingPower: BigInt(10) ** BigInt(18),
+ }),
+ ).toBe("A gas-free vote needs at least 1 ENS.");
+ expect(
+ describeGaslessFailure(
+ relayerError(400, "INSUFFICIENT_VOTING_POWER"),
+ delegate,
+ ),
+ ).toBe("Your balance is below the minimum for a gas-free delegation.");
+ });
+
+ it("says nothing was sent only when the relayer refused before sending", () => {
+ expect(
+ describeGaslessFailure(
+ relayerError(409, "SIMULATION_FAILED", "reverted"),
+ vote,
+ ),
+ ).toBe("The gas-free vote did not go through, and nothing was sent.");
+ expect(describeGaslessFailure(relayerError(502), delegate)).toBe(
+ "The relayer did not confirm the gas-free delegation.",
+ );
+ });
+
+ it("always answers one complete sentence and never says try again", () => {
+ for (const error of [
+ relayerError(503),
+ relayerError(409, "SIMULATION_FAILED", "reverted"),
+ relayerError(429, "RATE_LIMITED"),
+ relayerError(400, "INSUFFICIENT_VOTING_POWER"),
+ new Error("network"),
+ ]) {
+ for (const context of [vote, delegate]) {
+ const text = describeGaslessFailure(error, context);
+ expect(text).toMatch(/\.$/);
+ expect(text).not.toMatch(/try again/i);
+ }
+ }
+ });
+});
+
+describe("RELAYER_PAUSED", () => {
+ it("is a refusal before broadcasting, so the wallet path stays open", () => {
+ expect(classifyRelayerFailure(relayerError(503, "RELAYER_PAUSED"))).toBe(
+ "pre-broadcast",
+ );
+ });
+});
diff --git a/apps/dashboard/shared/utils/gaslessRelayerError.ts b/apps/dashboard/shared/utils/gaslessRelayerError.ts
index b06ad63a47..aeeccfd98f 100644
--- a/apps/dashboard/shared/utils/gaslessRelayerError.ts
+++ b/apps/dashboard/shared/utils/gaslessRelayerError.ts
@@ -64,10 +64,10 @@ export const getRelayerErrorCode = (error: unknown): string | undefined =>
/**
* Every code the relayer can answer with before it signs anything, mirroring
* `Errors` in `apps/relayer/src/errors.ts`. All of them mean no transaction
- * was broadcast, so retrying is safe. The two 503s are in here for that
- * reason: a low balance or an unreachable rate limiter are structured "did
- * not send" answers, not the ambiguous 5xx they look like from the status
- * alone. `TRANSACTION_REVERTED` is deliberately absent, being the one code
+ * was broadcast, so retrying is safe. The 503s are in here for that reason:
+ * a low balance, a paused relayer or an unreachable rate limiter are
+ * structured "did not send" answers, not the ambiguous 5xx they look like
+ * from the status alone. `TRANSACTION_REVERTED` is deliberately absent, being the one code
* that reports a transaction the relayer did broadcast.
*/
const ENACTMENT_REJECTION_CODES = new Set([
@@ -75,6 +75,7 @@ const ENACTMENT_REJECTION_CODES = new Set([
"INVALID_SIGNATURE",
"RATE_LIMITED",
"RELAYER_LOW_BALANCE",
+ "RELAYER_PAUSED",
"INVALID_CONTRACT",
"RATE_LIMITER_UNAVAILABLE",
"PROPOSAL_NOT_FOUND",
@@ -166,6 +167,8 @@ const ENACTMENT_MESSAGES: Record<
"The proposal data doesn't match what is on-chain, so the relayer refused to submit it.",
RELAYER_LOW_BALANCE: () =>
"The relayer is out of funds right now. You can still complete this action with your own wallet.",
+ RELAYER_PAUSED: () =>
+ "Gas-free transactions are paused right now. You can still complete this action with your own wallet.",
};
export const mapRelayerEnactmentError = (
@@ -228,6 +231,48 @@ export const mapRelayerError = (
return GENERIC_MESSAGE;
};
+/**
+ * Why a gas-free vote or delegation did not go through, as one complete
+ * sentence the modal puts right before "You can still ... with your wallet".
+ * It never says "try again later": the wallet is right there, so the copy
+ * points at it instead of at the relayer. The relayer's own refusals are
+ * about gas-free eligibility, not about voting at all, so they say so: "you
+ * don't have sufficient voting power" next to a wallet button would read as
+ * a contradiction.
+ */
+export const describeGaslessFailure = (
+ error: unknown,
+ context: {
+ operation: "vote" | "delegate";
+ minVotingPower: bigint | null;
+ decimals: number;
+ symbol: string;
+ },
+): string => {
+ const { code, status } = readRelayerError(error);
+ const noun = context.operation === "vote" ? "vote" : "delegation";
+
+ if (code === INSUFFICIENT_VOTING_POWER) {
+ return context.minVotingPower === null
+ ? `Your balance is below the minimum for a gas-free ${noun}.`
+ : `A gas-free ${noun} needs at least ${formatThreshold(
+ context.minVotingPower,
+ context.decimals,
+ context.symbol,
+ )}.`;
+ }
+ if (code === RATE_LIMITED || status === 429) {
+ return "You've used all your gas-free operations for this month.";
+ }
+ if (code === "RELAYER_LOW_BALANCE") return "The relayer is out of funds.";
+ if (code === "RELAYER_PAUSED") {
+ return `Gas-free ${context.operation === "vote" ? "voting" : "delegation"} is paused.`;
+ }
+ return classifyRelayerFailure(error) === "pre-broadcast"
+ ? `The gas-free ${noun} did not go through, and nothing was sent.`
+ : `The relayer did not confirm the gas-free ${noun}.`;
+};
+
export const isUserRejection = (error: unknown): boolean => {
const message =
error instanceof Error
diff --git a/apps/dashboard/stories/GaslessVoteFallback.stories.tsx b/apps/dashboard/stories/GaslessVoteFallback.stories.tsx
new file mode 100644
index 0000000000..9641ffec5a
--- /dev/null
+++ b/apps/dashboard/stories/GaslessVoteFallback.stories.tsx
@@ -0,0 +1,394 @@
+import type { Meta, StoryObj } from "@storybook/nextjs";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { useState } from "react";
+import { expect, userEvent, waitFor, within } from "storybook/test";
+import {
+ decodeFunctionData,
+ encodeFunctionResult,
+ parseAbi,
+ type Address,
+} from "viem";
+import { mainnet } from "viem/chains";
+import { createConfig, http, WagmiProvider } from "wagmi";
+import { mock } from "wagmi/connectors";
+
+import type {
+ RelayerRateLimitResponse,
+ RelayerStatusResponse,
+} from "@anticapture/client";
+
+import { VotingModal } from "@/features/governance/components/modals/VotingModal";
+import {
+ ProposalStatus,
+ type ProposalDetails,
+} from "@/features/governance/types";
+import { DaoIdEnum } from "@/shared/types/daos";
+
+/**
+ * Every way the ENS ballot reaches the wallet when the gasless relayer cannot
+ * be used, rendered from the real VotingModal. The wallet is wagmi's mock
+ * connector and both the chain and Gateful are answered by a fetch stub, so
+ * each story runs the same code path the page does with the relayer in that
+ * state.
+ */
+
+const VOTER: Address = "0x76A6D08b82034b397E7e09dAe4377C18F132BbB8";
+const MOCK_RPC_URL = "https://mock-rpc.anticapture.local";
+const VOTING_POWER = "125000000000000000000000";
+
+const governorAbi = parseAbi([
+ "function name() view returns (string)",
+ "function hasVoted(uint256 proposalId, address account) view returns (bool)",
+]);
+
+// Mainnet's id with an RPC the stub answers, so neither the mock connector
+// nor the public client ever leaves the page.
+const mockMainnet = {
+ ...mainnet,
+ rpcUrls: { default: { http: [MOCK_RPC_URL] } },
+} as const;
+
+const AVAILABLE: RelayerStatusResponse = {
+ available: true,
+ reasons: [],
+ checkedAt: "2026-10-24T18:00:00.000Z",
+};
+
+const UNAVAILABLE: RelayerStatusResponse = {
+ available: false,
+ reasons: ["LOW_BALANCE"],
+ checks: {
+ paused: false,
+ rpc: "ok",
+ balance: "low",
+ rateLimiter: "ok",
+ pendingTransactions: "ok",
+ recentRelays: "ok",
+ },
+ checkedAt: "2026-10-24T18:00:00.000Z",
+};
+
+const RATE_LIMIT: RelayerRateLimitResponse = {
+ address: VOTER,
+ vote: { used: 0, remaining: 3, limit: 3 },
+ delegation: { used: 0, remaining: 3, limit: 3 },
+ resetsAt: "2026-11-01T00:00:00.000Z",
+};
+
+type RelayAnswer = "refused-low-balance" | "no-answer";
+
+interface Scenario {
+ status: RelayerStatusResponse;
+ relay?: RelayAnswer;
+}
+
+const json = (body: unknown, status = 200) =>
+ new Response(JSON.stringify(body), {
+ status,
+ headers: { "content-type": "application/json" },
+ });
+
+const answerRpc = (method: string, params: unknown[] | undefined): unknown => {
+ switch (method) {
+ case "eth_chainId":
+ return "0x1";
+ case "eth_accounts":
+ case "eth_requestAccounts":
+ return [VOTER];
+ case "eth_blockNumber":
+ return "0x1";
+ case "eth_signTypedData_v4":
+ return `0x${"11".repeat(32)}${"22".repeat(32)}1b`;
+ case "eth_call": {
+ const [call] = (params ?? []) as [{ data: `0x${string}` }];
+ const { functionName } = decodeFunctionData({
+ abi: governorAbi,
+ data: call.data,
+ });
+ return functionName === "name"
+ ? encodeFunctionResult({
+ abi: governorAbi,
+ functionName: "name",
+ result: "ENS Governor",
+ })
+ : encodeFunctionResult({
+ abi: governorAbi,
+ functionName: "hasVoted",
+ result: false,
+ });
+ }
+ default:
+ return null;
+ }
+};
+
+const answerGateful = (path: string, scenario: Scenario): Response => {
+ if (path.endsWith("/relay/status")) return json(scenario.status);
+ if (path.endsWith("/relay/config")) {
+ return json({
+ minVotingPower: "1000000000000000000",
+ limits: { vote: 3, delegation: 3 },
+ });
+ }
+ if (path.includes("/relay/rate-limit/")) return json(RATE_LIMIT);
+ if (path.includes("/voting-powers/")) {
+ return json({
+ accountId: VOTER,
+ votingPower: VOTING_POWER,
+ votesCount: 12,
+ proposalsCount: 0,
+ delegationsCount: 40,
+ });
+ }
+ if (path.endsWith("/relay/vote")) {
+ return scenario.relay === "refused-low-balance"
+ ? json(
+ {
+ error: "Relayer wallet balance is too low to submit transactions",
+ code: "RELAYER_LOW_BALANCE",
+ },
+ 503,
+ )
+ : new Response("502 Bad Gateway", { status: 502 });
+ }
+ return json({ error: "not mocked" }, 404);
+};
+
+/** Routes every request the modal makes to this story's scenario. */
+const stubNetwork = (scenario: Scenario) => {
+ const realFetch = window.fetch;
+ window.fetch = async (input, init) => {
+ const url =
+ typeof input === "string"
+ ? input
+ : input instanceof URL
+ ? input.href
+ : input.url;
+
+ if (url.startsWith(MOCK_RPC_URL)) {
+ const body = JSON.parse(String(init?.body)) as {
+ id: number;
+ method: string;
+ params?: unknown[];
+ };
+ return json({
+ jsonrpc: "2.0",
+ id: body.id,
+ result: answerRpc(body.method, body.params),
+ });
+ }
+
+ const gateful = url.indexOf("/api/gateful/");
+ if (gateful !== -1) {
+ const path = new URL(url, window.location.origin).pathname;
+ return answerGateful(path, scenario);
+ }
+
+ return realFetch(input, init);
+ };
+ return () => {
+ window.fetch = realFetch;
+ };
+};
+
+let proposalCount = 0;
+
+const buildProposal = (): ProposalDetails => ({
+ // A proposal of its own per story: the vote store lives for the page, and
+ // stories share one page.
+ id: `${51915723803355532053585313225201979004629663092454892570706465301184496475700n + BigInt(proposalCount++)}`,
+ daoId: "ENS",
+ txHash: `0x${"ab".repeat(32)}`,
+ proposerAccountId: "0x983110309620D911731Ac0932219af06091b6744",
+ title: "[EP 6.32] Activate ENSv2",
+ description: "",
+ startBlock: 21_000_000,
+ endBlock: 21_045_000,
+ timestamp: 1_792_500_000,
+ status: ProposalStatus.ONGOING,
+ forVotes: "1400000000000000000000000",
+ againstVotes: "210000000000000000000000",
+ abstainVotes: "35000000000000000000000",
+ quorum: "1000000000000000000000000",
+ startTimestamp: 1_792_500_000,
+ endTimestamp: 1_793_100_000,
+ queuedTimestamp: null,
+ executedTimestamp: null,
+ queuedTxHash: null,
+ executedTxHash: null,
+ proposalType: null,
+ calldatas: [],
+ values: [],
+ targets: [],
+});
+
+const VotingModalStory = () => {
+ const [config] = useState(() =>
+ createConfig({
+ chains: [mockMainnet],
+ connectors: [
+ mock({
+ accounts: [VOTER],
+ features: { defaultConnected: true, reconnect: true },
+ }),
+ ],
+ transports: { [mockMainnet.id]: http(MOCK_RPC_URL) },
+ // One eth_call per read, so the stub answers the governor directly
+ // instead of decoding Multicall3 batches.
+ batch: { multicall: false },
+ }),
+ );
+ const [queryClient] = useState(
+ () => new QueryClient({ defaultOptions: { queries: { retry: false } } }),
+ );
+ const [proposal] = useState(buildProposal);
+
+ return (
+
+
+