Skip to content

feat(minibf): add the constitutional committee vote endpoints - #1369

Draft
michalrus wants to merge 4 commits into
feat/minibf-drep-votesfrom
feat/minibf-committee-votes
Draft

michalrus wants to merge 4 commits into
feat/minibf-drep-votesfrom
feat/minibf-committee-votes

Conversation

@michalrus

@michalrus michalrus commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

Resolves #1108.

Resolves #1110.

Merge this after:

Implementation

  • Add /governance/committee/votes and /governance/committee/{cc_id}/votes. Both routes scan the committee vote histories in ProposalState. The scan keeps a bounded frontier of complete slot groups. It reads each block from the archive for the transaction hash, the vote anchor, the block height, and the intra-block order.

  • /governance/committee/{cc_id}/votes accepts a hot or cold CIP-129 credential. On-chain votes store the hot credential only. For a cold credential, Dolos finds the authorized hot credentials in committee_auths and committee_auth_archive.

  • Keep removed committee authorizations for API queries:

    • The Conway ledger EPOCH rule removes authorization histories of cold credentials that leave the committee.
    • Add GovState::committee_auth_archive at CBOR field index 8. The archive stores the authorization histories that the garbage collection removes. It is not ledger-effective state.
    • In CommitteeGc::apply, append each removed history to committee_auth_archive. CommitteeGc::undo truncates the appended entries.
    • Add GovState::committee_hot_credentials. It yields all hot credentials from active authorizations and the archive.
    • Read the archive in /governance/committee/{cc_id}/votes.
  • ⚠️ [resync / replay] Stores synchronized with earlier versions ran the old garbage collection. That garbage collection erased past authorization histories. As a result, an existing store has an empty archive. To recover the erased histories and query past members by cold credential, you must replay or resync the store.

Testing

The synthetic test chain now carries a vote anchor.

Raise the gov snapshot schema revision from 1 to 2 and pin a revision-2 golden. The revision-1 golden stays for decode coverage.

The blockfrost-tests suite tests these endpoints in these files:

Add `/governance/committee/votes` and
`/governance/committee/{cc_id}/votes`. Both routes share one scan of the
committee vote histories in the proposal namespace. The scan keeps a
bounded frontier of complete slot groups. It then reads each block from
the archive for the transaction hash, the anchor, the block height, and
the order inside the block.

`/governance/committee/{cc_id}/votes` accepts a CIP-129 hot or cold
credential. Votes carry the hot credential only. A cold credential
therefore resolves through the authorization history in
`committee_auths`.

Do not add the `CommitteeGc` delta at the epoch boundary. This delta
erased the authorization history of every past committee member. A cold
credential of a past member therefore resolved to nothing.

Ratification does not change. `active_committee_size` and
`committee_tally` iterate the current members and read the map by key.
The retained entries stay unread. The map grows by one entry for each
past committee member.

The synthetic test chain now carries a vote anchor.

The `blockfrost-tests` suite tests this endpoint in these files:
- https://github.com/blockfrost/blockfrost-tests/blob/0f3f3ea268983a6cf2e6f7fd24c5082fc3a91e0e/src/fixtures/preprod/governance/committee/votes.ts
- https://github.com/blockfrost/blockfrost-tests/blob/0f3f3ea268983a6cf2e6f7fd24c5082fc3a91e0e/src/fixtures/preview/governance/committee/votes.ts
- https://github.com/blockfrost/blockfrost-tests/blob/0f3f3ea268983a6cf2e6f7fd24c5082fc3a91e0e/src/fixtures/preprod/governance/committee/cc-id-votes.ts
- https://github.com/blockfrost/blockfrost-tests/blob/0f3f3ea268983a6cf2e6f7fd24c5082fc3a91e0e/src/fixtures/preview/governance/committee/cc-id-votes.ts
@michalrus michalrus self-assigned this Sep 19, 2026
@michalrus michalrus added enhancement New feature or request area:cardano Cardano ledger / epoch / pots logic area:minibf Mini Blockfrost (minibf) API labels Sep 19, 2026
@michalrus
michalrus requested a balanced review from Copilot September 19, 2026 16:15
@coderabbitai

coderabbitai Bot commented Sep 19, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 1c34502f-490e-4c1c-bfa1-4fbcf31d574c

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

Removing authorization GC can incorrectly reactivate a stale hot credential when a former cold credential rejoins the committee.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 1 High severity

Open (1)
What changed in this PR

Adds Blockfrost-compatible constitutional committee vote endpoints and preserves authorization history for cold-credential lookups.

Changes:

  • Adds global and member-specific committee vote routes with pagination.
  • Resolves archived transaction, anchor, ordering, and block metadata.
  • Extends synthetic fixtures and tests with vote anchors and authorization history.
