Skip to content

Dev - #2175

Open
alextnetto wants to merge 5 commits into
mainfrom
dev
Open

Dev#2175
alextnetto wants to merge 5 commits into
mainfrom
dev

Conversation

@alextnetto

@alextnetto alextnetto commented Oct 21, 2026 •

Copy link
Copy Markdown
Member

Release of #2171: a gasless vote is relayed once and is never reported as failed after it lands, and the relayer answers a replayed or refused vote or delegation with 409 SIMULATION_FAILED instead of 500.

Merge the Version Packages PR #2174 into dev first. Until then, the release-readiness check fails on the unconsumed changeset.


Note

Medium Risk
Touches core governance voting and relayer error semantics; behavior changes are intentional but affect every gasless vote/delegation and how clients interpret failed simulations.

Overview
Fixes gasless voting so a ballot is not replayed at the relayer and relayed votes are not shown as failed after they may have landed.

Relayer maps on-chain simulation reverts (duplicate vote, bad nonce, expired delegation sig, etc.) to 409 SIMULATION_FAILED instead of 500 INTERNAL, with OpenAPI on the vote/delegate routes; nothing is broadcast when simulation fails.

Dashboard moves gasless voting into relayGaslessVote: hasVoted before signing, receipt confirmation on the DAO chain (not the wallet RPC), and outcomes like unconfirmed/unknown treated as pending rather than failure. A page-lifetime submission store (claimVote / settleVote, modal screens for pending/already-voted) stops double-clicks and reopening the modal from offering the ballot again; the proposal header reflects the submitted choice before the indexer catches up, with vote polling until the matching tx is indexed. User-facing errors recognize simulation “already voted”; relayer config/rate-limit queries get 60s stale time and invalidation after relayed vote/delegation.

Wallet path stays in voteOnProposal only (gasless removed from there).

Reviewed by Cursor Bugbot for commit a2f82f1. Configure here.

alextnetto and others added 5 commits October 20, 2026 10:48
…IMULATION_FAILED

castVoteBySig and delegateBySig simulations that reverted reached the global
handler as 500 INTERNAL. A second click on Vote re-signs the same ballot byte
for byte, so after a successful gasless vote the replay reverted with
"vote already cast" and the user got a 500, which Gateful also counts against
the relayer circuit breaker.

Reverts now map to the existing SIMULATION_FAILED (409) contract that the
dashboard already reads as pre-broadcast. A simulation that could not run
(RPC down) still surfaces as 500. The revert helpers move from the governor
gateway to services/chain/revert.ts so both paths share them.
…odal close

Map the relayer's SIMULATION_FAILED to clear copy (already voted, or would
revert and was not sent) instead of "Something went wrong. Try again later",
which invited the same failing retry.

Escape and the mobile drawer closed the voting modal without the reload the
X button and backdrop already did after a successful vote, so the page kept
offering Vote and a second click replayed the same signature.
…vote as failed

Production, 2026-10-20: the first relay call answered 200 in 357 ms and the
vote was mined, but the dashboard then waited for the receipt through the
user's wallet provider. That wait failed, the shared catch showed "Something
went wrong", the ballot came back, and two more clicks replayed the same
signature at the relayer.

- relayGaslessVote (modelled on relayGovernanceAction): reads hasVoted before
  asking for a signature, relays exactly once, and confirms the transaction
  on a public client for the DAO's chain. A failed receipt wait is
  "unconfirmed", an ambiguous relay failure is "unknown", and the
  relayer's "vote already cast" is a vote that exists. None of them is an
  error toast.
- The vote's submission lives in the page-lifetime submission store used by
  queue/execute, keyed by DAO, proposal and voter. A synchronous claim blocks
  double clicks, and closing, reopening or remounting the modal mirrors the
  in-flight, landed or pending vote instead of resetting to the ballot.
- The proposal page shows "You voted" as soon as the vote landed and polls
  the account's vote until it is indexed (two minutes at most), then
  refetches the tally and votes table. This replaces the page reload.
- Relayer balance, config and rate-limit queries get a 60 s stale time; each
  observer mount used to refetch them, two or three times per second.
