fix(light-client): verify boundary 2-chain QCs against their own epoch#4713
Conversation
A 2-chain proving the last leaf of an epoch includes a deciding QC produced in the subsequent epoch, signed by the next epoch's quorum. verify_qc_chain_and_get_version checked every certificate against the single quorum of the leaf's epoch, so fetching the last leaf of an epoch failed with "invalid threshold signature: Pairing check failed" whenever adjacent epochs have different stake tables. First observed when fetching the last leaf signed by the genesis stake table on a fresh node, where the next epoch uses the first contract-sourced stake table. Dispatch each certificate on the epoch committed in its signed payload: certificates from the leaf's epoch are checked against the proof's quorum, certificates from the following epoch against the next epoch's stake table. Any other epoch is rejected. The epoch number is part of the signed vote data, so a certificate claiming the wrong epoch fails signature verification. Regression tests (real BLS signatures over disjoint per-epoch quorums; both fail without the fix): - test_epoch_boundary_quorum_change - test_epoch_boundary_deciding_qc_wrong_quorum Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Claude finished @bfish713's task in 2m 34s —— View job Code Review: PR #4713 — verify boundary 2-chain QCs against their own epoch
Verdict: LGTM. This is a correct, well-scoped, and well-tested fix. The root-cause analysis in the description (identical-membership but reordered stake tables aggregating the wrong keys in the signer bitvec) is convincing and matches the code. Correctness — the fix is soundThe core question is whether dispatching on
The match in
|
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
| (Some(leaf_epoch), Some(cert_epoch)) => { | ||
| bail!( | ||
| "certificate from epoch {cert_epoch} cannot justify a leaf in epoch \ | ||
| {leaf_epoch}" | ||
| ); | ||
| }, |
There was a problem hiding this comment.
The dispatch is correct and, as the PR notes, binding — cert.epoch() reads qc.data.epoch, which is part of the signed VersionedVoteData, so a cert lying about its epoch fails the signature check against whichever quorum it is routed to.
Minor test-coverage note: this explicit bail! arm (a cert from a non-adjacent epoch, e.g. leaf_epoch + 2, or an earlier epoch than the leaf) isn't directly exercised by the two new tests. Both new fixtures only produce certs from leaf_epoch and leaf_epoch + 1. A small third case that hands verify_qc_chain_and_get_version a deciding QC tagged epoch 3 (or epoch 0) and asserts it hits this branch would lock in the rejection behavior independently of the signature check.
| ensure!( | ||
| cert.next_epoch_qc().is_none(), | ||
| "unexpected next-epoch QC on a certificate from the next epoch" | ||
| ); |
There was a problem hiding this comment.
The reasoning holds: a cert from leaf_epoch + 1 sits at the start of the next epoch (block 11 in the fixture), never at that epoch's transition boundary, so it legitimately carries no next-epoch QC — and verifying one here would need the stake table two epochs past the proof's, which isn't available. Rejecting is the right call.
Coverage nit (matches the PR's stated review focus): this ensure! isn't hit by either new test — the deciding QC in epoch_boundary_fixture is built with Certificate::non_epoch_change, so next_epoch_qc() is always None. Consider a variant that attaches a next-epoch QC to the deciding cert and asserts this specific error fires, so a future refactor can't silently drop the check.
The test client attached next_epoch_justify_qc to a leaf when the leaf's own block was in epoch transition. Production attaches it when the block justified by the leaf's QC (the parent) is in transition, so the mock dual-signed certificates one block too early and never dual-signed the QC for the last block of an epoch. The stricter verification introduced in this branch (rejecting a next-epoch QC on a non-transition certificate) correctly rejected these mock chains, failing every state::test stake table catchup test. Attach the QC based on the parent block, sign it with the quorum of the epoch after the parent's (which differs from the leaf's epoch + 1 at the epoch boundary), and commit the vote data under the parent's protocol version. Also gate the QC to pre-0.6 leaves: the new protocol justifies epoch changes with Certificate2 and always stores next_epoch_justify_qc: None in the Leaf2 representation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Problem
A 2-chain proving the last leaf of epoch E always includes a deciding QC produced in epoch E+1, signed by that epoch's quorum.
verify_qc_chain_and_get_versionverified every certificate against the single quorum of the leaf's epoch, so fetching the last leaf of an epoch failed withinvalid threshold signature: Pairing check failedwhenever adjacent epochs have different stake tables.First observed on milk at height 6000 (
epoch_height = 3000): the boundary between the genesis stake table (epochs 1–2, fromknown_nodes_with_stake, all stakes0x1) and the first contract-sourced stake table (epoch 3, real stakes, different ordering). Same members, same size, different order — so the signer bitvec aggregated the wrong keys and the pairing check failed. A freshly-syncing node can never backfill past this height. The bug is invisible at every other epoch boundary because adjacent contract-sourced tables are usually identical.Same bug family as #4594 (wrong quorum for pre-PoS QCs) and #4669 (cert2 verified against wrong epoch).
Fix
Dispatch each certificate on the epoch committed in its signed payload: certs from the leaf's epoch verify against the proof's quorum as before; certs from the following epoch verify against the next epoch's stake table (which
StakeTableQuorumalready carries for dual-signed transition certs); any other epoch is rejected. The epoch number is part of the signed vote data, so a certificate claiming the wrong epoch fails signature verification — the dispatch is binding.Pre-epoch certificates (epoch
None) keep the existing behavior, leaving the #4594 HotStuff1 path untouched.Review focus: the epoch-dispatch match in
verify_qc_chain_and_get_version, and thenext_epoch_qc().is_none()restriction inStakeTableQuorum::verify_next_epoch_static.Tests
Regression tests build the exact boundary shape with real BLS signatures over disjoint per-epoch quorums (leaf 10 = last leaf of epoch 1, dual-signed transition cert, deciding QC from epoch 2). Both fail without the fix:
test_epoch_boundary_quorum_change: a valid boundary proof must verify (the old code rejected it — the milk symptom)test_epoch_boundary_deciding_qc_wrong_quorum: a deciding QC claiming the next epoch but signed by the current epoch's quorum must fail (the old code accepted it)🤖 Generated with Claude Code