File Description
docs/​content/​apis/​minibf.mdx Documents the new endpoints.
crates/​testing/​src/​synthetic.rs Adds vote-anchor fixture support.
crates/​minibf/​src/​routes/​governance/​mod.rs Implements handlers, lookup logic, pagination, and tests.
crates/​minibf/​src/​lib.rs Registers the routes.
crates/​minibf/​src/​error.rs Adds invalid committee-ID handling.
crates/​cardano/​src/​ewrap/​loading.rs Stops committee authorization garbage collection.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread crates/cardano/src/ewrap/loading.rs

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🔵 Needs a closer look

Retained authorizations can incorrectly reactivate an old hot credential when a former cold credential rejoins the committee.

Review effort: Balanced
Findings: 1 High severity

Open (1)

The EPOCH rule removes the authorization history of a cold credential
that leaves the committee. The Blockfrost committee vote endpoints must
still find the former hot credentials of that cold credential.

- Add `GovState::committee_auth_archive` at CBOR field index 8. The
  archive holds only the histories that the GC removed. It is not
  ledger-effective state.
- Move each removed history to the archive in `CommitteeGc::apply`.
  The undo truncates the exact tail that its apply appended.
- Add `GovState::committee_hot_credentials`, which reads the archived
  generations and the live generation.
- Read the archive in the cold-credential vote endpoint.
- Raise the `gov` snapshot schema revision to 2 and pin a revision-2
  golden. The revision-1 golden stays for decode coverage.

An existing store has an empty archive. A replay or a resync is
necessary to recover the histories that an earlier GC deleted.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

The bounded-frontier pagination and scan-budget branches need focused coverage before approval.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 1 Low severity

Open (1)
Resolved since last review (1)

Comment thread crates/minibf/src/routes/governance/mod.rs
The endpoint tests used five rows and the default budget of 3000 items.
Thus, the tests did not operate the truncation path, the frontier
retry, or the scan budget.

- Add a fixture that puts four committee votes in four slots.
- Test that the scan budget rejects a history that is too long.
- Test that the row counter stops a scan when one slot holds more
  votes than the budget of groups.
- Test that the retry increases a truncated frontier and returns the
  full page in ascending and descending order.
- Test the same retry when a prune removes the blocks of a group.
- Test that a page at the group budget is short only when the rows do
  not exist.
- Add `TestApp::new_with_scan_limit_and_setup`, which applies a scan
  limit and a setup step.

The guard at the group budget cannot execute, because the row counter
stops the scan first. A comment records this. The guard stays, because
it makes sure that the retry loop ends.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

Rolling back legacy committee-GC WAL entries can panic, and the snapshot profile documentation is stale.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 1 High severity

Open (1)
Resolved since last review (1)
Previously missed (1)

In code that hasn't changed since last review

Low severity Update profile docs for gov revision 2

crates/​snapshot/​src/​namespaces.rs:77

Bumping gov to revision 2 makes the profile documentation inaccurate: crates/snapshot/PROFILE.md:152 still states that thirteen of fourteen live revisions are 1 and that epochs is the only/first bump. Update that compatibility-contract description to say that both epochs and gov are at revision 2.

Comment thread crates/cardano/src/model/gov.rs
A review asked whether a `CommitteeGc` delta from an older version can
reach `undo` and fail the archive assertion. It cannot, but the reason
was not in the code.

- Record why the assertion in `CommitteeGc::undo` is safe. The boundary
  work unit keeps the default, empty `commit_wal`, and only
  `RollWorkUnit` writes a `LogValue`. Thus, a rollback reads block
  deltas only.
- Record why the `gov` schema revision is 2. `GovState` carries
  `committee_auth_archive`. The text beside `SCHEMA_REVS` gave this
  reason for `epochs` only.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🟢 Approval recommended

The endpoint behavior, historical authorization model, rollback invariants, bounded scans, schema migration, and edge cases are comprehensively implemented and tested.

Review effort: Balanced
Findings: None

Resolved since last review (1)

@michalrus
michalrus added this pull request to stack #1370 September 22, 2026 13:42
@michalrus
michalrus removed this pull request from stack #1370 September 22, 2026 13:43
@michalrus
michalrus marked this pull request as ready for review September 22, 2026 13:45
@michalrus
michalrus requested a review from a team as a code owner September 22, 2026 13:45

@slowbackspace slowbackspace left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed against blockfrost-backend-ryo (committee_votes.sql, committee_cc_id_votes.sql, validateCommitteeCredentialId) and the pinned blockfrost-tests fixtures. On f66b3b6e in an isolated worktree: clippy is clean for the files this PR touches, the 83 minibf governance tests, the 15 cardano committee tests and every snapshot suite pass.

What checks out: the listing order matches ryo's voting_procedure.id (tx order, then voter map order). Pallas orders ConstitutionalCommitteeScript before ConstitutionalCommitteeKey, which is the ledger's Credential order that db-sync inserts in, so the several-members-in-one-tx case matches. The cold-to-hot union over every recorded authorization matches ryo's committee_registration join. The 400 text is identical, unknown ids return 200 [], and the governance_type names match ryo's snake_case mapping. The gov revision bump follows the schema-rev process (rev 1 golden kept, rev 2 pinned). Uppercase bech32 ids are fine: CheckedHrpstring::hrp() is guaranteed lowercase.

