Skip to content

Commit e5b9140

Browse files
feat: add signature aggregation using python bindings (#238)
* feat: add signature aggregation using python bindings * add highlevel modified flow for attestation import and proposal packing * feat: update containers to handle attestation instead of signedAttestations * fix: ci, add: test aggregation and verification * fix: update store mappings * fix: address comments * fix: address review comments from codex * rename: Signature to XmssSignature * fix: update aggregation API signature * update: block building logic with greedy signature aggregation * fix: make names intuitive and add type alias * add: extensive testing * refactor: aggregated sig container, address other comments * fix: simplify block building --------- Co-authored-by: harkamal <[email protected]>
1 parent b162611 commit e5b9140

File tree

20 files changed

+1784
-528
lines changed

20 files changed

+1784
-528
lines changed

packages/testing/src/consensus_testing/keys.py

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,13 @@
3939

4040
from lean_spec.config import LEAN_ENV
4141
from lean_spec.subspecs.containers import AttestationData
42-
from lean_spec.subspecs.containers.attestation.types import NaiveAggregatedSignature
4342
from lean_spec.subspecs.containers.block.types import (
4443
AggregatedAttestations,
4544
AttestationSignatures,
4645
)
4746
from lean_spec.subspecs.containers.slot import Slot
47+
from lean_spec.subspecs.containers.state.types import AttestationSignatureKey
48+
from lean_spec.subspecs.xmss.aggregation import MultisigAggregatedSignature
4849
from lean_spec.subspecs.xmss.containers import KeyPair, PublicKey, Signature
4950
from lean_spec.subspecs.xmss.interface import (
5051
PROD_SIGNATURE_SCHEME,
@@ -273,24 +274,39 @@ def sign_attestation_data(
273274
def build_attestation_signatures(
274275
self,
275276
aggregated_attestations: AggregatedAttestations,
276-
signature_lookup: Mapping[tuple[Uint64, bytes], Signature] | None = None,
277+
signature_lookup: Mapping[AttestationSignatureKey, Signature] | None = None,
277278
) -> AttestationSignatures:
278-
"""Build `AttestationSignatures` for already-aggregated attestations."""
279+
"""
280+
Build `AttestationSignatures` for already-aggregated attestations.
281+
282+
For each aggregated attestation, collect the participating validators' public keys and
283+
signatures, then produce a single leanVM aggregated signature proof.
284+
"""
279285
lookup = signature_lookup or {}
280-
return AttestationSignatures(
281-
data=[
282-
NaiveAggregatedSignature(
283-
data=[
284-
(
285-
lookup.get((vid, agg.data.data_root_bytes()))
286-
or self.sign_attestation_data(vid, agg.data)
287-
)
288-
for vid in agg.aggregation_bits.to_validator_indices()
289-
]
290-
)
291-
for agg in aggregated_attestations
286+
287+
proof_blobs: list[MultisigAggregatedSignature] = []
288+
for agg in aggregated_attestations:
289+
validator_ids = agg.aggregation_bits.to_validator_indices()
290+
message = agg.data.data_root_bytes()
291+
epoch = agg.data.slot
292+
293+
public_keys: list[PublicKey] = [self.get_public_key(vid) for vid in validator_ids]
294+
signatures: list[Signature] = [
295+
(lookup.get((vid, message)) or self.sign_attestation_data(vid, agg.data))
296+
for vid in validator_ids
292297
]
293-
)
298+
299+
# If the caller supplied raw signatures and any are invalid,
300+
# aggregation should fail with exception.
301+
aggregated_signature = MultisigAggregatedSignature.aggregate_signatures(
302+
public_keys=public_keys,
303+
signatures=signatures,
304+
message=message,
305+
epoch=epoch,
306+
)
307+
proof_blobs.append(aggregated_signature)
308+
309+
return AttestationSignatures(data=proof_blobs)
294310

295311

296312
def _generate_single_keypair(

packages/testing/src/consensus_testing/test_fixtures/fork_choice.py

Lines changed: 46 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,10 @@
2626
from lean_spec.subspecs.containers.slot import Slot
2727
from lean_spec.subspecs.containers.state import Validators
2828
from lean_spec.subspecs.containers.state.state import State
29+
from lean_spec.subspecs.containers.state.types import AttestationSignatureKey
2930
from lean_spec.subspecs.forkchoice import Store
3031
from lean_spec.subspecs.koalabear import Fp
3132
from lean_spec.subspecs.ssz import hash_tree_root
32-
from lean_spec.subspecs.xmss.constants import PROD_CONFIG
3333
from lean_spec.subspecs.xmss.containers import Signature
3434
from lean_spec.subspecs.xmss.types import HashDigestList, HashTreeOpening, Randomness
3535
from lean_spec.types import Bytes32, Uint64
@@ -231,7 +231,10 @@ def make_fixture(self) -> ForkChoiceTest:
231231

232232
elif isinstance(step, AttestationStep):
233233
# Process attestation from gossip (immutable)
234-
store = store.on_attestation(step.attestation, is_from_block=False)
234+
store = store.on_gossip_attestation(
235+
step.attestation,
236+
scheme=LEAN_ENV_TO_SCHEMES[self.lean_env],
237+
)
235238

236239
else:
237240
raise ValueError(f"Step {i}: unknown step type {type(step).__name__}")
@@ -301,7 +304,12 @@ def _build_block_from_spec(
301304
parent_root = self._resolve_parent_root(spec, store, block_registry)
302305

303306
# Build attestations from spec
304-
attestations = self._build_attestations_from_spec(spec, store, block_registry, parent_root)
307+
attestations, attestation_signatures = self._build_attestations_from_spec(
308+
spec, store, block_registry, parent_root, key_manager
309+
)
310+
311+
gossip_signatures = dict(store.gossip_signatures)
312+
gossip_signatures.update(attestation_signatures)
305313

306314
# Use State.build_block for core block building (pure spec logic)
307315
parent_state = store.states[parent_root]
@@ -310,6 +318,8 @@ def _build_block_from_spec(
310318
proposer_index=proposer_index,
311319
parent_root=parent_root,
312320
attestations=attestations,
321+
gossip_signatures=gossip_signatures,
322+
aggregated_payloads=store.aggregated_payloads,
313323
)
314324

315325
# Create proposer attestation for this block
@@ -325,8 +335,9 @@ def _build_block_from_spec(
325335
)
326336

327337
# Sign all attestations and the proposer attestation
328-
attestation_signatures = key_manager.build_attestation_signatures(
329-
final_block.body.attestations
338+
attestation_signatures_blob = key_manager.build_attestation_signatures(
339+
final_block.body.attestations,
340+
attestation_signatures,
330341
)
331342

332343
proposer_signature = key_manager.sign_attestation_data(
@@ -340,7 +351,7 @@ def _build_block_from_spec(
340351
proposer_attestation=proposer_attestation,
341352
),
342353
signature=BlockSignatures(
343-
attestation_signatures=attestation_signatures,
354+
attestation_signatures=attestation_signatures_blob,
344355
proposer_signature=proposer_signature,
345356
),
346357
)
@@ -392,34 +403,38 @@ def _build_attestations_from_spec(
392403
store: Store,
393404
block_registry: dict[str, Block],
394405
parent_root: Bytes32,
395-
) -> list[Attestation]:
396-
"""Build attestations list from BlockSpec."""
406+
key_manager: XmssKeyManager,
407+
) -> tuple[list[Attestation], dict[AttestationSignatureKey, Signature]]:
408+
"""Build attestations list from BlockSpec and their signatures."""
397409
if spec.attestations is None:
398-
return []
410+
return [], {}
399411

400412
parent_state = store.states[parent_root]
401413
attestations = []
414+
signature_lookup: dict[AttestationSignatureKey, Signature] = {}
402415

403416
for att_spec in spec.attestations:
404417
if isinstance(att_spec, SignedAttestationSpec):
405418
signed_att = self._build_signed_attestation_from_spec(
406-
att_spec, block_registry, parent_state
407-
)
408-
attestations.append(
409-
Attestation(validator_id=signed_att.validator_id, data=signed_att.message)
419+
att_spec, block_registry, parent_state, key_manager
410420
)
411421
else:
412-
attestations.append(
413-
Attestation(validator_id=att_spec.validator_id, data=att_spec.message)
414-
)
422+
signed_att = att_spec
423+
424+
attestation = Attestation(validator_id=signed_att.validator_id, data=signed_att.message)
425+
attestations.append(attestation)
426+
signature_lookup[(attestation.validator_id, attestation.data.data_root_bytes())] = (
427+
signed_att.signature
428+
)
415429

416-
return attestations
430+
return attestations, signature_lookup
417431

418432
def _build_signed_attestation_from_spec(
419433
self,
420434
spec: SignedAttestationSpec,
421435
block_registry: dict[str, Block],
422436
state: State,
437+
key_manager: XmssKeyManager,
423438
) -> SignedAttestation:
424439
"""
425440
Build a SignedAttestation from a SignedAttestationSpec.
@@ -466,15 +481,21 @@ def _build_signed_attestation_from_spec(
466481
)
467482

468483
# Create signed attestation
484+
if spec.signature is not None:
485+
signature = spec.signature
486+
elif spec.valid_signature:
487+
signature = key_manager.sign_attestation_data(
488+
attestation.validator_id, attestation.data
489+
)
490+
else:
491+
signature = Signature(
492+
path=HashTreeOpening(siblings=HashDigestList(data=[])),
493+
rho=Randomness(data=[Fp(0) for _ in range(Randomness.LENGTH)]),
494+
hashes=HashDigestList(data=[]),
495+
)
496+
469497
return SignedAttestation(
470498
validator_id=attestation.validator_id,
471499
message=attestation.data,
472-
signature=(
473-
spec.signature
474-
or Signature(
475-
path=HashTreeOpening(siblings=HashDigestList(data=[])),
476-
rho=Randomness(data=[Fp(0) for _ in range(PROD_CONFIG.RAND_LEN_FE)]),
477-
hashes=HashDigestList(data=[]),
478-
)
479-
),
500+
signature=signature,
480501
)

packages/testing/src/consensus_testing/test_fixtures/state_transition.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from lean_spec.subspecs.ssz.hash import hash_tree_root
1212
from lean_spec.types import Bytes32, Uint64
1313

14+
from ..keys import get_shared_key_manager
1415
from ..test_types import BlockSpec, StateExpectation
1516
from .base import BaseConsensusFixture
1617

@@ -258,10 +259,24 @@ def _build_block_from_spec(self, spec: BlockSpec, state: State) -> tuple[Block,
258259
for vid in agg.aggregation_bits.to_validator_indices()
259260
]
260261

262+
if plain_attestations:
263+
key_manager = get_shared_key_manager(max_slot=spec.slot)
264+
gossip_signatures = {
265+
(att.validator_id, att.data.data_root_bytes()): key_manager.sign_attestation_data(
266+
att.validator_id,
267+
att.data,
268+
)
269+
for att in plain_attestations
270+
}
271+
else:
272+
gossip_signatures = {}
273+
261274
block, post_state, _, _ = state.build_block(
262275
slot=spec.slot,
263276
proposer_index=proposer_index,
264277
parent_root=parent_root,
265278
attestations=plain_attestations,
279+
gossip_signatures=gossip_signatures,
280+
aggregated_payloads={},
266281
)
267282
return block, post_state

packages/testing/src/consensus_testing/test_fixtures/verify_signatures.py

Lines changed: 21 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,14 @@
1616
BlockWithAttestation,
1717
SignedBlockWithAttestation,
1818
)
19+
from lean_spec.subspecs.containers.block.types import AttestationSignatures
1920
from lean_spec.subspecs.containers.checkpoint import Checkpoint
2021
from lean_spec.subspecs.containers.state.state import State
2122
from lean_spec.subspecs.koalabear import Fp
2223
from lean_spec.subspecs.ssz import hash_tree_root
24+
from lean_spec.subspecs.xmss.constants import TARGET_CONFIG
25+
from lean_spec.subspecs.xmss.containers import Signature
26+
from lean_spec.subspecs.xmss.types import HashDigestList, HashTreeOpening, Randomness
2327
from lean_spec.types import Bytes32, Uint64
2428

2529
from ..keys import XmssKeyManager, get_shared_key_manager
@@ -179,26 +183,26 @@ def _build_block_from_spec(
179183
spec, state, key_manager
180184
)
181185

186+
# Provide signatures to State.build_block so it can include attestations during
187+
# fixed-point collection when available_attestations/known_block_roots are used.
188+
# This might contain invalid signatures as we are not validating them here.
189+
gossip_signatures = {
190+
(att.validator_id, att.data.data_root_bytes()): sig
191+
for att, sig in zip(attestations, attestation_signature_inputs, strict=True)
192+
}
193+
182194
# Use State.build_block for core block building (pure spec logic)
183-
final_block, _, _, _ = state.build_block(
195+
final_block, _, _, aggregated_signatures = state.build_block(
184196
slot=spec.slot,
185197
proposer_index=proposer_index,
186198
parent_root=parent_root,
187199
attestations=attestations,
200+
gossip_signatures=gossip_signatures,
201+
aggregated_payloads={},
188202
)
189203

190-
# Preserve per-attestation validity from the spec.
191-
#
192-
# For signature tests we must ensure that the signatures in the input spec are used
193-
# for any intentionally-invalid signature from the input spec remains invalid
194-
# in the produced `SignedBlockWithAttestation`.
195-
signature_lookup: dict[tuple[Uint64, bytes], Signature] = {
196-
(att.validator_id, att.data.data_root_bytes()): sig
197-
for att, sig in zip(attestations, attestation_signature_inputs, strict=True)
198-
}
199-
attestation_signatures = key_manager.build_attestation_signatures(
200-
final_block.body.attestations,
201-
signature_lookup=signature_lookup,
204+
attestation_signatures = AttestationSignatures(
205+
data=aggregated_signatures,
202206
)
203207

204208
# Create proposer attestation for this block
@@ -220,14 +224,10 @@ def _build_block_from_spec(
220224
proposer_attestation.data,
221225
)
222226
else:
223-
# Generate an invalid dummy signature (all zeros)
224-
from lean_spec.subspecs.xmss.constants import TEST_CONFIG
225-
from lean_spec.subspecs.xmss.containers import Signature
226-
from lean_spec.subspecs.xmss.types import HashDigestList, HashTreeOpening, Randomness
227-
227+
# Generate a structurally valid but cryptographically invalid signature (all zeros).
228228
proposer_attestation_signature = Signature(
229229
path=HashTreeOpening(siblings=HashDigestList(data=[])),
230-
rho=Randomness(data=[Fp(0) for _ in range(TEST_CONFIG.RAND_LEN_FE)]),
230+
rho=Randomness(data=[Fp(0) for _ in range(TARGET_CONFIG.RAND_LEN_FE)]),
231231
hashes=HashDigestList(data=[]),
232232
)
233233

@@ -333,14 +333,10 @@ def _build_signed_attestation_from_spec(
333333
attestation.data,
334334
)
335335
else:
336-
# Generate an invalid dummy signature (all zeros)
337-
from lean_spec.subspecs.xmss.constants import TEST_CONFIG
338-
from lean_spec.subspecs.xmss.containers import Signature
339-
from lean_spec.subspecs.xmss.types import HashDigestList, HashTreeOpening, Randomness
340-
336+
# Generate a structurally valid but cryptographically invalid signature (all zeros).
341337
signature = Signature(
342338
path=HashTreeOpening(siblings=HashDigestList(data=[])),
343-
rho=Randomness(data=[Fp(0) for _ in range(TEST_CONFIG.RAND_LEN_FE)]),
339+
rho=Randomness(data=[Fp(0) for _ in range(TARGET_CONFIG.RAND_LEN_FE)]),
344340
hashes=HashDigestList(data=[]),
345341
)
346342

0 commit comments

Comments
 (0)