Skip to content

Commit 2d5b273

Browse files
Enforce single AttestationData per block (leanSpec PR #510) (#258)
## Motivation Currently, `build_block` can produce multiple attestation entries sharing the same `AttestationData` -- each backed by a different aggregated signature proof. This happens when `extend_proofs_greedily` selects multiple proofs for the same vote data (e.g., from different aggregation intervals covering non-overlapping validator sets). [leanSpec PR #510](leanEthereum/leanSpec#510) introduces a new protocol invariant: **each unique `AttestationData` must appear at most once per block**. Multiple proofs for the same vote are compacted into a single entry, reducing block size and simplifying downstream processing. The invariant is enforced on both the building and validation sides. ## Description ### Block validation (`on_block_core`) A cheap O(n) uniqueness check is inserted **before** signature verification and state transition (the two most expensive steps). If a block contains duplicate `AttestationData` entries, it is rejected with a new `StoreError::DuplicateAttestationData` error. This uses `HashSet<&AttestationData>` -- possible because `AttestationData` already derives `Hash + Eq`. ### Block building (`build_block`) After the existing greedy proof selection loop, a new `compact_attestations` step groups all `(attestation, proof)` pairs by their `AttestationData` and merges duplicates: - **Single entry per data**: kept as-is (fast path). - **Multiple entries with empty proofs** (skip-sig / devnet mode): participant bitfields are unioned via `union_aggregation_bits`, producing a single `AggregatedSignatureProof::empty(merged_bits)`. - **Multiple entries with real proofs** (production with XMSS aggregation): the proof covering the most participants is kept. Full merge would require recursive proof aggregation, which lean-multisig does not yet support (the [spec itself notes](https://github.com/leanEthereum/leanSpec/blob/main/src/lean_spec/subspecs/xmss/aggregation.py#L72) "The API supports recursive aggregation but the bindings currently do not"). The intermediate block built inside the justification-check loop is **not** compacted -- it only tests whether justification advances, and vote counting is identical regardless of entry layout. ### New helpers | Function | Purpose | |----------|---------| | `union_aggregation_bits(a, b)` | Bitwise OR of two `AggregationBits` bitfields | | `compact_attestations(atts, proofs)` | Groups by `AttestationData`, merges duplicates, preserves first-occurrence order | ### Future work When lean-multisig adds recursive aggregation support, the `else` branch in `compact_attestations` (real proofs) can be upgraded to cryptographically merge all proofs instead of keeping only the best one. This will recover the small amount of validator coverage currently lost when multiple real proofs exist for the same `AttestationData`. ## Test plan - [x] `compact_attestations_no_duplicates` -- distinct data passes through unchanged - [x] `compact_attestations_merges_empty_proofs` -- two entries with same data + empty proofs merge into one with unioned participants covering all validators - [x] `compact_attestations_real_proofs_keeps_best` -- two entries with same data + real proofs keeps the one with more participants - [x] `compact_attestations_preserves_order` -- multiple data entries (some duplicated) output in first-occurrence order - [x] `on_block_rejects_duplicate_attestation_data` -- block with duplicate entries returns `DuplicateAttestationData` error via `on_block_without_verification` - [x] All 18 existing blockchain lib tests still pass - [x] `cargo fmt --all -- --check` clean - [x] `cargo clippy -p ethlambda-blockchain -- -D warnings` clean - [ ] Spec test fixtures update (deferred until leanSpec submodule includes PR #510) --------- Co-authored-by: Tomás Grüner <47506558+MegaRedHand@users.noreply.github.com>
1 parent 9a86501 commit 2d5b273

File tree

9 files changed

+484
-30
lines changed

9 files changed

+484
-30
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ docker-build: ## 🐳 Build the Docker image
2424
-t ghcr.io/lambdaclass/ethlambda:$(DOCKER_TAG) .
2525
@echo
2626

27-
LEAN_SPEC_COMMIT_HASH:=ad9a3226f55e1ba143e0991010ff1f6c2de62941
27+
LEAN_SPEC_COMMIT_HASH:=d39d10195414921e979e2fdd43723d89cee13c8b
2828

2929
leanSpec:
3030
git clone https://github.com/leanEthereum/leanSpec.git --single-branch

crates/blockchain/src/key_manager.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use ethlambda_types::{
55
primitives::{H256, HashTreeRoot as _},
66
signature::{ValidatorSecretKey, ValidatorSignature},
77
};
8+
use tracing::info;
89

910
use crate::metrics;
1011

@@ -102,6 +103,23 @@ impl KeyManager {
102103
.get_mut(&validator_id)
103104
.ok_or(KeyManagerError::ValidatorKeyNotFound(validator_id))?;
104105

106+
// Advance XMSS key preparation window if the slot is outside the current window.
107+
// Each bottom tree covers 65,536 slots; the window holds 2 at a time.
108+
// Multiple advances may be needed if the node was offline for an extended period.
109+
if !secret_key.is_prepared_for(slot) {
110+
info!(validator_id, slot, "Advancing XMSS key preparation window");
111+
while !secret_key.is_prepared_for(slot) {
112+
let before = secret_key.get_prepared_interval();
113+
secret_key.advance_preparation();
114+
if secret_key.get_prepared_interval() == before {
115+
return Err(KeyManagerError::SigningError(format!(
116+
"XMSS key exhausted for validator {validator_id}: \
117+
slot {slot} is beyond the key's activation interval"
118+
)));
119+
}
120+
}
121+
}
122+
105123
let signature: ValidatorSignature = {
106124
let _timing = metrics::time_pq_sig_attestation_signing();
107125
secret_key

0 commit comments

Comments
 (0)