Skip to content

test: add BLS voting key rotation tests - #3707

Merged
mkoura merged 2 commits into
masterfrom
bls_key_rotation_tests
Sep 24, 2026
Merged

mkoura merged 2 commits into
masterfrom
bls_key_rotation_tests

Conversation

@mkoura

@mkoura mkoura commented Sep 24, 2026

Copy link
Copy Markdown
Collaborator

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. maxKeyAge is derived from
the KES lifetime, not a protocol parameter:

ceil(maxKESEvolutions * slotsPerKESPeriod / epochLength) + 2

The Dijkstra EPOCH rule runs POOLREAP before SNAP, so a rotation
submitted in epoch f is in pool-state at f+1 and on the committee
at f+2 - the same schedule the VRF key follows, which is what CIP-0164
aligns voting keys with.

Tests

test_bls_rotation.py:

  • TestBlsKeyRotation::test_rotate_bls_key - full lifecycle: registration
    stamp, pending key in futurePoolParams, re-stamped bksRegisteredIn
    at f+1 with the committee still holding the old key, new key seated
    and voting at f+2, all other pool parameters unchanged
  • TestBlsKeyRotation::test_reregister_same_bls_key - renewal re-stamps
    the epoch with the key unchanged
  • TestBlsKeyRotation::test_drop_and_restore_bls_key - a Conway
    registration certificate drops the key, leaving the pool seated but
    keyless, then rotates one back in
  • TestBlsKeyExpiration::test_expired_bls_key - custom-genesis cluster
    with a 5 epoch KES lifetime, so maxKeyAge is 7 and a key can be aged
    out inside a testrun; checks the pool keeps its seat and loses its vote
  • TestBlsKeyRotationVoting::test_rotation_pair_keeps_voting - the
    operator 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 B
    sizes, and the helpers to read a registered key
  • leios.py - Leios trace constants and log search primitives, extracted
    from test_leios_blocks.py (-322 lines there, no behaviour change)
  • kes.py - refresh_opcerts(), extracted from the closure in
    test_kes.py, now used by both modules
  • test_bls_keys.py - moved onto bls.py, and gained
    test_bls_vkey_not_accepted_as_signing_key

Ledger gate

test_expired_bls_key and test_rotation_pair_keeps_voting xfail on
IntersectMBO/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 POOL rule then
rejects the update with VRFKeyHashAlreadyRegistered - even though the
VRF 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_key needs short epochs and runs on local_fast;
test_rotation_pair_keeps_voting needs endorser blocks and so a block
rate slow enough for a mempool backlog, which today means leios_fast.
The three ledger-only tests run on any Dijkstra variant.

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
mkoura requested a review from saratomaz as a code owner September 24, 2026 10:19
@mkoura
mkoura requested a lite review from Copilot and removed request for saratomaz September 24, 2026 10:19

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

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.

@mkoura
mkoura merged commit d6277ec into master Sep 24, 2026
4 checks passed
@mkoura
mkoura deleted the bls_key_rotation_tests branch September 24, 2026 10:27
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.

2 participants