Legend: 🔴 high = fix before merge (correctness, perf) · 🟡 medium = design or parity risk · 🟢 low = cleanup, tests, nits

}

for (slot, vote) in history {
if scanned == budget {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Severity: 🔴 high

For /governance/committee/votes this counter charges every committee vote in chain history against max_scan_items, whatever the page. Once the chain holds more than 3000 committee votes (the default), the endpoint returns 400 for ?count=1&page=1 and for every other request, permanently. Mainnet has 7 members voting on ~160 actions so far; preview and preprod test committees vote far more often. The only remedy is to raise max_scan_items, which also loosens the page cap on every other endpoint.

The counter does not protect anything the walk does not already pay. read_page for /governance/proposals decodes the same namespace without a budget. The frontier bounds memory to group_limit groups, and archive reads are bounded by group_limit <= to() <= max_scan_items. Rows inside one slot are bounded by the block size.

Suggest dropping the row budget and the two tests that pin it (rejects_history_over_scan_budget, counts_rows_inside_one_slot), and keeping the group_limit == budget guard as the loop terminator. Same point as #1368 (comment), but here the trigger is certain.


for mut group in groups {
settle_committee_casts(domain, group[0].slot, &mut group)?;
group.retain(|row| row.cast.is_some());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Severity: 🟡 medium

Because unresolved rows are dropped before the page offset is counted, this loop settles every group from the frontier edge up to the page. The frontier holds to() groups, so ?count=100&page=30 decodes up to 3000 blocks per request on a full-archive node. If rows counted toward the offset before settling, only the groups that overlap [from, to) would need a block read (at most count + 1), and pruned nodes would return short pages instead of shifted ones.

Same trade-off as #1368 (comment); whichever way #1368 lands, the two endpoints should agree, and the choice deserves a sentence in the PR description.

/// This function parses a CIP-129 constitutional-committee credential. The
/// header byte gives the role and the credential type. If the header role and
/// the Bech32 prefix do not agree, the credential is not valid.
fn parse_committee_id(id: &str) -> Result<(CommitteeCredentialRole, StakeCredential), Error> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Severity: 🟡 medium

This is the inverse of bech32_committee_hot / bech32_committee_cold in crates/minibf/src/mapping.rs, but no test round-trips the pair. The route tests parse only a hot script id and a cold key id. The hot key URL in bad_request fails on page=0 before the parser runs, and no test parses a cold script id. A regression in the 0x2 branch for cc_hot or the 0x3 branch for cc_cold would pass the suite.

Please add a unit test that encodes all four (role, kind) combinations with the encoders and asserts the parse returns the same role and credential, plus a 28-byte and a 30-byte payload and a low nibble of 0x0/0x1 as rejections. Moving the parser next to bech32_committee would also let both sides share the HRP constants and the nibble values instead of repeating the literals here.

let app = TestApp::new_with_scan_limit(committee_votes_across_slots_config(), 4);
let blocks = app.vectors().blocks.clone();

// The first frontier holds two groups, because `count` is 2. The

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Severity: 🟢 low

This test never truncates. group_limit = to().min(budget).max(1), and to() is count * page = 4 = budget, so the first frontier holds all four vote slots and the loop exits on the first pass. The comment says the first frontier holds count groups, which is not what the code does.

With a full archive the growth path is unreachable: to() groups always yield at least to() rows, so the page is full and the loop breaks. Growth only happens through pruning, which grows_past_a_pruned_group and returns_a_short_page_at_the_group_budget already cover. Either fix the comment and keep this as a plain paging check, or drop it.

/// This function finds the transaction, the anchor, and the block height for
/// each vote in one block. It then sorts the rows by transaction position and
/// ballot index.
fn settle_committee_casts<D: Domain>(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Severity: 🟢 low

settle_committee_casts and resolve_committee_vote_page repeat the block-matching and page-skipping logic of settle_casts and page_resolved_votes from the DRep path: decode the block, build a casts map with a VecDeque per target, pop_front in history order, sort by (tx position, index), drop rows without a cast, reverse for desc, skip from. The committee version is a superset (voter set, anchor, block height). Once #1368 settles, generalizing the DRep helpers over a voter filter and a row type would leave one copy to fix.

}
scanned += 1;

frontier.push(CommitteeVoteRow {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Severity: 🟢 low

Every row is built here, with a clone of voter and vote, before push decides whether the frontier accepts it. On the aggregate endpoint most rows fall outside the frontier. A would_accept(slot) check on the frontier before building the row avoids the work. Separately, each retry of the loop in committee_vote_page rescans the whole namespace and re-decodes the blocks that the previous pass already settled; carrying settled groups across retries would make the pruned-node path cheaper.

@michalrus
michalrus marked this pull request as draft September 22, 2026 16:29

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:cardano Cardano ledger / epoch / pots logic area:minibf Mini Blockfrost (minibf) API enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

minibf: add /governance/committee/votes minibf: add /governance/committee/{cc_id}/votes

3 participants