Skip to content

fix(governance): auto-reconcile pending receipts on the background poll#22

Merged
sidhujag merged 1 commit intomasterfrom
fix/governance-auto-reconcile-pending-receipts
Apr 24, 2026
Merged

fix(governance): auto-reconcile pending receipts on the background poll#22
sidhujag merged 1 commit intomasterfrom
fix/governance-auto-reconcile-pending-receipts

Conversation

@sidhujag
Copy link
Copy Markdown
Member

Problem

After submitting a vote, the per-proposal cohort chip (e.g. Voted 1/10) and the "Last N votes" activity-card badges sat on their pre-confirm state (Pending / Submitted) indefinitely. The user had to navigate into a proposal (which reopened its vote modal and implicitly triggered a reconcile via useOwnedMasternodes) and then come back to the Governance page before any relayed → confirmed transition surfaced.

Root cause: GET /gov/receipts/summary is documented as a pure SELECT — no RPC, no reconciliation — and POST /gov/receipts/reconcile (the only path that flips receipts from relayed to confirmed via gobject_getcurrentvotes) was only fired when the modal opened. The 30s Governance background poll only refetched the pure-read summary, so stale relayed rows never got touched.

Fix

The background poll in useGovernanceReceipts now runs a reconcile sweep before each summary refetch:

  1. Scan the last-known summary (read through a ref so the callback identity stays stable and useBackgroundPoll doesn't reset its cadence on every state change) for proposals with relayed > 0.
  2. Fan out governanceService.reconcileReceipts(hash) in parallel. Promise.allSettled so one flaky proposal can't stall the sweep.
  3. Call load() to surface any transitions the reconciles just wrote.

RPC cost

Bounded by three existing backend layers, no new server-side work needed:

  • Route-level freshness check in /gov/receipts/reconcile short-circuits with reconciled: false (no RPC) when every row for a proposal is already confirmed inside receiptsFreshnessMs.
  • currentVotesCache (2 min TTL) collapses concurrent gobject_getcurrentvotes calls across all logged-in users to one RPC per proposal per TTL window.
  • POST /gov/vote already invalidates that cache for the just-voted proposal, so the first post-relay reconcile hits the chain cleanly instead of returning a stale snapshot.

What stays the same

  • /gov/receipts/summary stays pure — only the client sweep issues reconciles.
  • refresh() (called by Governance.closeVoteModal) stays pure. useOwnedMasternodes already reconciled the just-voted proposal when the modal opened, so closeVoteModal doesn't need to fan out again. Guarded by a dedicated test.
  • GovernanceActivity's independent 30s poll is untouched — it reads the same DB the sweep just updated, so badges flip on its next tick.
  • Feature-detected: services without reconcileReceipts degrade to a plain load (legacy test fixtures keep passing — dedicated test).

Test plan

5 new tests added in useGovernanceReceipts.test.js:

  • A background tick fires reconcile for every proposal with relayed > 0 and re-loads /summary to surface relayed → confirmed transitions.
  • A background tick with no relayed rows skips reconcile entirely and only re-loads /summary.
  • A reconcile failure on one proposal does not stop the remaining reconciles or the follow-up load.
  • enabled=false suppresses the background sweep so anonymous / gated-off sessions never issue reconcile RPC.
  • A service stub without reconcileReceipts still polls /summary cleanly (back-compat).

Plus one guard on the existing surface:

  • Manual refresh() does NOT fire reconcile — reconciliation belongs only to the background cadence so modal-close doesn't double up on the RPC the vote modal already issued.

Verified locally

  • useGovernanceReceipts.test.js — 17/17 pass.
  • Governance.test.js, GovernanceOpsHero.test.js, GovernanceActivity.test.js, ProposalVoteModal.test.js — 105/105 pass combined across all governance-related suites.
  • No lint errors.

After deploy

Submit a vote, leave the Governance page open. Within ~30s the cohort chip on that proposal row should flip from Pending to Voted X/Y, and the activity card should flip its badge from Submitted to On-chain — no page reload, no navigation required.

Made with Cursor

After submitting a vote, the cohort chip and "Last N votes" activity
badge stayed on "Pending / Submitted" indefinitely. `/gov/receipts/summary`
is a pure SELECT, and the only code path that flips receipts from
`relayed` to `confirmed` is `POST /gov/receipts/reconcile`, which was
only fired by `useOwnedMasternodes` when the vote modal opened. Users
had to navigate into a proposal (reopening its modal) to trigger a
reconcile, then come back to the Governance page before the UI caught
up with chain state.

The 30s background poll in `useGovernanceReceipts` now runs a reconcile
sweep before each summary refetch: it scans the last-known summary for
rows with `relayed > 0`, fans out `reconcileReceipts(hash)` in parallel
(allSettled so one flaky proposal can't stall the sweep), and then calls
`load()` to surface any transitions. The callback reads `summary` via
a ref so its identity stays stable and `useBackgroundPoll` doesn't
reset its cadence on every state change.

RPC cost is bounded by two existing backend layers:

  * Route-level freshness check short-circuits reconcile with
    `reconciled: false` (no RPC) when every row for a proposal is
    already confirmed inside `receiptsFreshnessMs`.
  * `currentVotesCache` (2 min TTL) collapses concurrent
    `gobject_getcurrentvotes` calls across users to one RPC per
    proposal per TTL window.
  * `POST /gov/vote` already invalidates that cache for the just-voted
    proposal, so the first reconcile after a relay hits the chain
    cleanly instead of returning a stale snapshot.

Manual `refresh()` stays pure — the modal-open reconcile already covers
the just-voted proposal, so closeVoteModal doesn't need to fan out
again. Feature-detected: services without `reconcileReceipts` degrade
to a plain load so legacy test fixtures keep passing.

Covered by 5 new tests: reconcile fires for relayed>0 rows and surfaces
transitions, sweep is a no-op when nothing is pending, one failed
reconcile doesn't block the rest, enabled=false gates everything,
legacy service shape degrades gracefully. Plus a guard that manual
refresh() stays pure.

Made-with: Cursor
@sidhujag sidhujag merged commit 856ed86 into master Apr 24, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant