From c9547b8eceb96381600550ceacc0e0338dbf7f1d Mon Sep 17 00:00:00 2001 From: slowbackspace Date: Tue, 1 Sep 2026 16:58:54 +0200 Subject: [PATCH 1/4] feat(governance): add counted flag to proposal votes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../governance/proposals_proposal_votes.sql | 66 ++++++++++++++----- .../unpaged/proposals_proposal_votes.sql | 66 ++++++++++++++----- src/types/queries/governance.ts | 1 + 3 files changed, 103 insertions(+), 30 deletions(-) diff --git a/src/sql/governance/proposals_proposal_votes.sql b/src/sql/governance/proposals_proposal_votes.sql index ac18efc6..e97b3c51 100644 --- a/src/sql/governance/proposals_proposal_votes.sql +++ b/src/sql/governance/proposals_proposal_votes.sql @@ -1,9 +1,33 @@ +WITH proposal_votes AS ( + SELECT vp.id, + vp.tx_id, + vp.index, + vp.voter_role, + vp.drep_voter, + vp.pool_voter, + vp.committee_voter, + vp.vote, + -- The ledger keeps only the latest vote per voter and proposal (upsert semantics) + ROW_NUMBER() OVER ( + PARTITION BY vp.voter_role, + vp.drep_voter, + vp.pool_voter, + vp.committee_voter + ORDER BY vp.tx_id DESC, + vp.id DESC + ) AS recency_rank + FROM gov_action_proposal gap + JOIN tx gap_tx ON (gap_tx.id = gap.tx_id) + JOIN voting_procedure vp ON (vp.gov_action_proposal_id = gap.id) + WHERE encode(gap_tx.hash, 'hex') = $4 + AND gap.index = $5 +) SELECT encode(vp_tx.hash, 'hex') AS "tx_hash", - vp.index AS "cert_index", + p.index AS "cert_index", ( CASE - WHEN vp.voter_role::TEXT = 'ConstitutionalCommittee' THEN 'constitutional_committee' - ELSE LOWER(vp.voter_role::TEXT) + WHEN p.voter_role::TEXT = 'ConstitutionalCommittee' THEN 'constitutional_committee' + ELSE LOWER(p.voter_role::TEXT) END ) AS "voter_role", -- ConstitutionalCommittee, DRep, SPO -> constitutional_committee, drep, spo @@ -12,22 +36,34 @@ SELECT encode(vp_tx.hash, 'hex') AS "tx_hash", ) AS "voter", dh.has_script AS "voter_has_script", ch.has_script AS "cc_voter_has_script", - LOWER(vote::TEXT) AS "vote" -- Yes, No, Abstain -> yes,no,abstain -FROM gov_action_proposal gap - JOIN tx gap_tx ON (gap_tx.id = gap.tx_id) - JOIN voting_procedure vp ON (vp.gov_action_proposal_id = gap.id) - JOIN tx vp_tx ON (vp_tx.id = vp.tx_id) - LEFT JOIN drep_hash dh ON (vp.drep_voter = dh.id) - LEFT JOIN pool_hash ph ON (vp.pool_voter = ph.id) - LEFT JOIN committee_hash ch ON (vp.committee_voter = ch.id) -WHERE encode(gap_tx.hash, 'hex') = $4 - AND gap.index = $5 + LOWER(p.vote::TEXT) AS "vote", + -- Yes, No, Abstain -> yes,no,abstain + ( + p.recency_rank = 1 + AND ( + p.drep_voter IS NULL + -- deregistration drops the DRep's votes from the ledger; + -- re-registering does not restore them + OR NOT EXISTS ( + SELECT 1 + FROM drep_registration dr + WHERE dr.drep_hash_id = p.drep_voter + AND dr.deposit < 0 + AND dr.tx_id > p.tx_id + ) + ) + ) AS "counted" +FROM proposal_votes p + JOIN tx vp_tx ON (vp_tx.id = p.tx_id) + LEFT JOIN drep_hash dh ON (p.drep_voter = dh.id) + LEFT JOIN pool_hash ph ON (p.pool_voter = ph.id) + LEFT JOIN committee_hash ch ON (p.committee_voter = ch.id) ORDER BY CASE - WHEN LOWER($1) = 'desc' THEN vp.id + WHEN LOWER($1) = 'desc' THEN p.id END DESC, CASE WHEN LOWER($1) <> 'desc' - OR $1 IS NULL THEN vp.id + OR $1 IS NULL THEN p.id END ASC LIMIT CASE WHEN $2 >= 1 diff --git a/src/sql/governance/unpaged/proposals_proposal_votes.sql b/src/sql/governance/unpaged/proposals_proposal_votes.sql index 155741c4..401917f2 100644 --- a/src/sql/governance/unpaged/proposals_proposal_votes.sql +++ b/src/sql/governance/unpaged/proposals_proposal_votes.sql @@ -1,9 +1,33 @@ +WITH proposal_votes AS ( + SELECT vp.id, + vp.tx_id, + vp.index, + vp.voter_role, + vp.drep_voter, + vp.pool_voter, + vp.committee_voter, + vp.vote, + -- The ledger keeps only the latest vote per voter and proposal (upsert semantics) + ROW_NUMBER() OVER ( + PARTITION BY vp.voter_role, + vp.drep_voter, + vp.pool_voter, + vp.committee_voter + ORDER BY vp.tx_id DESC, + vp.id DESC + ) AS recency_rank + FROM gov_action_proposal gap + JOIN tx gap_tx ON (gap_tx.id = gap.tx_id) + JOIN voting_procedure vp ON (vp.gov_action_proposal_id = gap.id) + WHERE encode(gap_tx.hash, 'hex') = $2 + AND gap.index = $3 +) SELECT encode(vp_tx.hash, 'hex') AS "tx_hash", - vp.index AS "cert_index", + p.index AS "cert_index", ( CASE - WHEN vp.voter_role::TEXT = 'ConstitutionalCommittee' THEN 'constitutional_committee' - ELSE LOWER(vp.voter_role::TEXT) + WHEN p.voter_role::TEXT = 'ConstitutionalCommittee' THEN 'constitutional_committee' + ELSE LOWER(p.voter_role::TEXT) END ) AS "voter_role", -- ConstitutionalCommittee, DRep, SPO -> constitutional_committee, drep, spo @@ -12,20 +36,32 @@ SELECT encode(vp_tx.hash, 'hex') AS "tx_hash", ) AS "voter", dh.has_script AS "voter_has_script", ch.has_script AS "cc_voter_has_script", - LOWER(vote::TEXT) AS "vote" -- Yes, No, Abstain -> yes,no,abstain -FROM gov_action_proposal gap - JOIN tx gap_tx ON (gap_tx.id = gap.tx_id) - JOIN voting_procedure vp ON (vp.gov_action_proposal_id = gap.id) - JOIN tx vp_tx ON (vp_tx.id = vp.tx_id) - LEFT JOIN drep_hash dh ON (vp.drep_voter = dh.id) - LEFT JOIN pool_hash ph ON (vp.pool_voter = ph.id) - LEFT JOIN committee_hash ch ON (vp.committee_voter = ch.id) -WHERE encode(gap_tx.hash, 'hex') = $2 - AND gap.index = $3 + LOWER(p.vote::TEXT) AS "vote", + -- Yes, No, Abstain -> yes,no,abstain + ( + p.recency_rank = 1 + AND ( + p.drep_voter IS NULL + -- deregistration drops the DRep's votes from the ledger; + -- re-registering does not restore them + OR NOT EXISTS ( + SELECT 1 + FROM drep_registration dr + WHERE dr.drep_hash_id = p.drep_voter + AND dr.deposit < 0 + AND dr.tx_id > p.tx_id + ) + ) + ) AS "counted" +FROM proposal_votes p + JOIN tx vp_tx ON (vp_tx.id = p.tx_id) + LEFT JOIN drep_hash dh ON (p.drep_voter = dh.id) + LEFT JOIN pool_hash ph ON (p.pool_voter = ph.id) + LEFT JOIN committee_hash ch ON (p.committee_voter = ch.id) ORDER BY CASE - WHEN LOWER($1) = 'desc' THEN vp.id + WHEN LOWER($1) = 'desc' THEN p.id END DESC, CASE WHEN LOWER($1) <> 'desc' - OR $1 IS NULL THEN vp.id + OR $1 IS NULL THEN p.id END ASC diff --git a/src/types/queries/governance.ts b/src/types/queries/governance.ts index 13ff8535..2356ae17 100644 --- a/src/types/queries/governance.ts +++ b/src/types/queries/governance.ts @@ -182,6 +182,7 @@ export interface ProposalsProposalVotes { voter_has_script: boolean; cc_voter_has_script: boolean | null; vote: 'yes' | 'no' | 'abstain'; + counted: boolean; } export interface ProposalsProposalMetadata { From 834383983090f8b8fd136e1e2a1ed91cfbcdb208 Mon Sep 17 00:00:00 2001 From: slowbackspace Date: Tue, 1 Sep 2026 17:06:45 +0200 Subject: [PATCH 2/4] fix(governance): freeze counted flag when a proposal leaves the active 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). --- .../governance/proposals_proposal_votes.sql | 19 +++++++++++++++++-- .../unpaged/proposals_proposal_votes.sql | 19 +++++++++++++++++-- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/sql/governance/proposals_proposal_votes.sql b/src/sql/governance/proposals_proposal_votes.sql index e97b3c51..f7a9d25a 100644 --- a/src/sql/governance/proposals_proposal_votes.sql +++ b/src/sql/governance/proposals_proposal_votes.sql @@ -7,6 +7,14 @@ WITH proposal_votes AS ( vp.pool_voter, vp.committee_voter, vp.vote, + -- Epoch in which the proposal left the active set; its vote tally is frozen + -- from that point on + LEAST( + gap.ratified_epoch, + gap.enacted_epoch, + gap.dropped_epoch, + gap.expired_epoch + ) AS closed_epoch, -- The ledger keeps only the latest vote per voter and proposal (upsert semantics) ROW_NUMBER() OVER ( PARTITION BY vp.voter_role, @@ -42,14 +50,21 @@ SELECT encode(vp_tx.hash, 'hex') AS "tx_hash", p.recency_rank = 1 AND ( p.drep_voter IS NULL - -- deregistration drops the DRep's votes from the ledger; - -- re-registering does not restore them + -- deregistration drops the DRep's votes from the ledger and re-registering + -- does not restore them, but only while the proposal is still live: + -- once it closes the tally is frozen and later deregistrations have no effect OR NOT EXISTS ( SELECT 1 FROM drep_registration dr + JOIN tx dr_tx ON (dr_tx.id = dr.tx_id) + JOIN block dr_b ON (dr_b.id = dr_tx.block_id) WHERE dr.drep_hash_id = p.drep_voter AND dr.deposit < 0 AND dr.tx_id > p.tx_id + AND ( + p.closed_epoch IS NULL + OR dr_b.epoch_no < p.closed_epoch + ) ) ) ) AS "counted" diff --git a/src/sql/governance/unpaged/proposals_proposal_votes.sql b/src/sql/governance/unpaged/proposals_proposal_votes.sql index 401917f2..0fbb8bc2 100644 --- a/src/sql/governance/unpaged/proposals_proposal_votes.sql +++ b/src/sql/governance/unpaged/proposals_proposal_votes.sql @@ -7,6 +7,14 @@ WITH proposal_votes AS ( vp.pool_voter, vp.committee_voter, vp.vote, + -- Epoch in which the proposal left the active set; its vote tally is frozen + -- from that point on + LEAST( + gap.ratified_epoch, + gap.enacted_epoch, + gap.dropped_epoch, + gap.expired_epoch + ) AS closed_epoch, -- The ledger keeps only the latest vote per voter and proposal (upsert semantics) ROW_NUMBER() OVER ( PARTITION BY vp.voter_role, @@ -42,14 +50,21 @@ SELECT encode(vp_tx.hash, 'hex') AS "tx_hash", p.recency_rank = 1 AND ( p.drep_voter IS NULL - -- deregistration drops the DRep's votes from the ledger; - -- re-registering does not restore them + -- deregistration drops the DRep's votes from the ledger and re-registering + -- does not restore them, but only while the proposal is still live: + -- once it closes the tally is frozen and later deregistrations have no effect OR NOT EXISTS ( SELECT 1 FROM drep_registration dr + JOIN tx dr_tx ON (dr_tx.id = dr.tx_id) + JOIN block dr_b ON (dr_b.id = dr_tx.block_id) WHERE dr.drep_hash_id = p.drep_voter AND dr.deposit < 0 AND dr.tx_id > p.tx_id + AND ( + p.closed_epoch IS NULL + OR dr_b.epoch_no < p.closed_epoch + ) ) ) ) AS "counted" From b9013082fb220ca37de7ab9df9fe7fd68b037fda Mon Sep 17 00:00:00 2001 From: slowbackspace Date: Wed, 2 Sep 2026 13:44:14 +0200 Subject: [PATCH 3/4] chore: bump @blockfrost/openapi to 0.1.93 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. --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 7e03ec5e..cb4575cf 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "dependencies": { "@blockfrost/blockfrost-js": "6.1.1", "@blockfrost/blockfrost-utils": "3.0.0", - "@blockfrost/openapi": "0.1.91", + "@blockfrost/openapi": "0.1.93", "@fastify/cors": "^11.1.0", "@fastify/http-proxy": "^11.5.0", "@fastify/postgres": "^6.0.2", diff --git a/yarn.lock b/yarn.lock index 046fcb91..82ff4678 100644 --- a/yarn.lock +++ b/yarn.lock @@ -101,15 +101,15 @@ __metadata: languageName: node linkType: hard -"@blockfrost/openapi@npm:0.1.91": - version: 0.1.91 - resolution: "@blockfrost/openapi@npm:0.1.91" +"@blockfrost/openapi@npm:0.1.93": + version: 0.1.93 + resolution: "@blockfrost/openapi@npm:0.1.93" dependencies: ajv: ^8.17.1 cbor: ^9.0.2 rimraf: 6.0.1 yaml: ^2.6.1 - checksum: d33b439dcd11519d4a0578343d7de5909eeb35b86929f1bd8ac9b5e73534f42508cc363a8b68e9fec4ac804849fc8c393e42c64c3015873716cefcebbf4090d5 + checksum: 0273c7f948962fe1739a3dae2c26e06fac81443af799afcc72350441f5b165daf2b4b43ede76e092ee180dc558be676fc95dba44e427fd1f0f59158f92504bb5 languageName: node linkType: hard @@ -1956,7 +1956,7 @@ __metadata: dependencies: "@blockfrost/blockfrost-js": 6.1.1 "@blockfrost/blockfrost-utils": 3.0.0 - "@blockfrost/openapi": 0.1.91 + "@blockfrost/openapi": 0.1.93 "@eslint/compat": ^2.0.0 "@eslint/eslintrc": ^3.3.3 "@eslint/js": ^9.39.1 From 08b111090d7ba08519e581a66ecb4288620e47f5 Mon Sep 17 00:00:00 2001 From: slowbackspace Date: Wed, 2 Sep 2026 13:56:20 +0200 Subject: [PATCH 4/4] chore: bump version to 6.8.0 --- CHANGELOG.md | 11 +++++++++++ package.json | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2971629f..f221c091 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [6.8.0] - 2026-09-02 + +### Added + +- New `counted` field in `/governance/proposals/:tx_hash/:cert_index/votes`, indicating whether the vote counts toward the proposal's tally: only the voter's latest vote counts, and a DRep vote stops counting if the DRep deregisters while the proposal is still live (blockfrost/openapi#466) + +### Changed + +- Constitutional committee voters in `/governance/proposals/:tx_hash/:cert_index/votes` are now CIP-129 `cc_hot1...` ids instead of raw hex (blockfrost/openapi#465) +- Updated `@blockfrost/openapi` to 0.1.93 + ## [6.7.2] - 2026-08-05 ### Added diff --git a/package.json b/package.json index cb4575cf..d0c0fee9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "blockfrost-backend-ryo", - "version": "6.7.2", + "version": "6.8.0", "description": "", "keywords": [], "license": "Apache-2.0",