Skip to content

Commit ad9a322

Browse files
authored
separate attestation and proposal keys & recursive aggregation API update - Devnet4 (#449)
* separate attestation and proposal keys * sign block hash * recursive aggregation * address comments * address comments
1 parent be85318 commit ad9a322

File tree

73 files changed

+1629
-1443
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

73 files changed

+1629
-1443
lines changed

docs/client/networking.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,15 +68,15 @@ Snappy-compressed message, which type is identified by the topic:
6868

6969
| Topic Name | Message Type | Encoding |
7070
|------------------------------------------------------------|-----------------------------|--------------|
71-
| /leanconsensus/devnet3/blocks/ssz_snappy | SignedBlockWithAttestation | SSZ + Snappy |
71+
| /leanconsensus/devnet3/blocks/ssz_snappy | SignedBlock | SSZ + Snappy |
7272
| /leanconsensus/devnet3/attestation\_{subnet_id}/ssz_snappy | SignedAttestation | SSZ + Snappy |
7373
| /leanconsensus/devnet3/aggregation/ssz_snappy | SignedAggregatedAttestation | SSZ + Snappy |
7474

7575
### Message Types
7676

7777
Three main message types exist:
7878

79-
- _Blocks_, defined by the `SignedBlockWithAttestation` type, are proposed by
79+
- _Blocks_, defined by the `SignedBlock` type, are proposed by
8080
validators and propagated on the block topic. Every node needs to see blocks
8181
quickly.
8282

packages/testing/src/consensus_testing/keys.py

Lines changed: 93 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@
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

2628
from __future__ import annotations
@@ -36,6 +38,7 @@
3638
from concurrent.futures import ProcessPoolExecutor
3739
from functools import partial
3840
from pathlib import Path
41+
from typing import Literal
3942

4043
from lean_spec.config import LEAN_ENV
4144
from lean_spec.subspecs.containers import AttestationData, ValidatorIndex
@@ -48,7 +51,7 @@
4851
from lean_spec.subspecs.koalabear import Fp
4952
from lean_spec.subspecs.xmss.aggregation import AggregatedSignatureProof
5053
from 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
5255
from lean_spec.subspecs.xmss.interface import (
5356
PROD_SIGNATURE_SCHEME,
5457
TEST_SIGNATURE_SCHEME,
@@ -60,7 +63,7 @@
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(
374422
def _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

383437
def _generate_keys(lean_env: str, count: int, max_slot: int) -> None:

0 commit comments

Comments
 (0)