Repository navigation
feat: keep voting possible whenever the gasless relayer cannot be used - #2176
alextnetto wants to merge 6 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
1 Skipped Deployment
|
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
🚅 Deployed to the anticapture-pr-2176 environment in anticapture-infra
|
🎨 UI Review
Voting modal — payment sectionHand-rolled controls
Repeated info box
Loading state / layout shift
Copy
Vote pending screen
Mobile
Generated by Claude Code |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 111022508d
ℹ️ 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".
| // Why the last relayed attempt from this ballot did not go through. Once | ||
| // set, the ballot stays on the wallet for the life of the page: the same | ||
| // relayer would most likely refuse the same voter the same way. | ||
| const [gaslessFailure, setGaslessFailure] = useState<string | null>(null); |
There was a problem hiding this comment.
Scope the gasless failure state to the current voter
If a relayed vote is refused for one account (for example, because its monthly allowance is exhausted), switching to another eligible wallet on the same proposal still leaves gaslessFailure set. useVoteSubmission correctly changes its key with the connected address, but this state is never reset or keyed by that address, so getVotePayment forces the second account onto a paid wallet transaction even when the relayer is available and that account is eligible. Reset the failure when voteKey changes, or retain failures per submission key so one account's refusal does not disable gasless voting for another.
Useful? React with 👍 / 👎.
| // A failed status read already means "use the wallet"; retrying it | ||
| // would only hold the ballot on the slower path for longer. |
There was a problem hiding this comment.
Refresh relayer availability while the proposal stays mounted
If the relayer becomes paused, unreachable, or stuck while a user reads a proposal, this query continues returning its original availability indefinitely: React Query's staleTime marks data stale but does not schedule a refetch. ProposalSection keeps VotingModal and both governance action modals mounted even when closed, and GlobalProviders configures no polling, so reopening a modal in the same focused tab does not refresh the status either. Consequently, the advertised 30-second freshness window does not prevent offering a broken gasless path, and the new pending-nonce check may never receive the subsequent probe needed to classify a transaction as stuck. Add a bounded refetch interval or explicitly refresh status when opening the ballot.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Status timeouts can crash relayer
- Added a catch handler on the raced promise so late Redis/RPC rejections after the timeout cannot become unhandled rejections.
Or push these changes by commenting:
@cursor push f13e0c6a46
Preview (f13e0c6a46)
diff --git a/apps/relayer/src/services/status/relayer-status.ts b/apps/relayer/src/services/status/relayer-status.ts
--- a/apps/relayer/src/services/status/relayer-status.ts
+++ b/apps/relayer/src/services/status/relayer-status.ts
@@ -61,6 +61,10 @@
ms,
);
});
+ void promise.catch(() => {
+ // After the race settles on the timeout, a late rejection must not crash
+ // the process.
+ });
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}You can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit 1110225. Configure here.
| ); | ||
| }); | ||
| return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); | ||
| } |
There was a problem hiding this comment.
Status timeouts can crash relayer
High Severity
withTimeout races each status check and then abandons the original promise. When Redis is down, ping() stays queued and later rejects after the 3s cap, which becomes an unhandled rejection and can take down the process. Hung RPC reads have the same problem and can also accumulate leftover connections every cache window.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 1110225. Configure here.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d7987af321
ℹ️ 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".
| if ( | ||
| submission.hash && | ||
| (await isRelayedVotePending(publicClient, submission.hash)) | ||
| ) { |
There was a problem hiding this comment.
Allow wallet fallback when the relayed transaction is stuck
If the relayed vote remains in the mempool because its fee is too low or an earlier relayer nonce is stuck, isRelayedVotePending keeps returning true and this branch prevents the wallet fallback indefinitely. Even after /relay/status reports PENDING_TRANSACTIONS_STUCK, clicking “Vote with my wallet instead” only displays an error, potentially leaving the voter unable to vote before the deadline. Bound this waiting period or allow an explicit wallet fallback for a stuck relay; the wallet uses a different sender nonce, and the existing on-chain duplicate check can still run first.
Useful? React with 👍 / 👎.



Why
On 2026-10-21 the ENS relayer key leaked and its wallet was drained. The dashboard only asked
GET /relay/balancebefore offering a gasless vote, so most ways the relayer can break (RPC down, Redis down, a stuck nonce, broadcasts that keep failing, a key we want switched off) were invisible to it. A voter could hit Submit, get a generic error, and hit the same broken path again. The rule this PR enforces: the relayer is a convenience, the wallet is the guarantee. A voter is never left without a way to vote.Relayer:
GET /relay/statusandGASLESS_PAUSEDGET /relay/status(getRelayerStatus), always 200, returns{ available, reasons[], checks, balanceWei, thresholdWei, checkedAt }. Checks run in parallel, each bounded at 3 s, and the answer is cached for 10 s:PAUSED: the newGASLESS_PAUSEDenv var (only"true"pauses).RPC_UNAVAILABLE/WRONG_CHAIN/CHAIN_STALE(latest block older than 120 s).LOW_BALANCE/BALANCE_UNKNOWN.RATE_LIMITER_UNAVAILABLE(Redis ping).PENDING_TRANSACTIONS_STUCK(pending nonce ahead of the mined one, with no progress, for 5 min).RECENT_RELAY_FAILURES(3 broadcast failures within 10 min and no success since). The shared signer is wrapped, so votes, delegations, queue, execute and webhook enactment all feed it.GASLESS_PAUSED=truemakes vote, delegate, queue and execute refuse with503 RELAYER_PAUSEDbefore recovering a signature or reading the chain. Webhook enactment reports it as skipped. This is the switch to flip the next time a key is in doubt./healthis unchanged (still the deploy gate)./relay/balancestays for back-compat.Gateful:
/{dao}/relay/statusalways answers/relay/*passes the relayer's answer through. When the relayer cannot answer, Gateful answers for it withavailable: false:RELAYER_NOT_CONFIGUREDfor a DAO with no relayer, andRELAYER_UNREACHABLEfor a timeout, an error, a malformed body or an open circuit.breaker.execute, so polling dashboards cannot trip or occupy the circuit that real relay traffic uses (tested).Dashboard: every relayer problem lands on the wallet
useRelayerStatus: only an explicitavailable: trueoffers gasless; an error, a loading state or any reason goes to the wallet. This covers votes, delegations, queue and execute.votePayment.ts, unit tested):hasVotedon-chain first, so a late relayed vote is not duplicated. If both land anyway, the governor counts only the first and the second reverts.RELAYER_PAUSEDis mirrored inENACTMENT_REJECTION_CODES.VotingModalin each of the six states, with wagmi's mock connector and a stubbed chain and Gateful.Tests
relayer.test.ts), tsc and eslint clean.Deploy notes
/relay/status, Gateful answersRELAYER_UNREACHABLEand the dashboard uses the wallet.LOW_BALANCEand ENS votes go through the wallet until the key is rotated. SettingGASLESS_PAUSED=trueonens-relayeruntil then makes that explicit.Note
Medium Risk
Touches governance vote submission, relayer availability, and gateway proxy behavior; incorrect fallback or status handling could mis-route votes or hide gasless outages, but wallet voting remains the safety net.
Overview
Gasless voting is now gated on relayer health, with the wallet as the always-available path. The relayer exposes
GET /relay/status(always 200, parallel self-checks, short cache) and aGASLESS_PAUSEDkill switch that refuses relays and enactment before signatures or chain reads. Gateful proxies that status with the same contract, answeringavailable: falsewhen the relayer is missing, unreachable, or behind an open circuit—without using the relay circuit breaker for polls.The dashboard replaces balance-only gating with
useRelayerStatus: gasless is offered only on explicitavailable: true.votePaymentdrives labels and warnings; failed or unconfirmed relays stick the ballot on Vote with wallet, optional opt-out/in, and pending votes without a hash can fall back after an on-chainhasVotedcheck viareleaseUnsentVote.Reviewed by Cursor Bugbot for commit 1110225. Configure here.
Review fixes (Fable review, medium findings)
GASLESS_PAUSED: accepts true/1/yes/on in any case, and refuses to start on a value it does not recognise, so a typo can no longer leave the switch silently off.hasVoted, then whether the relayed transaction is still in the mempool, and only then hands the ballot to the wallet.hasVotedbefore sending (after a relayed attempt) and after any failure or revert. If the relayed vote already counts, the modal says the vote is cast instead of offering the ballot again.relayer_gasless_availableandrelayer_gasless_unavailable_reason{reason}on/metrics, andinfra/monitoring/alerts.ymladds aGaslessRelayerUnavailablewarning after 10 minutes (deliberate pauses excluded). Gateful logs once when it starts answering for an unreachable relayer and once when it recovers.Tests: relayer 161, gateful 196, dashboard 984, all passing; typecheck and lint clean; the six Storybook flows pass.
Why the Railway preview checks fail
The failures are in the
anticapture-pr-2176environment, not in this code, and the code checks (test,typecheck, E2E) were skipped because they wait for the preview Gateful.erpcstarts with zero upstreams on every network. Chainstack upstreams usechainstack://${CHAINSTACK_API_KEY}, and that platform key was deleted on 2026-10-22 during the incident response. The previewnodefulproxy also fails with "API key ... not valid" (Tailscale auth key revoked)./healthanswers 503, and "Wait for preview gateful" times out.CHAINSTACK_API_KEYon eRPC, and give the PR-environmentnodefula valid Tailscale auth key. The same applies to production: restarting the production eRPC before that key is set would drop its Chainstack fallback.