- voteOnProposal now covers wallet votes only.
- Votes can be changed again on UNI and TORN: a landed vote reopens the
  ballot where the DAO allows changing votes, while an in-flight or
  ambiguous one still blocks a second submission.
- "Indexed" now means the submitted transaction itself, so polling keeps
  going while the API still returns the vote a change replaced.
- An "already voted" answer no longer shows the attempted choice as the
  vote on record; the modal ends on its own screen with a Close button.
- A gasless delegation invalidates the cached delegation allowance, so the
  next one does not hit the relayer's 429 on a stale count.
- Plain-language copy for a relay the governor would refuse.
- Gateful changeset: the relay routes it exposes now document the 409.
fix(relayer,dashboard): relay a gasless vote once; no 500 or error toast after it lands
@vercel

vercel Bot commented Oct 21, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
anticapture-storybook Ready Ready Preview Oct 21, 2026 1:29pm UTC
1 Skipped Deployment
Project Deployment Actions Updated
anticapture (dev) Ignored Ignored Oct 21, 2026 1:29pm UTC

Request Review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-21T13:37:19.621337Z a2f82f1 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

Copy link
Copy Markdown
Collaborator

🎨 UI Review

Automated review · ℹ️ Reviewed without a Figma reference — grounded in the diff + UX-expert evaluation. No dashboard Vercel preview was posted on this PR (only Storybook), so visual claims are limited to what the code renders.


Voting modal — "Your vote was sent" (VotePendingContent.tsx)

  • Dead end: this is the only terminal screen with no button. "Vote submitted" and "You have already voted" both end with a Close Button; this one leaves only the header X. Add <Button variant="outline" onClick={onClose}>Close</Button> under the alert, same as VoteAlreadyCastContent. [UX-heuristic]
  • The warning InlineAlert carries 2–3 sentences (text-sm, text-warning on bg-warning/10) with the icon vertically centered (items-center in the DS component). Long body copy in warning color is hard to read and the centered icon floats mid-paragraph. Keep the alert to one line ("Not confirmed yet.") and move the rest into a text-secondary text-sm leading-5 paragraph like the one in VoteAlreadyCastContent. [UX-heuristic]
  • Copy: "The relayer did not answer clearly" leaks infra jargon. Use something like "We couldn't confirm your vote yet. It may already be on its way." [UX-heuristic]
  • Explorer link is text-sm leading-5 (20px tall) — below the 44px touch target on mobile. Add py-3 (or min-h-11) to the <a>. [UX-heuristic]
  • Heading says "Your vote was sent" while the icon is a neutral hourglass and the alert is a warning. Three different tones on one screen; pick one (neutral hourglass + info alert reads as "pending", warning reads as "problem"). [UX-heuristic]

Voting modal — "You have already voted" (VoteAlreadyCastContent.tsx)

  • Uses the same green CheckCircle2 in bg-surface-opacity-success as "Vote submitted successfully!". A user who clicked Submit sees a success-looking screen for a vote that was not just cast. Use the Info icon on bg-surface-contrast (the DS info variant look) so it doesn't read as a fresh success. [UX-heuristic]
  • Missing the vote the user cast. Show the For / Against / Abstain pill (same bg-surface-opacity-* pill as LoadingContent) so "Your earlier vote is the one that counts" tells them which one. [UX-heuristic]

Voting modal — in-flight screen (VotingModal.tsx, LoadingComponent props)

  • vote={SUPPORT_LABEL[voteDetails.support ?? 2]} falls back to Abstain when the support is unknown (e.g. modal reopened after a refresh). The pill would show "Abstain" for a vote that was For/Against. Question for author: hide the Vote row when support is unknown instead of defaulting. [Code-only]
  • Submit button no longer shows its loading spinner; the whole body swaps to the in-flight screen. Confirm the footer (Submit + torn-delegators messages) is gone on that screen so there's no empty bordered footer strip. [Code-only]

Mobile

  • All three new screens are px-4 py-8 centered stacks with no w-full on the single Buttons — fine on desktop, but on mobile the lone Close button should be w-full to match the bottom-sheet pattern used elsewhere in the modal. [UX-heuristic]

Proposal page header

  • No visible change besides the Vote/voted state now flipping as soon as the vote is submitted. Confirm the "voted" state shows the correct For/Against/Abstain badge (not blank) during the window where indexed support is absent.

