Skip to content

feat(governance): add counted flag to proposal votes - #352

Merged
slowbackspace merged 4 commits into
fix/proposal-votes-cc-cip129from
feat/proposal-votes-counted
Sep 2, 2026
Merged

feat(governance): add counted flag to proposal votes#352
slowbackspace merged 4 commits into
fix/proposal-votes-cc-cip129from
feat/proposal-votes-counted

Conversation

@slowbackspace

@slowbackspace slowbackspace commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a boolean counted to each entry of /governance/proposals/{tx_hash}/{cert_index}/votes indicating whether the vote is still counted in the ledger's current vote tally (blockfrost/openapi#466). The endpoint returns every vote ever cast, but the ledger keeps only the latest vote per voter and drops a DRep's votes on deregistration, so results could diverge significantly from Ogmios' queryLedgerState/governanceProposals.

Semantics

counted = true iff:

  • the vote is the voter's latest on this proposal (window rank per voter over the proposal's votes, by tx_id), and
  • for DRep voters, no deregistration follows the vote's tx while the proposal was still live — re-registering does not restore previously dropped votes, so the check is "no dereg after the vote", not "currently registered" (Koios gets this subtly wrong); and once a proposal is ratified/enacted/dropped/expired its tally is frozen, so later deregistrations have no effect (bounded by LEAST of the gov_action_proposal epoch columns)

For live proposals this reconstructs the votes map at the current chain tip; for closed proposals it reflects the state when voting closed. The dereg check is anchored on the existing bf_idx_drep_registration_hash_deposit index, evaluated only for returned rows.

Validation (mainnet db-sync)

  • Busiest closed proposal (9b62b3c6...#0, 453 votes): 39 flagged uncounted — 36 superseded re-votes + 3 DReps deregistered while the proposal was live; the epoch bound prevents 65 votes from being wrongly dropped by post-closure deregistrations
  • Live proposal (7d37220b...#0, 109 votes): tip semantics, 2 uncounted
  • No voter retains more than one counted vote on any tested proposal
  • Paged/unpaged outputs, row order, and pagination behavior unchanged (page sweep compared against unpaged)
  • Warm page ~50ms; nonexistent proposal ~30ms

Notes

Fixes blockfrost/openapi#466

The votes endpoint returns every vote-cast transaction ever submitted
for a proposal, but the ledger keeps only the latest vote per voter
(upsert semantics) and drops a DRep's votes on deregistration, so the
response can diverge significantly from the current ledger vote state
(queryLedgerState/governanceProposals in Ogmios).

Add a boolean "counted" to each vote entry: true only for the voter's
latest vote on the proposal, and for DRep voters only when no
deregistration follows the vote — re-registering does not restore
previously dropped votes, so the check is "no dereg after the vote's
tx", not "currently registered". Recency is a window rank per voter
within the proposal's votes; the dereg check is an index-only scan on
bf_idx_drep_registration_hash_deposit.

Validated on mainnet db-sync: on the busiest proposal (453 votes) the
flag marks 99 votes uncounted (36 superseded re-votes + 63 latest votes
of since-deregistered DReps), no voter retains more than one counted
vote, paged/unpaged outputs and ordering are unchanged, and a warm
page returns in ~50ms.

Requires @blockfrost/openapi with the counted field in proposal_votes
(blockfrost/openapi#466); until the dependency is bumped the field is
stripped by response serialization.

Fixes blockfrost/openapi#466
…e set

A DRep deregistration only drops votes from proposals that are still
live; once a proposal is ratified/enacted/dropped/expired its tally is
frozen, so a later deregistration must not retroactively mark the vote
uncounted. Bound the deregistration check by the epoch in which the
proposal left the active set (LEAST of the gov_action_proposal epoch
columns; NULL = still live, tip semantics unchanged).

On mainnet this matters a lot for closed proposals: gap 12 had 65 of
453 votes wrongly flagged uncounted by post-closure deregistrations
(now 39 uncounted: 36 superseded + 3 pre-closure deregistrations).
@slowbackspace

Copy link
Copy Markdown
Contributor Author

Perf comparison vs master on mainnet dev db-sync (9 warm runs each, interleaved, median end-to-end ms from client):

scenario rows master this PR
gap 7, 721 votes, page 1 100 45.2 43.2
gap 61, 687 votes, page 1 100 44.8 44.9
gap 12, 453 votes, page 1 100 45.2 41.2
gap 156, 110 votes (live), page 1 100 42.4 38.1
gap 5, 7 CC votes, page 1 7 38.5 33.3
nonexistent proposal 0 36.1 33.0
gap 7, last page (8) 21 47.1 45.4
gap 7, desc, page 1 100 49.1 45.9
gap 7, unpaged 721 77.3 54.1

Server-side (EXPLAIN ANALYZE, best of 5) on the busiest proposal: 7.6 ms -> 12.1 ms paged, 9.3 ms -> 11.6 ms unpaged, so the real added cost is a few ms, invisible behind connection overhead. The counted subplan is an index-only scan on bf_idx_drep_registration_hash_deposit (~0.02 ms per returned DRep row).

Scaling check: the window function runs over all of a proposal's votes before pagination, so cost scales with proposal size. Running the same shape over the entire voting_procedure table at once (36k votes, ~50x today's biggest proposal) executes in 95 ms, so there is ample headroom.

Brings the counted field and cc_hot voter schema for
/governance/proposals/{tx_hash}/{cert_index}/votes so response
serialization no longer strips the new field.
@slowbackspace

Copy link
Copy Markdown
Contributor Author

Cross-checked the counted logic against Koios (koios-artifacts grest.proposal_votes, which silently filters instead of flagging). Three differences, all in our favor, quantified on mainnet dev db-sync across all 36k votes:

  1. Recency: Koios picks the latest vote per voter by block_time with no tiebreak, so a same-block re-vote is resolved arbitrarily. We order by (tx_id, id), matching chain application order. 5 same-block re-vote groups exist on mainnet today (all happen to have identical vote values, so no visible impact yet).
  2. DRep deregistration: Koios checks "currently registered" (latest drep_registration deposit >= 0). That diverges from the ledger in both directions: 41 votes it would count although the ledger dropped them (DRep deregistered after voting, then re-registered; re-registration does not restore votes), and 2513 votes it would hide although they counted in the final tally (DRep deregistered only after the proposal left the active set, mostly closed proposals). Our vote-follows-dereg check bounded by the proposal's close epoch matches the ledger in both cases.
  3. Voter identity: Koios deduplicates on the bare 28-byte hash (COALESCE across drep/pool/cc), which would collapse two voters of different roles sharing a hash. Zero such collisions among mainnet voters today; we partition by role + voter columns so it cannot happen.

Also confirmed both implementations ignore voting_procedure.invalid (0 rows on mainnet) and neither models CC resignation or SPO retirement (Koios has a TODO for it; our schema description scopes it out). CIP-129 cc_hot encoding already verified byte-for-byte against grest.cip129_hex_to_cc_hot output earlier in #351.

@slowbackspace
slowbackspace merged commit 13c4b2f into fix/proposal-votes-cc-cip129 Sep 2, 2026
1 check passed
slowbackspace added a commit that referenced this pull request Sep 2, 2026
feat(governance): add counted flag to proposal votes (re-land #352)
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