Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .codespell-ignore-words.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ subspecs
crate
crates
ethereum
poseidon2
poseidon1
keccak
blake
merkle
Expand Down
5 changes: 3 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -119,10 +119,10 @@ jobs:
- name: Checkout leanSpec
uses: actions/checkout@v4

- name: Set up Python 3.12
- name: Set up Python 3.14
uses: actions/setup-python@v5
with:
python-version: "3.12"
python-version: "3.14"

- name: Install uv
uses: astral-sh/setup-uv@v4
Expand All @@ -134,6 +134,7 @@ jobs:
run: |
uv run pytest tests/interop/ \
-v \
--no-cov \
--timeout=120 \
-x \
--tb=short \
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
)
from lean_spec.subspecs.containers.block.types import (
AggregatedAttestations,
AttestationSignatures,
)
from lean_spec.subspecs.containers.checkpoint import Checkpoint
from lean_spec.subspecs.containers.slot import Slot
Expand Down Expand Up @@ -456,23 +457,14 @@ def _build_block_from_spec(
#
# block_payloads contains explicit spec attestations only.
parent_state = store.states[parent_root]
final_block, post_state, _, _ = parent_state.build_block(
final_block, post_state, _, block_proofs = parent_state.build_block(
slot=spec.slot,
proposer_index=proposer_index,
parent_root=parent_root,
known_block_roots=set(store.blocks.keys()),
aggregated_payloads=merged_store.latest_known_aggregated_payloads,
)

# Sign everything
#
# Aggregate signatures for attestations in the block body.
# Sign the block root with the proposer's proposal key.
attestation_signatures_blob = key_manager.build_attestation_signatures(
final_block.body.attestations,
attestation_signatures,
)

proposer_signature = key_manager.sign_block_root(
proposer_index,
spec.slot,
Expand All @@ -483,7 +475,7 @@ def _build_block_from_spec(
return SignedBlock(
block=final_block,
signature=BlockSignatures(
attestation_signatures=attestation_signatures_blob,
attestation_signatures=AttestationSignatures(data=block_proofs),
proposer_signature=proposer_signature,
),
)
Expand Down

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

10 changes: 6 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ requires-python = ">=3.12"
dependencies = [
"pydantic>=2.12.0,<3",
"typing-extensions>=4.4",
"lean-multisig-py>=0.1.0",
"lean-multisig-py>=0.0.1",
"httpx>=0.28.0,<1",
"aiohttp>=3.11.0,<4",
"cryptography>=46.0.0",
Expand Down Expand Up @@ -117,6 +117,8 @@ timeout = 300
[tool.coverage.run]
source = ["src"]
branch = true

[tool.coverage.report]
fail_under = 80

[tool.mdformat]
Expand All @@ -131,18 +133,18 @@ members = ["packages/*"]

[tool.uv.sources]
lean-ethereum-testing = { workspace = true }
lean-multisig-py = { git = "https://github.com/anshalshukla/leanMultisig-py", branch = "devnet2" }
lean-multisig-py = { git = "https://github.com/anshalshukla/leanMultisig-py", branch = "devnet4" }

[dependency-groups]
test = [
"pytest>=8.3.3,<9",
"pytest-cov>=6.0.0,<7",
"pytest-xdist>=3.6.1,<4",
"pytest-asyncio>=0.24.0,<1",
"pytest-asyncio>=1.0.0",
"pytest-timeout>=2.2.0,<3",
"hypothesis>=6.138.14",
"lean-ethereum-testing",
"lean-multisig-py>=0.1.0",
"lean-multisig-py>=0.0.1",
"pycryptodome>=3.20.0,<4",
]
lint = [
Expand Down
2 changes: 1 addition & 1 deletion src/lean_spec/subspecs/containers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
efficient merkleization.
Hash functions used for merkleization differ by devnet. Early devnets use
SHA256. Later devnets will switch to Poseidon2 for better SNARK compatibility.
SHA256. Later devnets will switch to Poseidon1 for better SNARK compatibility.
"""

from .attestation import (
Expand Down
112 changes: 40 additions & 72 deletions src/lean_spec/subspecs/containers/state/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -774,7 +774,6 @@ def aggregate(
attestation_signatures: dict[AttestationData, set[AttestationSignatureEntry]] | None = None,
new_payloads: dict[AttestationData, set[AggregatedSignatureProof]] | None = None,
known_payloads: dict[AttestationData, set[AggregatedSignatureProof]] | None = None,
recursive: bool = False,
) -> list[tuple[AggregatedAttestation, AggregatedSignatureProof]]:
"""
Aggregate gossip signatures using new payloads, with known payloads as helpers.
Expand All @@ -783,9 +782,6 @@ def aggregate(
attestation_signatures: Raw XMSS signatures from gossip, keyed by attestation data.
new_payloads: Aggregated proofs pending processing (child proofs).
known_payloads: Known aggregated proofs already accepted.
recursive: If True, greedily select child proofs from new/known payloads to
extend aggregation. If False, only attestation_signatures are used (bindings
do not support recursive aggregation yet).

Returns:
List of (attestation, proof) pairs from aggregation.
Expand All @@ -795,75 +791,47 @@ def aggregate(
new_payloads = new_payloads or {}
known_payloads = known_payloads or {}

if recursive:
# Recursive aggregation: extend with child proofs from new/known payloads.
# Once bindings support recursive aggregation, keep this path and remove the
# plain block below.
attestation_keys = set(new_payloads.keys()) | set(gossip_signatures.keys())
if not attestation_keys:
return results
for data in attestation_keys:
child_proofs: list[AggregatedSignatureProof] = []
covered_validators: set[ValidatorIndex] = set()
self._extend_proofs_greedily(
new_payloads.get(data), child_proofs, covered_validators
)
self._extend_proofs_greedily(
known_payloads.get(data), child_proofs, covered_validators
)
raw_entries = []
for entry in sorted(
gossip_signatures.get(data, set()), key=lambda e: e.validator_id
):
if entry.validator_id in covered_validators:
continue
public_key = self.validators[entry.validator_id].get_attestation_pubkey()
raw_entries.append((entry.validator_id, public_key, entry.signature))
covered_validators.add(entry.validator_id)
if not raw_entries and len(child_proofs) < 2:
continue
raw_entries = sorted(raw_entries, key=lambda e: e[0])
raw_xmss = [(pk, sig) for _, pk, sig in raw_entries]
xmss_participants = AggregationBits.from_validator_indices(
ValidatorIndices(data=[e[0] for e in raw_entries])
)
proof = AggregatedSignatureProof.aggregate(
xmss_participants=xmss_participants,
children=child_proofs,
raw_xmss=raw_xmss,
message=data.data_root_bytes(),
slot=data.slot,
)
attestation = AggregatedAttestation(aggregation_bits=proof.participants, data=data)
results.append((attestation, proof))
else:
# Plain aggregation: only attestation_signatures. Remove this block once
# bindings support recursive aggregation.
attestation_keys = set(gossip_signatures.keys())
if not attestation_keys:
return results
for data in attestation_keys:
raw_entries = []
for entry in sorted(
gossip_signatures.get(data, set()), key=lambda e: e.validator_id
):
public_key = self.validators[entry.validator_id].get_attestation_pubkey()
raw_entries.append((entry.validator_id, public_key, entry.signature))
if not raw_entries:
attestation_keys = set(new_payloads.keys()) | set(gossip_signatures.keys())
if not attestation_keys:
return results
for data in attestation_keys:
child_proofs: list[AggregatedSignatureProof] = []
covered_validators: set[ValidatorIndex] = set()
self._extend_proofs_greedily(new_payloads.get(data), child_proofs, covered_validators)
self._extend_proofs_greedily(known_payloads.get(data), child_proofs, covered_validators)
raw_entries = []
for entry in sorted(gossip_signatures.get(data, set()), key=lambda e: e.validator_id):
if entry.validator_id in covered_validators:
continue
raw_entries = sorted(raw_entries, key=lambda e: e[0])
raw_xmss = [(pk, sig) for _, pk, sig in raw_entries]
xmss_participants = AggregationBits.from_validator_indices(
ValidatorIndices(data=[e[0] for e in raw_entries])
)
proof = AggregatedSignatureProof.aggregate(
xmss_participants=xmss_participants,
children=[],
raw_xmss=raw_xmss,
message=data.data_root_bytes(),
slot=data.slot,
public_key = self.validators[entry.validator_id].get_attestation_pubkey()
raw_entries.append((entry.validator_id, public_key, entry.signature))
covered_validators.add(entry.validator_id)
if not raw_entries and len(child_proofs) < 2:
continue
raw_entries = sorted(raw_entries, key=lambda e: e[0])
raw_xmss = [(pk, sig) for _, pk, sig in raw_entries]
xmss_participants = AggregationBits.from_validator_indices(
ValidatorIndices(data=[e[0] for e in raw_entries])
)

children = [
(
child,
[
self.validators[vid].get_attestation_pubkey()
for vid in child.participants.to_validator_indices()
],
)
attestation = AggregatedAttestation(aggregation_bits=proof.participants, data=data)
results.append((attestation, proof))
for child in child_proofs
]
proof = AggregatedSignatureProof.aggregate(
xmss_participants=xmss_participants,
children=children,
raw_xmss=raw_xmss,
message=data.data_root_bytes(),
slot=data.slot,
)
attestation = AggregatedAttestation(aggregation_bits=proof.participants, data=data)
results.append((attestation, proof))

return results
94 changes: 25 additions & 69 deletions src/lean_spec/subspecs/forkchoice/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -919,86 +919,42 @@ def update_safe_target(self) -> "Store":
# The head and attestation pools remain unchanged.
return self.model_copy(update={"safe_target": safe_target})

def aggregate(
self, recursive: bool = False
) -> tuple["Store", list[SignedAggregatedAttestation]]:
def aggregate(self) -> tuple["Store", list[SignedAggregatedAttestation]]:
"""
Aggregate committee signatures and payloads together.

This method aggregates signatures from the attestation_signatures map.

Args:
recursive: When True, previously produced payloads are only used as inputs
during recursive aggregation and are not carried forward to the next interval.

Returns:
Tuple of (new Store with updated payloads, list of new SignedAggregatedAttestation).
"""
head_state = self.states[self.head]

if recursive:
# Recursive aggregation: state uses new/known payloads; do not carry
# forward existing new payloads. Once bindings support recursive aggregation,
# keep this path and remove the else block below.
aggregated_results = head_state.aggregate(
attestation_signatures=self.attestation_signatures,
new_payloads=self.latest_new_aggregated_payloads,
known_payloads=self.latest_known_aggregated_payloads,
recursive=True,
)
new_aggregates: list[SignedAggregatedAttestation] = []
new_aggregated_payloads: dict[AttestationData, set[AggregatedSignatureProof]] = {}
aggregated_attestation_data: set[AttestationData] = set()
for att, proof in aggregated_results:
aggregated_attestation_data.add(att.data)
new_aggregates.append(SignedAggregatedAttestation(data=att.data, proof=proof))
new_aggregated_payloads.setdefault(att.data, set()).add(proof)
remaining_attestation_signatures = {
attestation_data: signatures
for attestation_data, signatures in self.attestation_signatures.items()
if attestation_data not in aggregated_attestation_data
}
return self.model_copy(
update={
"latest_new_aggregated_payloads": new_aggregated_payloads,
"attestation_signatures": remaining_attestation_signatures,
}
), new_aggregates
else:
# Plain aggregation: only attestation_signatures. TODO: Remove this block once
# bindings support recursive aggregation.
#
# Freshly aggregated proofs go directly to latest_known because they are
# our own aggregation output and can be used immediately for block building
# and fork choice. Proofs from on_gossip_aggregated_attestation remain in
# latest_new until accepted at interval 4.
aggregated_results = head_state.aggregate(
attestation_signatures=self.attestation_signatures,
new_payloads=None,
known_payloads=None,
recursive=False,
)
new_aggregates = []
new_aggregated_payloads = {
attestation_data: set(proofs)
for attestation_data, proofs in self.latest_known_aggregated_payloads.items()
}
aggregated_attestation_data = set()
for att, proof in aggregated_results:
aggregated_attestation_data.add(att.data)
new_aggregates.append(SignedAggregatedAttestation(data=att.data, proof=proof))
new_aggregated_payloads.setdefault(att.data, set()).add(proof)
remaining_attestation_signatures = {
attestation_data: signatures
for attestation_data, signatures in self.attestation_signatures.items()
if attestation_data not in aggregated_attestation_data
aggregated_results = head_state.aggregate(
attestation_signatures=self.attestation_signatures,
new_payloads=self.latest_new_aggregated_payloads,
known_payloads=self.latest_known_aggregated_payloads,
)

new_aggregates: list[SignedAggregatedAttestation] = []
new_aggregated_payloads: dict[AttestationData, set[AggregatedSignatureProof]] = {}
aggregated_attestation_data: set[AttestationData] = set()
for att, proof in aggregated_results:
aggregated_attestation_data.add(att.data)
new_aggregates.append(SignedAggregatedAttestation(data=att.data, proof=proof))
new_aggregated_payloads.setdefault(att.data, set()).add(proof)
remaining_attestation_signatures = {
attestation_data: signatures
for attestation_data, signatures in self.attestation_signatures.items()
if attestation_data not in aggregated_attestation_data
}

return self.model_copy(
update={
"latest_new_aggregated_payloads": new_aggregated_payloads,
"attestation_signatures": remaining_attestation_signatures,
}
return self.model_copy(
update={
"latest_new_aggregated_payloads": new_aggregated_payloads,
"attestation_signatures": remaining_attestation_signatures,
}
), new_aggregates
), new_aggregates

def tick_interval(
self, has_proposal: bool, is_aggregator: bool = False
Expand Down
2 changes: 1 addition & 1 deletion src/lean_spec/subspecs/networking/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
GOSSIPSUB_PROTOCOL_ID_V12: Final = ProtocolId("/meshsub/1.2.0")
"""Gossipsub v1.2 protocol ID - IDONTWANT bandwidth optimization."""

GOSSIPSUB_DEFAULT_PROTOCOL_ID: Final = GOSSIPSUB_PROTOCOL_ID_V11
GOSSIPSUB_DEFAULT_PROTOCOL_ID: Final = GOSSIPSUB_PROTOCOL_ID_V12
"""
Default protocol ID per Ethereum consensus spec requirements.
Expand Down
15 changes: 15 additions & 0 deletions src/lean_spec/subspecs/poseidon1/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"""Specification for the Poseidon1 permutation."""

from .permutation import (
PARAMS_16,
PARAMS_24,
Poseidon1,
Poseidon1Params,
)

__all__ = [
"Poseidon1",
"Poseidon1Params",
"PARAMS_16",
"PARAMS_24",
]
Loading
Loading