Skip to content

Commit ac5f259

Browse files
anshalshuklatcoratgerclaude
authored
Aggregated block proof - devnet5 (leanEthereum#717)
* dummy type 1 and type 2 aggregation with block proofs * integrate bindings, fix tests * update bindings * update bindings * cleanup * fix lint * limit max attestation data to 8 * update CI params, remove parallelization in fill * fix reaggregation logic, cleanup * reduce max proof size to 500KiB * switch few CI jobs to macos * fix failing tc post main merge, minor refactor * deconstruct even in case of proposer, not just for aggregators * address comments * block deconstruction * address review feedback - Fix the broken sentence in LstarSpec.verify_signatures' docstring (dangling colon now reads as a coherent description of what the Type-2 proof binds). - Delete test_noop_when_not_a_validator: the validator-id early-return it exercised was removed so non-validator nodes also accrue block-imported attestation weight. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * address second-pass review Fixes flagged in the second round of consensus and Python architecture review: - sync/service.py: widen the decode_bytes catch to (SSZError, ValueError, IndexError). Pre-fix narrowing missed the SSZ exception family raised by Container deserialization, so a malformed proof would crash _process_block_wrapper instead of being demoted to a debug log. - sync/service.py: align the per-attestation loop variable name with the second loop. - validator/service.py: validate each participant index against the active validator set before indexing validators[vid] in _sign_block, raising a clear ValueError instead of a deep KeyError if a stale partial aggregate is passed in. - spec.py: document the one-slot deferral of fork-choice weight from block-imported attestations. The empty-set seeding plus the subsequent acceptance-tick migration is intentional and not a missed update_head call. - test_aggregation.py: add five Type-2 unit tests covering the aggregate-rejects paths, the verify round-trip, the message-swap rejection (pins the per-component binding security property), and the length-mismatch rejection. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Thomas Coratger <60488569+tcoratger@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent db21cc2 commit ac5f259

52 files changed

Lines changed: 1552 additions & 861 deletions

Some content is hidden

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

.github/workflows/ci.yml

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,15 @@ jobs:
7676
- name: Run tests
7777
run: just test
7878

79+
# coverage-gate, fill-tests and interop-tests run on macOS because the
80+
# lean_multisig_py prover's process-global setup is corrupted by xdist
81+
# parallelism; these jobs must run serially and macOS runners were the
82+
# stable choice during bring-up. This is an upstream limitation, not a
83+
# preference: revisit (and prefer ubuntu for cost) once the prover setup
84+
# is per-process safe. See the matching note in the Justfile fill-ci.
7985
coverage-gate:
8086
name: Coverage gate - Python 3.12
81-
runs-on: ubuntu-latest
87+
runs-on: macos-latest
8288
steps:
8389
- name: Checkout leanSpec
8490
uses: actions/checkout@v4
@@ -104,7 +110,7 @@ jobs:
104110

105111
fill-tests:
106112
name: Fill test fixtures - Python 3.14
107-
runs-on: ubuntu-latest
113+
runs-on: macos-latest
108114
steps:
109115
- name: Checkout leanSpec
110116
uses: actions/checkout@v4
@@ -130,7 +136,7 @@ jobs:
130136

131137
interop-tests:
132138
name: Interop tests - Multi-node consensus
133-
runs-on: ubuntu-latest
139+
runs-on: macos-latest
134140
timeout-minutes: 10
135141
steps:
136142
- name: Checkout leanSpec

Justfile

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,13 @@ test-consensus *args:
8181
uv run --group test pytest -n auto --maxprocesses=10 --durations=10 --dist=worksteal tests/lean_spec/subspecs/containers tests/lean_spec/subspecs/forkchoice tests/lean_spec/subspecs/networking "$@"
8282

8383
# Canonical CI fixture run; contributors should use `uv run fill` directly
84+
# Runs serially (no -n auto): xdist workers race on the lean_multisig_py
85+
# prover's process-global setup, which corrupts proofs intermittently.
86+
# This is an upstream limitation; restore -n auto once the prover setup
87+
# is per-process safe. See the matching note in .github/workflows/ci.yml.
8488
[group('tests'), private]
8589
fill-ci *args:
86-
uv run --group test fill --fork=Lstar --clean -n auto "$@"
90+
uv run --group test fill --fork=Lstar --clean "$@"
8791

8892
# Run API conformance tests against an external client
8993
[group('tests')]

packages/testing/src/consensus_testing/keys.py

Lines changed: 38 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,10 @@
4242

4343
from lean_spec.config import LEAN_ENV
4444
from lean_spec.forks.lstar.containers import AttestationData
45-
from lean_spec.forks.lstar.containers.block.types import (
46-
AggregatedAttestations,
47-
AttestationSignatures,
48-
)
45+
from lean_spec.forks.lstar.containers.block.types import AggregatedAttestations
4946
from lean_spec.subspecs.koalabear import Fp
5047
from lean_spec.subspecs.ssz.hash import hash_tree_root
51-
from lean_spec.subspecs.xmss.aggregation import AggregatedSignatureProof
48+
from lean_spec.subspecs.xmss.aggregation import TypeOneMultiSignature
5249
from lean_spec.subspecs.xmss.constants import TARGET_CONFIG
5350
from lean_spec.subspecs.xmss.containers import (
5451
PublicKey,
@@ -67,7 +64,13 @@
6764
HashTreeOpening,
6865
Randomness,
6966
)
70-
from lean_spec.types import Bytes32, Slot, Uint64, ValidatorIndex, ValidatorIndices
67+
from lean_spec.types import (
68+
Bytes32,
69+
Slot,
70+
Uint64,
71+
ValidatorIndex,
72+
ValidatorIndices,
73+
)
7174

7275
KeyRole = Literal["attestation", "proposal"]
7376
"""Discriminator for which signing role's key to load from a validator key pair."""
@@ -514,18 +517,21 @@ def sign_and_aggregate(
514517
self,
515518
validator_ids: list[ValidatorIndex],
516519
attestation_data: AttestationData,
517-
) -> AggregatedSignatureProof:
520+
) -> TypeOneMultiSignature:
518521
"""
519-
Sign attestation data with each validator and aggregate into a single proof.
522+
Sign attestation data with each validator and aggregate into a Type-1 proof.
520523
521-
Convenience method for the common sign-each-validator-then-aggregate pattern.
524+
Each validator's XMSS attestation key signs the attestation data
525+
root. The signatures are then handed to the multi-signature
526+
binding to produce a single cryptographically valid Type-1 proof
527+
binding all participants to (data, slot).
522528
523529
Args:
524530
validator_ids: Validators to sign with.
525531
attestation_data: The attestation data to sign.
526532
527533
Returns:
528-
Aggregated signature proof combining all validators' signatures.
534+
Cryptographically valid Type-1 proof covering validator_ids.
529535
"""
530536
raw_xmss = [
531537
(
@@ -534,46 +540,44 @@ def sign_and_aggregate(
534540
)
535541
for vid in validator_ids
536542
]
537-
538-
xmss_participants = ValidatorIndices(data=validator_ids).to_aggregation_bits()
539-
540-
return AggregatedSignatureProof.aggregate(
541-
xmss_participants=xmss_participants,
543+
return TypeOneMultiSignature.aggregate(
544+
xmss_participants=ValidatorIndices(data=validator_ids).to_aggregation_bits(),
542545
children=[],
543546
raw_xmss=raw_xmss,
544547
message=hash_tree_root(attestation_data),
545548
slot=attestation_data.slot,
546549
)
547550

548-
def build_attestation_signatures(
551+
def build_attestation_proofs(
549552
self,
550553
aggregated_attestations: AggregatedAttestations,
551554
signature_lookup: Mapping[AttestationData, Mapping[ValidatorIndex, Signature]]
552555
| None = None,
553-
) -> AttestationSignatures:
556+
) -> list[TypeOneMultiSignature]:
554557
"""
555-
Produce aggregated signature proofs for a list of attestations.
558+
Produce Type-1 proofs aligned with the given attestations.
556559
557560
For each aggregated attestation:
558561
559-
1. Identify participating validators from the aggregation bitfield
560-
2. Collect each participant's public key and individual signature
561-
3. Combine them into a single aggregated proof for the leanVM verifier
562+
1. Identify participating validators from the aggregation bitfield.
563+
2. Collect each participant's attestation public key and signature.
564+
3. Combine them into a single Type-1 single-message proof via the
565+
multi-signature binding.
562566
563567
Pre-computed signatures can be supplied via the lookup to avoid
564-
redundant signing. Missing signatures are computed on the fly.
568+
redundant signing. Missing entries are signed on the fly.
565569
566570
Args:
567571
aggregated_attestations: Attestations with aggregation bitfields set.
568572
signature_lookup: Optional pre-computed signatures keyed by
569573
attestation data then validator index.
570574
571575
Returns:
572-
One aggregated signature proof per attestation.
576+
One Type-1 single-message proof per attestation, parallel to the input.
573577
"""
574578
lookup = signature_lookup or {}
575579

576-
proofs: list[AggregatedSignatureProof] = []
580+
proofs: list[TypeOneMultiSignature] = []
577581
for agg in aggregated_attestations:
578582
# Decode which validators participated from the bitfield.
579583
validator_ids = agg.aggregation_bits.to_validator_indices()
@@ -582,7 +586,7 @@ def build_attestation_signatures(
582586
# Fall back to signing on the fly for any missing entries.
583587
sigs_for_data = lookup.get(agg.data, {})
584588

585-
# Collect the attestation public key for each participant.
589+
# Collect the attestation public keys for each participant.
586590
public_keys = [self.get_public_keys(vid)[0] for vid in validator_ids]
587591

588592
# Gather individual signatures, computing any that are missing.
@@ -593,16 +597,17 @@ def build_attestation_signatures(
593597

594598
# Produce a single aggregated proof that the leanVM can verify
595599
# in one pass over all participants.
596-
proof = AggregatedSignatureProof.aggregate(
597-
xmss_participants=agg.aggregation_bits,
598-
children=[],
599-
raw_xmss=list(zip(public_keys, signatures, strict=True)),
600-
message=hash_tree_root(agg.data),
601-
slot=agg.data.slot,
600+
proofs.append(
601+
TypeOneMultiSignature.aggregate(
602+
children=[],
603+
raw_xmss=list(zip(public_keys, signatures, strict=True)),
604+
xmss_participants=agg.aggregation_bits,
605+
message=hash_tree_root(agg.data),
606+
slot=agg.data.slot,
607+
)
602608
)
603-
proofs.append(proof)
604609

605-
return AttestationSignatures(data=proofs)
610+
return proofs
606611

607612

608613
def _generate_single_keypair(

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,7 @@ def make_fixture(self) -> Self:
300300
case BlockStep():
301301
# Build a complete signed block from the lightweight spec.
302302
# The spec contains minimal fields; we fill the rest.
303-
signed_block = step.block.build_signed_block_with_store(
303+
signed_block, store = step.block.build_signed_block_with_store(
304304
store, self._block_registry, key_manager, self.lean_env
305305
)
306306

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from lean_spec.forks.lstar.containers.state import State
1111
from lean_spec.forks.lstar.spec import LstarSpec
1212
from lean_spec.subspecs.ssz.hash import hash_tree_root
13-
from lean_spec.subspecs.xmss.aggregation import AggregatedSignatureProof
13+
from lean_spec.subspecs.xmss.aggregation import TypeOneMultiSignature
1414
from lean_spec.types import Bytes32, ValidatorIndices
1515

1616
from ..keys import XmssKeyManager
@@ -250,7 +250,7 @@ def _build_block_from_spec(
250250

251251
# Path 3: normal block construction via the spec's builder.
252252
else:
253-
aggregated_payloads: dict[AttestationData, set[AggregatedSignatureProof]] = {}
253+
aggregated_payloads: dict[AttestationData, set[TypeOneMultiSignature]] = {}
254254
if spec.attestations:
255255
aggregated_payloads = StateTransitionTest._build_aggregated_payloads_from_spec(
256256
spec.attestations, state, block_registry
@@ -304,7 +304,7 @@ def _build_aggregated_payloads_from_spec(
304304
attestation_specs: list[AggregatedAttestationSpec],
305305
state: State,
306306
block_registry: dict[str, Block],
307-
) -> dict[AttestationData, set[AggregatedSignatureProof]]:
307+
) -> dict[AttestationData, set[TypeOneMultiSignature]]:
308308
"""
309309
Build aggregated signature payloads from attestation specifications.
310310
@@ -320,7 +320,7 @@ def _build_aggregated_payloads_from_spec(
320320
# XMSS keys require precomputation up to the highest slot used.
321321
max_slot = max(spec.slot for spec in attestation_specs)
322322
key_manager = XmssKeyManager.shared(max_slot=max_slot)
323-
payloads: dict[AttestationData, set[AggregatedSignatureProof]] = {}
323+
payloads: dict[AttestationData, set[TypeOneMultiSignature]] = {}
324324

325325
for spec in attestation_specs:
326326
if not spec.valid_signature:

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

Lines changed: 60 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,20 @@
66

77
from pydantic import Field
88

9-
from lean_spec.forks.lstar.containers.attestation import AggregatedAttestation
10-
from lean_spec.forks.lstar.containers.block import (
11-
SignedBlock,
12-
)
13-
from lean_spec.forks.lstar.containers.block.types import (
14-
AggregatedAttestations,
15-
AttestationSignatures,
16-
)
9+
from lean_spec.forks.lstar.containers.attestation import AggregatedAttestation, AttestationData
10+
from lean_spec.forks.lstar.containers.block import SignedBlock
11+
from lean_spec.forks.lstar.containers.block.types import AggregatedAttestations
1712
from lean_spec.forks.lstar.containers.state import State
1813
from lean_spec.forks.lstar.spec import LstarSpec
19-
from lean_spec.types import AggregationBits, Boolean, ValidatorIndex
14+
from lean_spec.types import (
15+
AggregationBits,
16+
Boolean,
17+
ByteList512KiB,
18+
Bytes32,
19+
Checkpoint,
20+
Slot,
21+
ValidatorIndex,
22+
)
2023

2124
from ..keys import XmssKeyManager
2225
from ..test_types import BlockSpec
@@ -60,10 +63,6 @@ class VerifySignaturesTest(BaseConsensusFixture):
6063
6164
Supported operations:
6265
63-
- `{"operation": "drop_last_signature"}`: Remove the last entry
64-
from the block's attestation_signatures list. Produces a signed
65-
block whose signature-group count is one less than its
66-
attestation count.
6766
- `{"operation": "set_proposer_index", "value": int}`: Rewrite
6867
the block's proposer_index field. Use this to exercise the
6968
validator-bounds check that the builder skips because its round-
@@ -72,6 +71,15 @@ class VerifySignaturesTest(BaseConsensusFixture):
7271
first body attestation with one whose aggregation_bits carry no
7372
set bit. Exercises the empty-participants check inside
7473
signature verification.
74+
- `{"operation": "corrupt_proof"}`: Replace the merged proof with
75+
a short non-decodable blob. Exercises the Type-2 decode check.
76+
- `{"operation": "append_phantom_attestation"}`: Add a body
77+
attestation with no matching proof component. Exercises the
78+
component count check between the body and the merged proof.
79+
- `{"operation": "mutate_state_root"}`: Change a block field after
80+
signing so the block root differs. Exercises the per-component
81+
message binding that prevents reusing an honest proof under a
82+
different message.
7583
7684
Tampered blocks bypass the builder's structural invariants. The
7785
resulting fixture pins the exact rejection a client must raise when
@@ -153,16 +161,6 @@ def _apply_tamper(self, signed_block: SignedBlock) -> SignedBlock:
153161
assert self.tamper is not None
154162
operation = self.tamper.get("operation")
155163

156-
if operation == "drop_last_signature":
157-
original = signed_block.signature.attestation_signatures.data
158-
if not original:
159-
raise ValueError("drop_last_signature requires at least one attestation signature")
160-
truncated = AttestationSignatures(data=list(original[:-1]))
161-
tampered_signatures = signed_block.signature.model_copy(
162-
update={"attestation_signatures": truncated}
163-
)
164-
return signed_block.model_copy(update={"signature": tampered_signatures})
165-
166164
if operation == "set_proposer_index":
167165
value = self.tamper.get("value")
168166
if value is None:
@@ -185,4 +183,43 @@ def _apply_tamper(self, signed_block: SignedBlock) -> SignedBlock:
185183
new_block = signed_block.block.model_copy(update={"body": new_body})
186184
return signed_block.model_copy(update={"block": new_block})
187185

186+
if operation == "corrupt_proof":
187+
# Replace the merged proof with a short non-decodable blob.
188+
# Decoding the Type-2 envelope must fail before verification.
189+
return signed_block.model_copy(
190+
update={"proof": ByteList512KiB(data=b"\x00\x01\x02\x03")}
191+
)
192+
193+
if operation == "append_phantom_attestation":
194+
# Add a body attestation with no matching proof component.
195+
# The proof binds one component per original attestation plus
196+
# the proposer, so the body now claims more components than the
197+
# proof carries.
198+
body = signed_block.block.body
199+
phantom_data = AttestationData(
200+
slot=Slot(0),
201+
head=Checkpoint(root=Bytes32(b"\x00" * 32), slot=Slot(0)),
202+
target=Checkpoint(root=Bytes32(b"\x00" * 32), slot=Slot(0)),
203+
source=Checkpoint(root=Bytes32(b"\x00" * 32), slot=Slot(0)),
204+
)
205+
phantom = AggregatedAttestation(
206+
aggregation_bits=AggregationBits(data=[Boolean(True)]),
207+
data=phantom_data,
208+
)
209+
new_attestations = AggregatedAttestations(data=[*body.attestations.data, phantom])
210+
new_body = body.model_copy(update={"attestations": new_attestations})
211+
new_block = signed_block.block.model_copy(update={"body": new_body})
212+
return signed_block.model_copy(update={"block": new_block})
213+
214+
if operation == "mutate_state_root":
215+
# Change a block field after signing so the block root differs.
216+
# The proposer component's bound message no longer matches the
217+
# recomputed block root, even though the signature is honest.
218+
# This is the repackaging vector: an honest proof reused under
219+
# a different message.
220+
tampered_block = signed_block.block.model_copy(
221+
update={"state_root": Bytes32(b"\xff" * 32)}
222+
)
223+
return signed_block.model_copy(update={"block": tampered_block})
224+
188225
raise ValueError(f"Unknown tamper operation: {operation!r}")

0 commit comments

Comments
 (0)