Skip to content

Commit 172d9b0

Browse files
authored
Advance XMSS key preparation window before signing (#261)
## Summary - Expose `is_prepared_for(slot)` and `advance_preparation()` on `ValidatorSecretKey`, delegating to the leansig `SignatureSchemeSecretKey` trait - Before signing in `KeyManager::sign_message()`, check if the target slot is within the prepared window and advance if needed - Return a descriptive error if the key's activation interval is fully exhausted - Add a timing test for `advance_preparation()` (526ms in release mode on Apple Silicon) ## Root Cause XMSS keys use a Top-Bottom Tree Traversal scheme where only two consecutive bottom trees are loaded in memory at any time. Each bottom tree covers `sqrt(LIFETIME) = 2^16 = 65,536` slots, so the prepared window spans `131,072` slots (~6 days at 4s/slot). The leansig library provides `advance_preparation()` to slide this window forward by computing the next bottom tree, but ethlambda's `KeyManager` never called it. When the devnet at `admin@ethlambda-1` reached slot 131,072, all 4 nodes panicked simultaneously: ``` Signing: key not yet prepared for this epoch, try calling sk.advance_preparation. ``` The fix checks the prepared interval before every sign operation and advances the window on demand. This is a lazy approach — `advance_preparation` is called at signing time rather than proactively in the tick loop — because: - It happens once every ~3 days (65,536 slots) - The computation (one bottom tree of hash leaves) takes ~526ms in release mode - It keeps the change minimal and avoids tick-loop complexity ## Test plan - [x] `make fmt` clean - [x] `make lint` clean - [x] `make test` passes (existing tests use small lifetimes or skip verification) - [x] `test_advance_preparation_duration` passes (`cargo test -p ethlambda-types test_advance_preparation_duration --release -- --ignored --nocapture`) - [ ] Deploy to devnet with fresh genesis and verify it runs past slot 131,072 without panic
1 parent 1345286 commit 172d9b0

4 files changed

Lines changed: 105 additions & 1 deletion

File tree

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.

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

crates/common/types/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,4 @@ libssz-types.workspace = true
2424
[dev-dependencies]
2525
serde_json.workspace = true
2626
serde_yaml_ng.workspace = true
27+
rand.workspace = true

crates/common/types/src/signature.rs

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
use std::ops::Range;
2+
13
use leansig::{
24
serialization::Serializable,
3-
signature::{SignatureScheme, SigningError},
5+
signature::{SignatureScheme, SignatureSchemeSecretKey as _, SigningError},
46
};
57

68
use crate::primitives::H256;
@@ -97,4 +99,86 @@ impl ValidatorSecretKey {
9799
let sig = LeanSignatureScheme::sign(&self.inner, slot, &message.0)?;
98100
Ok(ValidatorSignature { inner: sig })
99101
}
102+
103+
/// Returns true if the key is prepared to sign at the given slot.
104+
///
105+
/// XMSS keys maintain a sliding window of two bottom trees. Only slots
106+
/// within this window can be signed without advancing the preparation.
107+
pub fn is_prepared_for(&self, slot: u32) -> bool {
108+
self.inner.get_prepared_interval().contains(&(slot as u64))
109+
}
110+
111+
/// Returns the slot range currently covered by the prepared window.
112+
pub fn get_prepared_interval(&self) -> Range<u64> {
113+
self.inner.get_prepared_interval()
114+
}
115+
116+
/// Advance the prepared window forward by one bottom tree.
117+
///
118+
/// Each call slides the window by sqrt(LIFETIME) = 65,536 slots.
119+
/// If the window is already at the end of the key's activation interval,
120+
/// this is a no-op.
121+
pub fn advance_preparation(&mut self) {
122+
self.inner.advance_preparation();
123+
}
124+
}
125+
126+
#[cfg(test)]
127+
mod tests {
128+
use super::*;
129+
use leansig::serialization::Serializable;
130+
use rand::{SeedableRng, rngs::StdRng};
131+
132+
const LEAVES_PER_BOTTOM_TREE: u32 = 1 << 16; // 65,536
133+
134+
/// Generate a ValidatorSecretKey with 3 bottom trees so advance_preparation can be tested.
135+
///
136+
/// This is slow (~minutes) because it computes 3 bottom trees of 65,536 leaves each.
137+
fn generate_key_with_three_bottom_trees() -> ValidatorSecretKey {
138+
let mut rng = StdRng::seed_from_u64(42);
139+
// Request enough active epochs for 3 bottom trees (> 2 * 65,536)
140+
let num_active_epochs = (LEAVES_PER_BOTTOM_TREE as usize) * 2 + 1;
141+
let (_pk, sk) = LeanSignatureScheme::key_gen(&mut rng, 0, num_active_epochs);
142+
let sk_bytes = sk.to_bytes();
143+
ValidatorSecretKey::from_bytes(&sk_bytes).expect("valid secret key")
144+
}
145+
146+
#[test]
147+
#[ignore = "slow: generates production-size XMSS key (~minutes)"]
148+
fn test_advance_preparation_duration() {
149+
println!("Generating XMSS key with 3 bottom trees (this takes a while)...");
150+
let keygen_start = std::time::Instant::now();
151+
let mut sk = generate_key_with_three_bottom_trees();
152+
println!("Key generation took: {:?}", keygen_start.elapsed());
153+
154+
// Initial window covers [0, 131072)
155+
assert!(sk.is_prepared_for(0));
156+
assert!(sk.is_prepared_for(LEAVES_PER_BOTTOM_TREE - 1));
157+
assert!(sk.is_prepared_for(2 * LEAVES_PER_BOTTOM_TREE - 1));
158+
assert!(!sk.is_prepared_for(2 * LEAVES_PER_BOTTOM_TREE));
159+
160+
// Time the advance_preparation call
161+
let advance_start = std::time::Instant::now();
162+
sk.advance_preparation();
163+
let advance_duration = advance_start.elapsed();
164+
165+
println!("advance_preparation() took: {advance_duration:?}");
166+
167+
// Window should now cover [65536, 196608)
168+
assert!(!sk.is_prepared_for(0));
169+
assert!(sk.is_prepared_for(LEAVES_PER_BOTTOM_TREE));
170+
assert!(sk.is_prepared_for(3 * LEAVES_PER_BOTTOM_TREE - 1));
171+
172+
// Verify signing works in the new window
173+
let message = H256::from([42u8; 32]);
174+
let slot = 2 * LEAVES_PER_BOTTOM_TREE; // slot 131,072 — the one that crashed the devnet
175+
let sign_start = std::time::Instant::now();
176+
let result = sk.sign(slot, &message);
177+
println!("Signing at slot {slot} took: {:?}", sign_start.elapsed());
178+
assert!(
179+
result.is_ok(),
180+
"signing should succeed after advance: {}",
181+
result.err().map_or(String::new(), |e| e.to_string())
182+
);
183+
}
100184
}

0 commit comments

Comments
 (0)