Conversation
Delegating, voting and publishing a proposal are settled on-chain by the wallet's own transaction signature, so they only ever needed a connected account. They were routed through the sign-in modal instead, forcing a SIWE message on anyone who just wanted to act on-chain. Connecting and signing in are now separate: - useWalletPrompt always opens the wallet picker, never the sign-in modal - LoginProvider no longer force-disconnects a wallet that has no session, and no longer re-opens the sign-in modal when the account changes - the header connect buttons (Anticapture and whitelabel) open the wallet picker directly - the sign-in modal, still raised by the surfaces that need a server session (proposal drafts, API keys), labels its wallet option "Sign in with wallet" Wallet-born sessions stay bound to their wallet: disconnecting or switching accounts still signs them out, it just never prompts again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each Snapshot vote type now renders its own designed ballot instead of the generic option list (DEV-1131, Figma frames 01-04 and 07): - shared BallotSection shell owns the "Your vote" label row, so ballots can place a counter or chip beside it, and adds a filter input plus a fixed-height scroll area once a list passes 8 options - approval shows an "N of M selected" counter - weighted/quadratic share an allocation ballot: per-option stepper, chart-token dots, stacked allocation bar, amber "N% Remaining" chip, running total and "Must equal 100% to vote"; the stepper cannot exceed 100% - ranked adds a drag grip and drag-to-reorder, keeping the chevrons as the keyboard path, plus the reorder helper line - comment field now uses the design-system Textarea and FormLabel Allocation colors come from the existing --base-chart-* tokens, no raw hex. Storybook stories cover each ballot against its Figma frame. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…mistic states Adds the standalone CURRENT RESULTS card that off-chain proposals never had (DEV-1131, Figma frames 06, 10 and 11). OffchainVotesContent stays a per-voter table; this card is the tally. - ranked rows with rank number, voting power and share; leading option gets the brand-orange bar and orange percentage, no LEADING chip and no quorum row, matching the final frame - shutter aware: encrypted while voting is open, reveal-pending once it closes with the tally still empty, then the normal card once the reveal lands. Both concealed states render dashes over empty tracks and never zeros, drop the rank numbers, and keep the authored choice order so nothing leaks the winner - optimistic votes: optimisticScores apply on top of the indexed tally so a vote shows the moment the signature returns, and useOffchainVoteIndexing drives the header chip through "Indexing your vote..." to "Indexed" and out after 1.2s, falling back to the indexer-catching-up chip past two minutes Storybook stories cover every state against its Figma frame. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Off-chain proposals derived their status through the on-chain enum, which is how a Snapshot vote could render as "Executed" (DEV-1131, Figma frame 12). getOffchainProposalStatus mirrors snapshot.box's getProposalState and implements the frame's derivation table row for row: - not started -> pending, voting open -> active - basic (For/Against/Abstain) resolves to passed/rejected and is quorum-aware: below quorum rejects even when For leads, and a rejection-type quorum inverts the test so reaching it rejects the proposal - ties reject, since passing needs strictly more For than Against - every other vote type closes and surfaces "winner: X . NN%" - no input can produce an executed state OffchainProposalBadge renders the five badges, with Active as a white pill on black text so it never reads as the green passed badge. The quorum branches are inert until the API exposes quorum and quorumType on OffchainProposal, which it does not today; the logic and its tests are in place for when it does. Label vocabulary is still OQ 2 and lives in one constant map. 16 unit tests, one per derivation row. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Casting an off-chain vote showed no indication of what the vote would do, while on-chain voting shows percentages and impact (DEV-1131, Figma frame 09). SingleChoiceOptions now takes an optional liveImpact, and when given renders a per-row bar, voting power and share, with a green up-arrow delta on the selected row. The projection grows the denominator too, so picking an option raises its share and dips every other row's, and the shares always sum to 100%. Opt-in by design: only single choice has a designed frame for this, so ballots 01-04 keep shipping without per-row results, and the approval preview waits on its design. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nothing told a connected wallet it had already voted on an off-chain proposal, and the ballot reset to null on open with no prop carrying the prior choice (DEV-1131, Figma frames 05 and 13). - useMyOffchainVote reads the wallet's own vote through the votes endpoint's voterAddresses filter, so the prior choice is finally available - OffchainVotedModal is the read-only "Your Vote" state: green "You voted" banner with date and voting power, per-option bars with the chosen shares, unchosen options dimmed at 0%, the static comment, and Close / Change vote - OffchainVotedChip is the green "You voted X" chip with a hover tooltip that opens the read-only modal; long labels collapse through the existing presentOffchainVoteLabel, and rows without a vote get no placeholder deriveOffchainVoteWeights only returns shares for basic and single choice. Approval and ranked are not share-based, and weighted/quadratic weights are not recoverable because the API models `choice` as bare choice indices and drops the per-option weights Snapshot stores. Rather than invent numbers, the modal takes weights explicitly and falls back to listing the chosen options. Surfacing those weights needs an API change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The status bug in the ticket had a single source: getOffchainProposalStatus returned ProposalStatus.EXECUTED whenever a basic Snapshot ballot had more For than Against, so a proposal that was never executed anywhere read "Executed" (DEV-1131, Figma frame 12). That util now delegates to the quorum-aware derivation and maps onto the view enum, which gains PASSED and REJECTED members with their own labels and colors. Both call sites are updated, so the fix lands on the proposals list and the proposal detail page, not just on new components. Status is derived once in ProposalSection and shared, so the badge, its winner copy, and the results card cannot disagree about the outcome. Also drops useMyOffchainVote: ProposalSection already queries the wallet's own vote through the same voterAddresses filter, and shipping a second hook for it would have meant two queries for one answer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces the ad-hoc off-chain "Current Results" block in ProposalInfoSection with OffchainResultsCard (DEV-1131, Figma frames 06, 10 and 11). This is what makes the shutter fix reach users: ProposalSection now resolves proposal privacy and passes isShutter through, so an encrypted or reveal-pending election renders dashes over empty tracks instead of the 0/0.0% it used to show. The old block's quorum row is not a loss: the off-chain adapter hardcodes quorum to "0", so that row could never render, and the final card drops it by design (OQ 6). VotingProgressBar stays. Drops the now-dead ChoiceIcon and its color helper import along with the replaced markup. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A wallet that already voted reopened the interactive ballot with its prior choice thrown away, because the modal reset to null on open (DEV-1131, frame 05). It now lands on the read-only "Your Vote" state and opts into editing through "Change vote", which swaps in the ballot. Closing either modal resets that choice, so the next open starts from the read-only state again. Shares come from deriveOffchainVoteWeights, which only answers for basic and single choice. Approval and ranked list their chosen options instead, and weighted stays unresolved until the API exposes the per-option weights. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The list rendered off-chain status as plain colored text through the on-chain helpers, so it could not show the white Active pill or the winner on a closed vote (DEV-1131, Figma frame 12). getOffchainProposalStatusView now also returns the off-chain status itself, and the list renders OffchainProposalBadge with it plus the winner. The on-chain path keeps using getStatusText and the color helpers, untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Completes the has-voted indicator (DEV-1131, Figma frame 13). The header and the mobile bottom bar showed a "You voted" label stacked over a plain choice chip; both now render OffchainVotedChip, one green pill carrying the choice, with a tooltip quoting voting power and date and a click into the read-only modal. The chip needs more than the label, so ProposalHeader takes a single grouped OffchainVoteIndicator prop rather than threading three separate ones through two component levels. It is only built once the indexed vote exists, since the tooltip quotes its values, and both call sites fall back to the previous label rendering until then. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closed Shutter elections showed 0 / 0.0% forever, e.g. "6.47 ENS Security Council" (DEV-1131). Root cause: both sync passes are forward-only on `created`. A Shutter proposal is ingested while its votes are still encrypted and its tally is zero, and the reveal only happens after voting closes, by which point the cursors have moved past it. Nothing ever re-read it. Adds a reveal reconciliation pass to each cycle: closed proposals inside the existing 14-day window whose tally is still all zeros are re-fetched by id along with their votes, then upserted without advancing either cursor. The upserts already overwrite choice, vp and scores, so the revealed values replace the encrypted ones with no schema change. A zero-tally proposal that genuinely had no votes is re-read cheaply and stays zero, so no extra state is needed to distinguish the two cases. Provider gains fetchProposalsByIds and fetchVotesByProposalIds, the latter paginated since one proposal can exceed a page of votes. Proposal and vote GraphQL field lists are extracted so the by-id queries cannot drift from the sync ones. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Weighted and quadratic ballots are stored as {choiceIndex: weight}, but the
response mapper ran Object.keys over that, keeping the picked indices and
throwing the weights away. The data was in the database the whole time; nothing
could read a voter's split back to them (DEV-1131).
Off-chain votes now also carry `weights`, null for the vote types that have no
weights. `choice` is unchanged, so existing consumers are unaffected. The
repository selects the same column twice under both aliases and each schema
interprets it its own way, so the two can never disagree.
This unblocks the weighted case of the read-only voted modal, which currently
falls back to listing chosen options because the weights were unavailable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…anges Switching accounts on a SIWE session fired signOut() and left the old session usable while the request was in flight, and indefinitely if it failed: the header showed wallet B while drafts and API keys still acted for wallet A. Sign-out is now the cleanup, not the gate. isWalletSessionStale derives the broken binding synchronously from wallet state, and useAuthSession serves the session through it, so a mismatched or disconnected wallet reads as no session from the render it happens. The API-key create and delete modals live outside the auth branch, so they close on the account change instead of submitting against the previous account. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four fixes from testing the real app (DEV-1131): - modal bodies were padded per section, so stacked sections produced 32px gaps where the frames specify 16px. One padded body with gap-4 now wraps unpadded sections, in both the ballot and the read-only voted modal - the results card had no height cap, so a 15-candidate election pushed the sidebar off screen. It caps at ~7 rows and scrolls, with the padding outside the scroll container so the designed 6-row card still fits without a scrollbar - ballot overflow used the native scrollbar while the results card used the design system's; both now use scrollbar-custom - ranked ballots no longer offer the filter: a ranking is an ordering, and hiding rows hides positions the voter still has to place Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Padding sits inside the scroll container so the scrollbar tracks the card border rather than floating inset, with the cap raised to 300px to absorb it. The designed 6-row card still does not scroll; a 15-candidate election does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both features were built, verified in Storybook and then never connected. They were exposed as optional props with silent defaults, so nothing failed: the preview never rendered and the indexing chip resolved to "idle", which returns null. Two acceptance criteria looked delivered and were not (DEV-1131, frames 09 and 10). - OffchainVotingModal now passes liveImpact to the single-choice ballot, built from the proposal's tally plus the voter's power. Suppressed on shutter proposals, where a preview would leak the concealed tally - the modal reports the voter's power spread across the choices they picked; weighted and quadratic split it by allocation, other types apply it in full to each pick, matching how Snapshot tallies them - ProposalSection records the signature time, runs useOffchainVoteIndexing and feeds the tally delta and chip state through to the results card - the delta is dropped as soon as the API returns the vote for the wallet, so it is never counted twice on top of the now-indexed tally Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- serve before initial metrics refresh so slow Authful can't keep /health down - publish DB counts independently of Authful in metrics refresh - derive daily-active from usage records, not lastUsedAt (survives Gateful cache hits) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- export user_api_keys_created_total as a real counter incremented on creation instead of a mutable row count (cascade delete on account removal read as a counter reset / false increase() spike) - guard MetricsSnapshotService.refresh against overlapping runs so a slow Authful can't clobber a newer snapshot or pile up fetches Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
It never emitted a single railway_service_* sample in either environment — its account-level RAILWAY_API_KEY was rejected for the whole retained history (~15d), so the only visible effect was a permanently firing RailwayExporterStale critical alert and three blank Grafana panels. Drops the scrape job, the three Railway alert rules, the three dashboard panels, and the Dockerfile/railway.toml pair. Also removes the need for a workspace-wide Railway API token in service env vars, and a build-time git clone of a third-party repo. Railway's own dashboard still shows per-service CPU/RAM/egress; only alerting on them is gone. Co-Authored-By: Claude <noreply@anthropic.com>
isIndexed was "the API returned a vote for this wallet", which is already true when a voter changes an existing vote: the chip flipped to Indexed immediately, the optimistic delta was dropped and the results fell back to the old tally while the label claimed the new choice. Snapshot votes carry no id, so compare a created+choice fingerprint against the vote the submission replaced, and poll the query until that replacement arrives instead of relying on the single refetch fired at signature time. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…lots Snapshot recomputes ranked/Copeland results from elimination or pairwise rounds and aggregates quadratic votes as renormalized square roots, so adding the full voting power to every ranked pick or a linear split to quadratic scores published wrong totals — and sometimes a wrong winner — until indexing caught up. Those types now wait for the indexer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The voted chip opens the read-only modal whatever the proposal status, and that modal always offered "Change vote", swapping in a ballot whose confirm gating never checked the voting window — so users were invited to compose and sign a vote Snapshot can only reject. The modal now takes whether voting is still open, and the ballot refuses to submit outside the window. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ff22247263
ℹ️ 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".
The snapshot service now asks Authful for activity since UTC midnight, so a dashboard rendered in America/Sao_Paulo and captioned GMT-3 claimed a calendar day the data doesn't describe between 00:00 and 02:59 UTC. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The indexed scores already contain this wallet's previous ballot, while the delta only describes the new one, so adding it counted the voter's power twice — keeping the old choice and inflating the new one, potentially naming the wrong leader — until indexing completed. Revotes now wait for the indexer, which the indexing chip already communicates. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ea015053ce
ℹ️ 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".
| scores: offchainScores, | ||
| scoresTotal: rawOffchainProposal.scoresTotal, | ||
| choices: offchainChoices, | ||
| quorum: rawOffchainProposal.quorum, |
There was a problem hiding this comment.
Thread rejection quorum type through status derivation
For Snapshot spaces configured with voting.quorumType = "rejection", this call supplies only the numeric quorum, so getOffchainProposalStatus always takes the participation-quorum branch and can label a proposal rejected merely because total turnout is below the threshold, even though rejection quorum is measured against Against votes. A repo-wide search shows quorumType is not indexed or exposed by the API at all; carry the space setting through the REST/client contract and pass it at both status call sites.
AGENTS.md reference: AGENTS.md:L22-L31
Useful? React with 👍 / 👎.
| const projectedTotal = | ||
| liveImpact && value !== null | ||
| ? currentTotal + liveImpact.votingPower | ||
| : currentTotal; |
There was a problem hiding this comment.
Replace the prior vote in the live-impact preview
When an already-indexed single-choice voter opens Change Vote, liveImpact.scores already includes that wallet's previous voting power, but the preview increases both the denominator and the newly selected score without subtracting the old contribution. The fresh remaining path is this newly added live-impact preview: the later optimistic-results handling does not affect it, so a revote from A to B can still display totals such as A=60, B=50 out of 110 instead of A=50, B=50 out of 100. Pass the prior choice into the preview and model a replacement, or disable the projection for revotes.
Useful? React with 👍 / 👎.
The preview added the voter's power to the selected choice and to the denominator without removing the ballot already counted in the indexed scores, so a revote from A to B projected A=60, B=50 of 110 instead of A=50, B=50 of 100. The projection now starts from the tally with this wallet's previous ballot taken back out. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: bb165ca349
ℹ️ 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".
| await this.repository.upsertProposals(proposals); | ||
| await this.repository.upsertVotes(votes); |
There was a problem hiding this comment.
Upsert revealed proposals and votes atomically
If upsertProposals succeeds but upsertVotes fails—for example because of a transient database error or an oversized vote batch—the proposal now has a nonzero tally and getRevealPendingProposalIds will exclude it from every subsequent reconciliation. Its encrypted vote choices therefore remain stale permanently even though the log says the pass will retry; write both sets in one transaction, or persist votes before changing the predicate that selects the proposal.
Useful? React with 👍 / 👎.
| </p> | ||
| {!isComplete && ( | ||
| <p className="text-secondary font-inter text-[12px] font-normal not-italic leading-[18px]"> | ||
| Must equal 100% to vote |
There was a problem hiding this comment.
Require full allocation before enabling quadratic votes
For a quadratic ballot, entering any positive allocation such as 5% enables the Vote button because OffchainVotingModal checks only total > 0, while this new shared control still displays “95% Remaining” and explicitly says the total must equal 100%. The user can therefore submit a ballot the UI identifies as incomplete; make the quadratic confirmation gate match the 100% requirement, or give quadratic ballots controls and copy that reflect their actual completion rule.
Useful? React with 👍 / 👎.
Persisting the revealed scores is what removes a proposal from getRevealPendingProposalIds, so a failing vote upsert after a successful proposal upsert dropped the proposal out of every later reconciliation and left its encrypted choices stale permanently. Votes now land first, so any failure leaves the zero tally in place and the next pass retries. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… for Quadratic reuses the weighted allocation control, which clamps at 100 and states the total must equal 100%, but confirmation only required a positive total — so a 5% ballot was submittable while the UI still said 95% remaining. Snapshot normalizes both types' weights, so requiring 100 loses nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a777566e46
ℹ️ 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".
| ); | ||
| }, [choices, scores, optimisticScores]); | ||
|
|
||
| const total = effectiveScores.reduce((sum, score) => sum + score, 0); |
There was a problem hiding this comment.
Pass turnout into the detail results card
For approval proposals, this separate detail-card path still divides each score by the sum of all choice scores instead of the newly exposed scoresTotal. Because one voter's full power can appear on several approved choices, a proposal where everyone approves two options renders each option as 50% rather than 100%; pass scoresTotal through ProposalInfoSection and use it as the denominator for these ballots.
Useful? React with 👍 / 👎.
| const hasClosed = Date.now() >= end * 1000; | ||
| if (!hasClosed) return "encrypted"; | ||
| // Once the reveal lands the tally is non-zero and the card renders normally. | ||
| return total > 0 ? "none" : "reveal-pending"; |
There was a problem hiding this comment.
Stop treating every zero tally as a pending reveal
When a closed Shutter proposal legitimately received no voting power, its final tally remains zero even after decryption and indexing have completed. This condition therefore keeps the card concealed behind “decrypting votes, results in a few minutes” forever; determine reveal completion from an explicit finality/reveal signal rather than requiring a positive tally.
Useful? React with 👍 / 👎.
Note
High Risk
Wallet/session decoupling and immediate stale-session gating change core auth UX and security boundaries; per-user Prometheus labels carry PII and require protected metrics and Grafana access.
Overview
This PR bundles several product and platform changes across the dashboard, offchain indexer, authful, user-api, and monitoring.
Wallet vs session: Connecting a wallet no longer implies signing in. Vote, delegate, and publish flows open RainbowKit directly without SIWE; header Connect opens the wallet picker instead of the login modal. Session-gated surfaces (drafts, API keys) still use the sign-in modal.
useAuthSessionandisWalletSessionStalehide wallet-born sessions immediately when the wallet disconnects or the account switches, instead of waiting on sign-out or force-disconnecting connected wallets without a session.Snapshot / offchain governance: The indexer and API persist
scoresTotalandquorum, with a historical metadata backfill job. The dashboard derives Passed, No quorum, and non-stale Ongoing states instead of mapping winning Snapshot proposals to on-chain-style Executed / queue labels.API keys UX: Save-key modal layout, MCP section copy, animated client tabs, truncated names, usage dropdown/BlankSlate, shorter chart, and scrollable chart legend on mobile.
Validation metrics: Authful adds
GET /tokens/active(usage from daily counters). User API exposes bearer-protected/metricswith per-user gauges (email/wallet identifiers), a Grafana User API Validation dashboard, andUSER_API_METRICS_TOKENrequired when Authful provisioning is enabled.Monitoring: Removes the non-functional railway-exporter (scrape job, alerts, panels). Extends Prometheus retention; PR Grafana can use basic auth instead of Google OAuth.
Reviewed by Cursor Bugbot for commit 6b63c61. Configure here.