2020
2121- Each key pair is stored in a separate JSON file with hex-encoded SSZ.
2222- Directory structure: ``test_keys/{scheme}_scheme/{index}.json``
23- - Each file contains: ``{"public": "0a1b...", "secret": "2c3d..."}``
23+ - Each file has four hex-encoded SSZ fields:
24+ ``attestation_public``, ``attestation_secret``,
25+ ``proposal_public``, ``proposal_secret``
2426"""
2527
2628from __future__ import annotations
3638from concurrent .futures import ProcessPoolExecutor
3739from functools import partial
3840from pathlib import Path
41+ from typing import Literal
3942
4043from lean_spec .config import LEAN_ENV
4144from lean_spec .subspecs .containers import AttestationData , ValidatorIndex
4851from lean_spec .subspecs .koalabear import Fp
4952from lean_spec .subspecs .xmss .aggregation import AggregatedSignatureProof
5053from lean_spec .subspecs .xmss .constants import TARGET_CONFIG
51- from lean_spec .subspecs .xmss .containers import KeyPair , PublicKey , Signature
54+ from lean_spec .subspecs .xmss .containers import PublicKey , Signature , ValidatorKeyPair
5255from lean_spec .subspecs .xmss .interface import (
5356 PROD_SIGNATURE_SCHEME ,
5457 TEST_SIGNATURE_SCHEME ,
6063 HashTreeOpening ,
6164 Randomness ,
6265)
63- from lean_spec .types import Uint64
66+ from lean_spec .types import Bytes32 , Uint64
6467
6568__all__ = [
6669 "CLI_DEFAULT_MAX_SLOT" ,
@@ -162,14 +165,14 @@ def get_keys_dir(scheme_name: str) -> Path:
162165 return Path (__file__ ).parent / "test_keys" / f"{ scheme_name } _scheme"
163166
164167
165- class LazyKeyDict (Mapping [ValidatorIndex , KeyPair ]):
168+ class LazyKeyDict (Mapping [ValidatorIndex , ValidatorKeyPair ]):
166169 """Load pre-generated keys from disk (cached after first call)."""
167170
168171 def __init__ (self , scheme_name : str ) -> None :
169172 """Initialize with scheme name for locating key files."""
170173 self ._scheme_name = scheme_name
171174 self ._keys_dir = get_keys_dir (scheme_name )
172- self ._cache : dict [ValidatorIndex , KeyPair ] = {}
175+ self ._cache : dict [ValidatorIndex , ValidatorKeyPair ] = {}
173176 self ._available_indices : set [ValidatorIndex ] | None = None
174177
175178 def _ensure_dir_exists (self ) -> None :
@@ -194,15 +197,15 @@ def _get_available_indices(self) -> set[ValidatorIndex]:
194197 )
195198 return self ._available_indices
196199
197- def _load_key (self , idx : ValidatorIndex ) -> KeyPair :
200+ def _load_key (self , idx : ValidatorIndex ) -> ValidatorKeyPair :
198201 """Load a single key from disk."""
199202 key_file = self ._keys_dir / f"{ idx } .json"
200203 if not key_file .exists ():
201204 raise KeyError (f"Key file not found: { key_file } " )
202205 data = json .loads (key_file .read_text ())
203- return KeyPair .from_dict (data )
206+ return ValidatorKeyPair .from_dict (data )
204207
205- def __getitem__ (self , idx : ValidatorIndex ) -> KeyPair :
208+ def __getitem__ (self , idx : ValidatorIndex ) -> ValidatorKeyPair :
206209 """Get key pair by validator index, loading from disk if needed."""
207210 if idx not in self ._cache :
208211 self ._cache [idx ] = self ._load_key (idx )
@@ -244,7 +247,7 @@ def __init__(
244247 """Initialize the manager with optional custom configuration."""
245248 self .max_slot = max_slot
246249 self .scheme = scheme
247- self ._state : dict [ValidatorIndex , KeyPair ] = {}
250+ self ._state : dict [ValidatorIndex , ValidatorKeyPair ] = {}
248251
249252 try :
250253 self .scheme_name = next (
@@ -260,7 +263,7 @@ def keys(self) -> LazyKeyDict:
260263 _LAZY_KEY_CACHE [self .scheme_name ] = LazyKeyDict (self .scheme_name )
261264 return _LAZY_KEY_CACHE [self .scheme_name ]
262265
263- def __getitem__ (self , idx : ValidatorIndex ) -> KeyPair :
266+ def __getitem__ (self , idx : ValidatorIndex ) -> ValidatorKeyPair :
264267 """Get key pair, returning advanced state if available."""
265268 if idx in self ._state :
266269 return self ._state [idx ]
@@ -282,20 +285,52 @@ def __iter__(self) -> Iterator[ValidatorIndex]:
282285 """Iterate over validator indices."""
283286 return iter (self .keys )
284287
285- def get_public_key (self , idx : ValidatorIndex ) -> PublicKey :
286- """Get a validator's public key."""
287- return self [idx ].public
288+ def _sign_with_secret (
289+ self ,
290+ validator_id : ValidatorIndex ,
291+ slot : Slot ,
292+ message : Bytes32 ,
293+ secret_field : Literal ["attestation_secret" , "proposal_secret" ],
294+ ) -> Signature :
295+ """
296+ Shared signing logic for attestation/proposal paths.
297+
298+ Handles XMSS state advancement until the requested slot is within the
299+ prepared interval, caches the updated secret, and produces the signature.
300+
301+ Args:
302+ validator_id: Validator index whose key should be used.
303+ slot: The slot to sign for.
304+ message: The message bytes to sign.
305+ secret_field: Which secret on the key pair should advance.
306+ """
307+ kp = self [validator_id ]
308+ sk = getattr (kp , secret_field )
309+
310+ # Advance key state until the slot is ready for signing.
311+ prepared = self .scheme .get_prepared_interval (sk )
312+ while int (slot ) not in prepared :
313+ activation = self .scheme .get_activation_interval (sk )
314+ if prepared .stop >= activation .stop :
315+ raise ValueError (f"Slot { slot } exceeds key lifetime { activation .stop } " )
316+ sk = self .scheme .advance_preparation (sk )
317+ prepared = self .scheme .get_prepared_interval (sk )
318+
319+ # Cache advanced state (only the selected secret changes).
320+ self ._state [validator_id ] = kp ._replace (** {secret_field : sk })
321+
322+ return self .scheme .sign (sk , slot , message )
288323
289324 def sign_attestation_data (
290325 self ,
291326 validator_id : ValidatorIndex ,
292327 attestation_data : AttestationData ,
293328 ) -> Signature :
294329 """
295- Sign an attestation data with automatic key state advancement .
330+ Sign attestation data with the attestation key .
296331
297- XMSS is stateful: signing advances the internal key state.
298- This method handles advancement transparently .
332+ XMSS is stateful: this delegates to the shared helper which advances the
333+ attestation key state as needed while leaving the proposal key untouched .
299334
300335 Args:
301336 validator_id: The validator index to sign the attestation data for.
@@ -307,25 +342,37 @@ def sign_attestation_data(
307342 Raises:
308343 ValueError: If slot exceeds key lifetime.
309344 """
310- slot = attestation_data .slot
311- kp = self [validator_id ]
312- sk = kp .secret
345+ return self ._sign_with_secret (
346+ validator_id ,
347+ attestation_data .slot ,
348+ attestation_data .data_root_bytes (),
349+ "attestation_secret" ,
350+ )
351+
352+ def sign_block_root (
353+ self ,
354+ validator_id : ValidatorIndex ,
355+ slot : Slot ,
356+ block_root : Bytes32 ,
357+ ) -> Signature :
358+ """
359+ Sign a block root with the proposal key.
313360
314- # Advance key state until slot is in prepared interval
315- prepared = self .scheme .get_prepared_interval (sk )
316- while int (slot ) not in prepared :
317- activation = self .scheme .get_activation_interval (sk )
318- if prepared .stop >= activation .stop :
319- raise ValueError (f"Slot { slot } exceeds key lifetime { activation .stop } " )
320- sk = self .scheme .advance_preparation (sk )
321- prepared = self .scheme .get_prepared_interval (sk )
361+ Advances the proposal key state until the requested slot is within the
362+ prepared interval, then signs the block root.
322363
323- # Cache advanced state
324- self ._state [validator_id ] = kp ._replace (secret = sk )
364+ Args:
365+ validator_id: The validator index to sign the block for.
366+ slot: The slot of the block being signed.
367+ block_root: The hash_tree_root(block) to sign.
325368
326- # Sign hash tree root of the attestation data
327- message = attestation_data .data_root_bytes ()
328- return self .scheme .sign (sk , slot , message )
369+ Returns:
370+ XMSS signature.
371+
372+ Raises:
373+ ValueError: If slot exceeds key lifetime.
374+ """
375+ return self ._sign_with_secret (validator_id , slot , block_root , "proposal_secret" )
329376
330377 def build_attestation_signatures (
331378 self ,
@@ -350,7 +397,7 @@ def build_attestation_signatures(
350397 # Look up pre-computed signatures by attestation data and validator ID.
351398 sigs_for_data = lookup .get (agg .data , {})
352399
353- public_keys : list [PublicKey ] = [self . get_public_key ( vid ) for vid in validator_ids ]
400+ public_keys : list [PublicKey ] = [self [ vid ]. attestation_public for vid in validator_ids ]
354401 signatures : list [Signature ] = [
355402 sigs_for_data .get (vid ) or self .sign_attestation_data (vid , agg .data )
356403 for vid in validator_ids
@@ -359,10 +406,11 @@ def build_attestation_signatures(
359406 # If the caller supplied raw signatures and any are invalid,
360407 # aggregation should fail with exception.
361408 participants = AggregationBits .from_validator_indices (validator_ids )
409+ raw_xmss = list (zip (public_keys , signatures , strict = True ))
362410 proof = AggregatedSignatureProof .aggregate (
363- participants = participants ,
364- public_keys = public_keys ,
365- signatures = signatures ,
411+ xmss_participants = participants ,
412+ children = [] ,
413+ raw_xmss = raw_xmss ,
366414 message = message ,
367415 slot = slot ,
368416 )
@@ -374,10 +422,16 @@ def build_attestation_signatures(
374422def _generate_single_keypair (
375423 scheme : GeneralizedXmssScheme , num_slots : int , index : int
376424) -> dict [str , str ]:
377- """Generate one key pair (module-level for pickling in ProcessPoolExecutor )."""
425+ """Generate dual key pairs for one validator (module-level for pickling)."""
378426 print (f"Starting key #{ index } generation..." )
379- pk , sk = scheme .key_gen (Slot (0 ), Uint64 (num_slots ))
380- return KeyPair (public = pk , secret = sk ).to_dict ()
427+ att_pk , att_sk = scheme .key_gen (Slot (0 ), Uint64 (num_slots ))
428+ prop_pk , prop_sk = scheme .key_gen (Slot (0 ), Uint64 (num_slots ))
429+ return ValidatorKeyPair (
430+ attestation_public = att_pk ,
431+ attestation_secret = att_sk ,
432+ proposal_public = prop_pk ,
433+ proposal_secret = prop_sk ,
434+ ).to_dict ()
381435
382436
383437def _generate_keys (lean_env : str , count : int , max_slot : int ) -> None :
0 commit comments