Skip to content

feat: keep voting possible whenever the gasless relayer cannot be used - #2176

Open
alextnetto wants to merge 6 commits into
devfrom
feat/gasless-health-fallback
Open

alextnetto wants to merge 6 commits into
devfrom
feat/gasless-health-fallback

Conversation

@alextnetto

@alextnetto alextnetto commented Oct 24, 2026 •

Copy link
Copy Markdown
Member

Why

On 2026-10-21 the ENS relayer key leaked and its wallet was drained. The dashboard only asked GET /relay/balance before 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/status and GASLESS_PAUSED

  • New GET /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 new GASLESS_PAUSED env 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=true makes vote, delegate, queue and execute refuse with 503 RELAYER_PAUSED before 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.
  • /health is unchanged (still the deploy gate). /relay/balance stays for back-compat.

Gateful: /{dao}/relay/status always answers

  • A dedicated handler ahead of /relay/* passes the relayer's answer through. When the relayer cannot answer, Gateful answers for it with available: false: RELAYER_NOT_CONFIGURED for a DAO with no relayer, and RELAYER_UNREACHABLE for a timeout, an error, a malformed body or an open circuit.
  • The probe never goes through 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

  • All gasless gating now reads useRelayerStatus: only an explicit available: true offers gasless; an error, a loading state or any reason goes to the wallet. This covers votes, delegations, queue and execute.
  • The voting modal (logic in votePayment.ts, unit tested):
    • Healthy: "Gas-free vote", button Submit gas-free, plus Pay with my wallet instead at all times.
    • Unavailable: a warning and Vote with wallet.
    • Refused before broadcast (low balance, paused, rate limit, voting power, RPC error, ...): the reason, then Vote with wallet. The relayer is not offered again on that ballot.
    • Relayer never confirmed and returned no hash: the pending screen now has Vote with my wallet instead. It checks hasVoted on-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_PAUSED is mirrored in ENACTMENT_REJECTION_CODES.
  • New Storybook story Governance/Gasless Vote Fallback renders the real VotingModal in each of the six states, with wagmi's mock connector and a stubbed chain and Gateful.

Tests

  • relayer: 139 passed (was 95), typecheck and lint clean.
  • gateful: 195 passed (13 new in relayer.test.ts), tsc and eslint clean.
  • dashboard: 975 passed, tsc and eslint clean.
  • The six Storybook play functions pass.
  • Not run: relayer e2e (needs anvil and an archive RPC). The client was regenerated locally from the dev Gateful spec plus the new path; CI regenerates it from the preview Gateful.

Deploy notes

  • The order does not matter for safety: until the relayer serves /relay/status, Gateful answers RELAYER_UNREACHABLE and the dashboard uses the wallet.
  • Prod ENS relayer today is still on the leaked key with an empty wallet, so after this ships the status will read LOW_BALANCE and ENS votes go through the wallet until the key is rotated. Setting GASLESS_PAUSED=true on ens-relayer until 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 a GASLESS_PAUSED kill switch that refuses relays and enactment before signatures or chain reads. Gateful proxies that status with the same contract, answering available: false when 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 explicit available: true. votePayment drives 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-chain hasVoted check via releaseUnsentVote.

Reviewed by Cursor Bugbot for commit 1110225. Configure here.

Review fixes (Fable review, medium findings)

  • Delegation fallback: a refused or unconfirmed gas-free delegation moves that account to its wallet, with a "Delegate with my wallet" button and copy that names the reason. A ref guard stops a background status refresh from asking for a second signature mid-signing.
  • 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.
  • Per-voter fallback state: "gas-free failed" and "pay with my wallet" now live in a page-lifetime store keyed by DAO, proposal and voter, so they follow the voter across account switches and remounts.
  • Unconfirmed hash: a relayed vote whose hash never confirms also gets "Vote with my wallet instead". It checks hasVoted, then whether the relayed transaction is still in the mempool, and only then hands the ballot to the wallet.
  • Late-broadcast race: the wallet path reads hasVoted before 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.
  • Already on-chain: when the fallback check finds the vote on-chain, the submission settles as landed and the success screen replaces the waiting screen.
  • Status refresh: the relayer status refetches every 60 s while a page is open.
  • Operator signal: the relayer publishes relayer_gasless_available and relayer_gasless_unavailable_reason{reason} on /metrics, and infra/monitoring/alerts.yml adds a GaslessRelayerUnavailable warning after 10 minutes (deliberate pauses excluded). Gateful logs once when it starts answering for an unreachable relayer and once when it recovers.
  • Also: copy before "You can still vote with your wallet" is now about the gas-free allowance, not about voting itself, and the payment mode on the Submit button reaches PostHog.

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-2176 environment, not in this code, and the code checks (test, typecheck, E2E) were skipped because they wait for the preview Gateful.

  • The preview erpc starts with zero upstreams on every network. Chainstack upstreams use chainstack://${CHAINSTACK_API_KEY}, and that platform key was deleted on 2026-10-22 during the incident response. The preview nodeful proxy also fails with "API key ... not valid" (Tailscale auth key revoked).
  • With no RPC, the relayer cannot start, the DAO APIs fail their health checks, Gateful's /health answers 503, and "Wait for preview gateful" times out.
  • Fix: create a new Chainstack platform key and set CHAINSTACK_API_KEY on eRPC, and give the PR-environment nodeful a valid Tailscale auth key. The same applies to production: restarting the production eRPC before that key is set would drop its Chainstack fallback.

@vercel

vercel Bot commented Oct 24, 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 25, 2026 10:29pm UTC
1 Skipped Deployment
Project Deployment Actions Updated
anticapture Ignored Ignored Oct 25, 2026 10:29pm UTC

Request Review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 24, 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-25T22:29:09.147212Z d7987af New commits
ℹ️ 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.

@railway-app

railway-app Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

🚅 Deployed to the anticapture-pr-2176 environment in anticapture-infra

Service Status Web Updated
nouns-indexer ✅ Success (View Logs) Oct 31, 2026 at 5:42 pm UTC
ens-relayer ❌ Build Failed (View Logs) Oct 25, 2026 at 10:33 pm UTC
gateful ❌ Build Failed (View Logs) Web Oct 25, 2026 at 10:32 pm UTC
prometheus ✅ Success (View Logs) Oct 25, 2026 at 10:27 pm UTC
authful ✅ Success (View Logs) Web Oct 25, 2026 at 10:27 pm UTC
otelcol ✅ Success (View Logs) Oct 25, 2026 at 10:27 pm UTC
grafana ✅ Success (View Logs) Web Oct 25, 2026 at 10:27 pm UTC
alertmanager ✅ Success (View Logs) Web Oct 25, 2026 at 10:27 pm UTC
loki ✅ Success (View Logs) Oct 25, 2026 at 10:27 pm UTC
tempo ✅ Success (View Logs) Oct 25, 2026 at 10:27 pm UTC
docs ✅ Success (View Logs) Web Oct 25, 2026 at 10:27 pm UTC
mcp ✅ Success (View Logs) Web Oct 25, 2026 at 10:27 pm UTC
nodeful 🚨 Crashed (View Logs) Oct 25, 2026 at 1:27 am UTC
ens-indexer-offchain ✅ Success (View Logs) Oct 25, 2026 at 1:27 am UTC
gitcoin-indexer-offchain ✅ Success (View Logs) Oct 25, 2026 at 1:27 am UTC
compound-indexer-offchain ✅ Success (View Logs) Oct 25, 2026 at 12:46 am UTC
shutter-indexer-offchain ✅ Success (View Logs) Oct 25, 2026 at 12:28 am UTC
uniswap-indexer-offchain ✅ Success (View Logs) Oct 25, 2026 at 12:26 am UTC
fluid-api ❌ Build Failed (View Logs) Oct 24, 2026 at 6:58 pm UTC
lil-nouns-api ❌ Build Failed (View Logs) Oct 24, 2026 at 6:58 pm UTC
ens-api ❌ Build Failed (View Logs) Oct 24, 2026 at 6:58 pm UTC
tornado-api ❌ Build Failed (View Logs) Oct 24, 2026 at 6:58 pm UTC
uniswap-api ❌ Build Failed (View Logs) Oct 24, 2026 at 6:58 pm UTC
obol-api ❌ Build Failed (View Logs) Oct 24, 2026 at 6:58 pm UTC
gitcoin-api ❌ Build Failed (View Logs) Oct 24, 2026 at 6:58 pm UTC
aave-api ❌ Build Failed (View Logs) Oct 24, 2026 at 6:58 pm UTC
nouns-api ❌ Build Failed (View Logs) Oct 24, 2026 at 6:58 pm UTC
compound-api ❌ Build Failed (View Logs) Oct 24, 2026 at 6:58 pm UTC
shutter-api ❌ Build Failed (View Logs) Oct 24, 2026 at 6:58 pm UTC
fluid-indexer ✅ Success (View Logs) Oct 24, 2026 at 6:53 pm UTC
uniswap-indexer ✅ Success (View Logs) Oct 24, 2026 at 6:53 pm UTC
lil-nouns-indexer ✅ Success (View Logs) Oct 24, 2026 at 6:53 pm UTC
shutter-indexer ✅ Success (View Logs) Oct 24, 2026 at 6:53 pm UTC
gitcoin-indexer ✅ Success (View Logs) Oct 24, 2026 at 6:53 pm UTC
ens-indexer ✅ Success (View Logs) Oct 24, 2026 at 6:53 pm UTC
address-enrichment ✅ Success (View Logs) Web Oct 24, 2026 at 6:53 pm UTC
tornado-indexer ✅ Success (View Logs) Oct 24, 2026 at 6:53 pm UTC
aave-indexer ✅ Success (View Logs) Oct 24, 2026 at 6:53 pm UTC
obol-indexer ✅ Success (View Logs) Oct 24, 2026 at 6:53 pm UTC
compound-indexer ✅ Success (View Logs) Oct 24, 2026 at 6:53 pm UTC
scroll-indexer ✅ Success (View Logs) Oct 24, 2026 at 6:53 pm UTC
erpc ❌ Build Failed (View Logs) Oct 24, 2026 at 6:53 pm UTC
scroll-api ❌ Build Failed (View Logs) Oct 24, 2026 at 6:50 pm UTC
user-api ✅ Success (View Logs) Web Oct 24, 2026 at 6:44 pm UTC

Copy link
Copy Markdown
Collaborator

🎨 UI Review

Automated review · Figma: not found (searched PR body + the two ClickUp lists + workspace) · Preview: unreachable from the routine (no Vercel bypass token)
ℹ️ Reviewed without a Figma reference — grounded in the diff + UX-expert evaluation. Vercel preview was not reachable, so visual claims are limited to what the classes/copy render.


Voting modal — payment section

Hand-rolled controls

  • "Pay with my wallet instead" and "Use the gas-free vote" are raw <button className="text-link ...">. Use the DS Button (ghost/link variant) so hover, focus ring and disabled states match the rest of the modal. (VotingModal.tsx, both text-link cursor-pointer text-sm leading-5 buttons) [Code-only]
  • Touch target: those two buttons are text-sm leading-5 = 20px tall. Below the 44px minimum on mobile; they sit right next to a 44px Submit and will be mis-tapped. Give them the DS button height on mobile. [UX-heuristic]
  • No focus-visible style on either raw button — keyboard users get no ring. The DS Button fixes this. [UX-heuristic]

Repeated info box

  • The "Gas-free vote…" and "You pay the network fee…" boxes are the same hand-rolled border border-border-default bg-surface-contrast px-[10px] py-2 block twice. Use a DS container/InlineAlert (info) instead of a one-off, and drop the px-[10px] arbitrary value for a spacing token. [Code-only]

Loading state / layout shift

  • While the relayer status loads, getVotePayment resolves to wallet with reason none: no box, button reads "Submit". When status lands the box appears and the button flips to "Submit gas-free", so content jumps and the primary label changes under the cursor. Reserve the box height with a DS Skeleton while loading, and keep the button label stable (or disabled) until status resolves. [UX-heuristic]

Copy

  • "the relayer pays the network fee" leaks dev jargon. Say "We cover the network fee for this vote." [UX-heuristic]
  • Button labels are inconsistent: "Vote with wallet" (modal) vs "Vote with my wallet instead" (pending screen). Pick one. [UX-heuristic]
  • "Checking the chain..." — voters don't know what "the chain" check means. Use "Checking your vote…" and use the real ellipsis … (the file already uses … in shortenHash). [UX-heuristic]

Vote pending screen

  • After "Vote with my wallet instead" finds the vote already on-chain, the only feedback is a toast; the screen stays and the button re-enables, so the voter can keep clicking it and nothing changes. Replace the button with a confirmed state (or keep it disabled) once the vote is found. (VotePendingContent.tsx / handleVoteWithWallet) [UX-heuristic]
  • While checking, the button only changes its text. Add the DS spinner/loading state so it reads as busy and not as disabled. [UX-heuristic]

Mobile

  • The extra payment box + warning InlineAlert stack above the footer; with flex-wrap the link-button drops under the text at small widths. Confirm the Submit footer stays visible without the modal body becoming the only scroll area, and that the modal is still a bottom-sheet drawer on mobile (not in this diff — please verify in the Storybook story at mobile width). [UX-heuristic]

Generated by Claude Code

@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: 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);

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 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 👍 / 👎.

Comment on lines +80 to +81
// A failed status read already means "use the wallet"; retrying it
// would only hold the ballot on the slower path for longer.

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 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 👍 / 👎.

@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: 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.

Create PR

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));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1110225. 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: 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".

Comment on lines +488 to +491
if (
submission.hash &&
(await isRelayedVotePending(publicClient, submission.hash))
) {

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 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 👍 / 👎.

This branch had an error being deployed

1 failed and 1 active deployments
Preview – anticapture-storybook — d7987af3 Deployed Oct 25, 2026 by vercel[bot]
anticapture-infra / anticapture-pr-2176 — d7987af3 Deployed Oct 25, 2026 by railway-app[bot]
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