test: add BLS voting key rotation tests - #3707
Merged
Merged
Conversation
A pool registers its BLS (Leios voting) key through the stake pool registration certificate, and the ledger stores it with the epoch the registration took effect in. The key is offered to the Leios committee only while `currentEpoch < bksRegisteredIn + maxKeyAge`, so it has to be rotated. Rotating is a re-registration, which the ledger treats as a pool update: it lands in `futurePoolParams`, takes effect on the next epoch boundary, and needs one more boundary before the committee holds it. `TestBlsKeyRotation` covers the ledger side on a pool the test registers itself. Such a pool is seated on the committee even with no stake - the candidate list has no stake or delegator filter - while it stays out of the leader election distribution, which does filter on delegators, so it never forges and costs the cluster nothing. * `test_rotate_bls_key` walks the whole lifecycle: the registration stamp, the pending update, the re-stamped registration epoch, the committee still holding the old key in that epoch, and the new key seated and voting one boundary later. * `test_reregister_same_bls_key` renews a key without replacing it. * `test_drop_and_restore_bls_key` drops the key with a Conway era certificate, which carries none, leaving the pool seated but keyless, and rotates a key back in. `TestBlsKeyExpiration::test_expired_bls_key` runs on a cluster whose KES setup gives BLS keys a lifetime of 7 epochs, as `maxKeyAge` is derived from `maxKESEvolutions` and `slotsPerKESPeriod`. It rotates one pool in time and checks that the others keep their seat with their key reported and no longer voting, while the rotated pool keeps voting. `TestBlsKeyRotationVoting::test_rotation_pair_keeps_voting` covers the operator procedure end to end on a block producing pool. The node votes with whatever `--shelley-bls-key` it was started with, so the test hands it the old and the new key as a JSON array - the rotation pair - and checks that it votes in the epoch that still has the old key seated and in the one that has the new key. It then restarts the node with the old key alone, which has to stop it from voting, and cuts over to the new key alone, which has to bring the votes back. A test that waits out epoch boundaries is worth little if it can wait past the one it means to observe, so the waits whose observation holds in one epoch only pass `future_is_ok=False`. Everything that can rule a test out is checked before the work it would waste: the expiration test reads the epoch length off its startup genesis and skips before a dedicated cluster is spun up, and the rotation pair test settles the epoch length before its instance is marked for respin. Three modules of shared code come out of this: * `tests/bls.py` holds what the BLS tests knew separately - the key specs and envelope checks that were private to `test_bls_keys`, and the readers for the key a pool has registered. * `tests/leios.py` holds the Leios trace message constants and the log search primitives that were private to `test_leios_blocks`: the skip reason, the `MSG_*` constants, the message groups, `VOTING_START_EPOCH`, the search interval constants, the block interval check (now `skip_if_no_ebs`), and `LogSearch` with `get_log_position`, `find_msgs`, `init_searches` and `search_round`. `wait_for_msgs` is new: it searches a single log file until the expected messages show up or a deadline passes. * `tests/kes.py` gains `refresh_opcerts`, which `test_kes` and the expiration test both need to keep pools forging on a cluster with a short KES setup.
The first run of these tests on `leios_fast` failed
`test_rotation_pair_keeps_voting` when it re-registered a cluster pool:
VRFKeyHashAlreadyRegistered (KeyHash 9219ac4c...)
(VRFVerKeyHash bbdc6cb4...)
The pool re-registers with the very VRF key it already uses, so the
rejection is not about the key being taken. The Dijkstra `POOL` rule
wants an occurrence of that key hash recorded in `psVRFKeyHashes`, and
`injectStakePools` records none - the map is filled only by the Conway
PV11 hardfork and by the Conway to Dijkstra translation, and a cluster
that starts in Dijkstra at slot 0 runs neither. A pool that came from
the genesis therefore cannot be updated by a registration certificate
at all, which is why nothing about this showed up on a pool the tests
register themselves - all three of those passed.
Gate the two tests that rotate the key of a cluster pool on
IntersectMBO/cardano-ledger#6102. The gate reacts to the rejection
itself rather than to a genesis heuristic: the heuristic stays true
after the ledger is fixed, and `finish_test()` would then fail a test
that would have passed. Reacting to the error means the tests simply
start running once `psVRFKeyHashes` is populated.
The re-registration is now shared by both tests in
`reregister_cluster_pool`, which holds the gate.
mkoura
requested
a lite review from Copilot
and removed request for
saratomaz
September 24, 2026 10:19
Contributor
There was a problem hiding this comment.
Copilot review overview
🔵 Needs a closer look
The broad, multi-file E2E changes and ledger-gated scenarios warrant final human review.
Review effort: Lite
Findings: None
What changed in this PR
Adds Dijkstra-era E2E coverage for BLS voting-key rotation, renewal, removal, expiration, and operator cutover.
Changes:
- Adds BLS rotation and expiration lifecycle tests.
- Extracts shared BLS, Leios, and KES helpers.
- Adds ledger issue gating and invalid-key coverage.
| File | Description |
|---|---|
cardano_node_tests/tests/test_leios_blocks.py |
Uses extracted Leios helpers |
cardano_node_tests/tests/test_kes.py |
Uses extracted KES helper |
cardano_node_tests/tests/test_bls_rotation.py |
Adds BLS rotation and expiration scenarios |
cardano_node_tests/tests/test_bls_keys.py |
Uses shared BLS helpers and adds validation |
cardano_node_tests/tests/leios.py |
Shared Leios trace and log-search helpers |
cardano_node_tests/tests/kes.py |
Shared operational-certificate refresh helper |
cardano_node_tests/tests/issues.py |
Adds ledger issue #6102 gate |
cardano_node_tests/tests/bls.py |
Shared BLS key utilities |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds E2E tests for rotation of the node BLS (Leios voting) keys in the
Dijkstra era, modelled on the existing KES rotation tests.
Mechanism under test
A pool's BLS key is stamped with the epoch it was registered in, and the
Leios committee honours it while
currentEpoch < bksRegisteredIn + maxKeyAge.maxKeyAgeis derived fromthe KES lifetime, not a protocol parameter:
The Dijkstra
EPOCHrule runsPOOLREAPbeforeSNAP, so a rotationsubmitted in epoch
fis inpool-stateatf+1and on the committeeat
f+2- the same schedule the VRF key follows, which is what CIP-0164aligns voting keys with.
Tests
test_bls_rotation.py:TestBlsKeyRotation::test_rotate_bls_key- full lifecycle: registrationstamp, pending key in
futurePoolParams, re-stampedbksRegisteredInat
f+1with the committee still holding the old key, new key seatedand voting at
f+2, all other pool parameters unchangedTestBlsKeyRotation::test_reregister_same_bls_key- renewal re-stampsthe epoch with the key unchanged
TestBlsKeyRotation::test_drop_and_restore_bls_key- a Conwayregistration certificate drops the key, leaving the pool seated but
keyless, then rotates one back in
TestBlsKeyExpiration::test_expired_bls_key- custom-genesis clusterwith a 5 epoch KES lifetime, so
maxKeyAgeis 7 and a key can be agedout inside a testrun; checks the pool keeps its seat and loses its vote
TestBlsKeyRotationVoting::test_rotation_pair_keeps_voting- theoperator side end to end on a block producing pool: hand the node both
keys, check it votes in the epoch that still holds the old key and in
the one that holds the rotated key, then that the old key alone stops
voting and the new key alone keeps voting
The first three register their own pool, so they need no node of their
own and disturb no cluster pool. The last two rotate the key of a cluster
pool and restart its node.
Shared modules
bls.py- key envelope and bech32 properties, CIP-0164 Appendix Bsizes, and the helpers to read a registered key
leios.py- Leios trace constants and log search primitives, extractedfrom
test_leios_blocks.py(-322 lines there, no behaviour change)kes.py-refresh_opcerts(), extracted from the closure intest_kes.py, now used by both modulestest_bls_keys.py- moved ontobls.py, and gainedtest_bls_vkey_not_accepted_as_signing_keyLedger gate
test_expired_bls_keyandtest_rotation_pair_keeps_votingxfail onIntersectMBO/cardano-ledger#6102: a pool whose parameters come from the
genesis cannot be re-registered at all, because genesis staking injection
records no occurrence of its VRF key hash and the
POOLrule thenrejects the update with
VRFKeyHashAlreadyRegistered- even though theVRF key does not change.
The gate reacts to the rejection rather than to a "pools came from
genesis" heuristic, which would stay true after the fix and turn
finish_test()into a failure on a test that would have passed.Coverage today
The two cluster-pool tests need a variant that starts before Dijkstra and
hard-forks into it, so until #6102 is fixed they xfail on
leios_fast.test_expired_bls_keyneeds short epochs and runs onlocal_fast;test_rotation_pair_keeps_votingneeds endorser blocks and so a blockrate slow enough for a mempool backlog, which today means
leios_fast.The three ledger-only tests run on any Dijkstra variant.