Delegation modal / Relayer

  • No UI changes (rate-limit refetch only). Nothing to report.

Generated by Claude Code

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Wallet vote wait treated as retryable
    • Wallet votes that broadcast but fail receipt wait now settle as ambiguous using the captured hash, so the ballot is not offered for retry.

Create PR

Or push these changes by commenting:

@cursor push fd242874b6
Preview (fd242874b6)
diff --git a/apps/dashboard/features/governance/components/modals/VotingModal.tsx b/apps/dashboard/features/governance/components/modals/VotingModal.tsx
--- a/apps/dashboard/features/governance/components/modals/VotingModal.tsx
+++ b/apps/dashboard/features/governance/components/modals/VotingModal.tsx
@@ -3,7 +3,7 @@
 import { useQueryClient } from "@tanstack/react-query";
 import { Check, User2Icon, X } from "lucide-react";
 import { useEffect, useMemo, useState } from "react";
-import type { Address } from "viem";
+import type { Address, Hash } from "viem";
 import { formatUnits } from "viem";
 import { useAccount, usePublicClient, useWalletClient } from "wagmi";
 
@@ -347,6 +347,7 @@
           );
         }
       } else {
+        let broadcastHash: Hash | null = null;
         const receipt = await voteOnProposal(
           vote,
           proposal.id,
@@ -354,7 +355,10 @@
           chain,
           daoId,
           walletClient,
-          setTransactionhash,
+          (hash) => {
+            setTransactionhash(hash);
+            if (hash) broadcastHash = hash as Hash;
+          },
           comment,
           tornDelegatedVoteAddresses,
         );
@@ -362,7 +366,13 @@
           showCustomToast("Your vote transaction reverted on-chain.", "error");
         }
         settlement = settlementForWalletVote(
-          receipt?.status === "success" ? receipt.transactionHash : null,
+          receipt
+            ? {
+                status: receipt.status,
+                transactionHash: receipt.transactionHash,
+              }
+            : null,
+          broadcastHash,
         );
       }
     } catch (error) {

diff --git a/apps/dashboard/features/governance/utils/voteSubmission.test.ts b/apps/dashboard/features/governance/utils/voteSubmission.test.ts
--- a/apps/dashboard/features/governance/utils/voteSubmission.test.ts
+++ b/apps/dashboard/features/governance/utils/voteSubmission.test.ts
@@ -68,7 +68,7 @@
   it("allows a new attempt only after one that changed nothing", () => {
     const key = freshKey();
     claimVote(key, "gasless", 1);
-    settleVote(key, settlementForWalletVote(null));
+    settleVote(key, settlementForWalletVote(null, null));
 
     expect(claimVote(key, "gasless", 0)).toBe(true);
     expect(readVoteDetails(key).support).toBe(0);
@@ -85,7 +85,10 @@
   it("lets a landed vote be changed where the DAO allows it", () => {
     const key = freshKey();
     claimVote(key, "wallet", 1, true);
-    settleVote(key, settlementForWalletVote(hash));
+    settleVote(
+      key,
+      settlementForWalletVote({ status: "success", transactionHash: hash }),
+    );
 
     expect(claimVote(key, "wallet", 0, true)).toBe(true);
     expect(readVoteDetails(key).support).toBe(0);
@@ -134,6 +137,15 @@
   });
 });
 
+describe("settlementForWalletVote", () => {
+  it("keeps a broadcast vote in doubt when the receipt could not be read", () => {
+    expect(settlementForWalletVote(null, hash)).toEqual({
+      kind: "ambiguous",
+      hash,
+    });
+  });
+});
+
 describe("settlementForGaslessVote", () => {
   const cases: Array<
     [GaslessVoteOutcome, ReturnType<typeof settlementForGaslessVote>]

diff --git a/apps/dashboard/features/governance/utils/voteSubmission.ts b/apps/dashboard/features/governance/utils/voteSubmission.ts
--- a/apps/dashboard/features/governance/utils/voteSubmission.ts
+++ b/apps/dashboard/features/governance/utils/voteSubmission.ts
@@ -135,13 +135,32 @@
   }
 };
 
-/** The wallet path reports its confirmed hash, or null when nothing landed. */
-export const settlementForWalletVote = (hash: Hash | null): VoteSettlement =>
-  hash
-    ? { kind: "done", outcome: "landed", hash }
-    : { kind: "done", outcome: "failed", hash: null };
+type WalletVoteReceipt = {
+  status: "success" | "reverted";
+  transactionHash: Hash;
+};
 
 /**
+ * The wallet path reports a read receipt, or a broadcast hash when the
+ * receipt could not be read. Only a failure with no broadcast is retryable.
+ */
+export const settlementForWalletVote = (
+  receipt: WalletVoteReceipt | null,
+  broadcastHash: Hash | null = null,
+): VoteSettlement => {
+  if (receipt?.status === "success") {
+    return { kind: "done", outcome: "landed", hash: receipt.transactionHash };
+  }
+  if (receipt?.status === "reverted") {
+    return { kind: "done", outcome: "failed", hash: receipt.transactionHash };
+  }
+  if (broadcastHash) {
+    return { kind: "ambiguous", hash: broadcastHash };
+  }
+  return { kind: "done", outcome: "failed", hash: null };
+};
+
+/**
  * What the voting modal shows for a submission. Only a vote that may start
  * again gets the ballot; every other state is mirrored on any open, so no
  * screen offers a submission that could replay or race another.

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit a2f82f1. Configure here.

proposalId: BigInt(params.proposalId),
support: params.voteNumber,
},
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wallet vote wait treated as retryable

Medium Severity

voteOnProposal still returns null when waitForTransactionReceipt fails after writeContract has already broadcast the vote. The new settlement maps that null through settlementForWalletVote to a retryable failed outcome, so the ballot comes back even though the vote may already be on-chain.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a2f82f1. Configure here.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a2f82f16b4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +335 to +336
if (outcome.hash) {
void queryClient.invalidateQueries({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Invalidate the quota after every gasless relay attempt

When the relayer rejects a vote during simulation or returns an ambiguous error without a hash, this invalidation is skipped. The relayer has already incremented the monthly allowance before simulation in RelayService.relayVote, while useRelayerRateLimit now caches the old value for 60 seconds; consequently, a voter whose last slot was consumed by a refused request remains marked gasless-eligible and subsequent proposals are incorrectly sent to the relayer only to receive 429 responses. Invalidate the rate-limit query after the relay request settles even when no hash is returned.

Useful? React with 👍 / 👎.

Comment on lines +380 to +384
useEffect(() => {
if (isOffchain || !isWatchingOnchainVote || !isOnchainVoteIndexed) return;
const daoKey = daoEnum.toLowerCase() as ProposalPathParamsDaoEnumKey;
void queryClient.invalidateQueries({
queryKey: proposalQueryKey(daoKey, proposalId),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear the pending state once the vote is indexed

When receipt confirmation fails but the relayed transaction subsequently lands, isOnchainVoteIndexed eventually becomes true, yet this effect only invalidates queries and leaves the submission as ambiguous. Because getVoteScreen maps that state to pending, reopening the modal continues to claim the vote is unconfirmed for the rest of the page lifetime even though the API has indexed the exact transaction; it would also permanently block a later change on any changeable-vote DAO using this flow. Promote the stored submission to a landed state when the matching indexed vote is observed.

Useful? React with 👍 / 👎.

Comment on lines +66 to +69
const reader = {
readContract,
waitForTransactionReceipt,
} as unknown as VoteChainReader;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Replace the unsafe test-double casts

This new test double bypasses the VoteChainReader contract with as unknown as, and the signer immediately below uses the same pattern, so interface changes can silently leave these tests exercising an invalid fake instead of producing a type error. Define correctly typed implementations of the narrowed interfaces rather than suppressing their incompatibilities.

AGENTS.md reference: AGENTS.md:L127-L133

Useful? React with 👍 / 👎.

This branch had an error being deployed

1 failed and 1 active deployments
Preview – anticapture-storybook — a2f82f16 Deployed Oct 21, 2026 by vercel[bot]
dev — a2f82f16 Deployed Oct 21, 2026 by alextnetto via Deploy Vercel (dev) #153
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants