Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -168,3 +168,6 @@ scripts/

# Client codebase used by Claude skill for running reference tests
clients/

# Local agent worktrees
.claude/worktrees/
6 changes: 2 additions & 4 deletions packages/testing/src/consensus_testing/keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,6 @@
Slot,
Uint64,
ValidatorIndex,
ValidatorIndices,
)

KeyRole = Literal["attestation", "proposal"]
Expand Down Expand Up @@ -534,13 +533,13 @@ def sign_and_aggregate(
"""
raw_xmss = [
(
vid,
self.get_public_keys(vid)[0],
self.sign_attestation_data(vid, attestation_data),
)
for vid in validator_ids
]
return TypeOneMultiSignature.aggregate(
xmss_participants=ValidatorIndices(data=validator_ids).to_aggregation_bits(),
children=[],
raw_xmss=raw_xmss,
message=hash_tree_root(attestation_data),
Expand Down Expand Up @@ -599,8 +598,7 @@ def build_attestation_proofs(
proofs.append(
TypeOneMultiSignature.aggregate(
children=[],
raw_xmss=list(zip(public_keys, signatures, strict=True)),
xmss_participants=agg.aggregation_bits,
raw_xmss=list(zip(validator_ids, public_keys, signatures, strict=True)),
message=hash_tree_root(agg.data),
slot=agg.data.slot,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,6 @@ def _sign_block(
Complete signed block.
"""
block_root = hash_tree_root(final_block)
proposer_participants = ValidatorIndices(data=[proposer_index]).to_aggregation_bits()
proposer_pubkey = key_manager.get_public_keys(proposer_index)[1]

# The binding rejects placeholder bytes; if anything in the merged
Expand All @@ -295,8 +294,7 @@ def _sign_block(
)
proposer_type_1 = TypeOneMultiSignature.aggregate(
children=[],
raw_xmss=[(proposer_pubkey, proposer_signature)],
xmss_participants=proposer_participants,
raw_xmss=[(proposer_index, proposer_pubkey, proposer_signature)],
message=block_root,
slot=self.slot,
)
Expand Down
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,14 @@ known-first-party = ["lean_spec"]

[tool.ruff.lint.per-file-ignores]
"tests/**" = ["D"]
"src/lean_spec/subspecs/xmss/constants.py" = ["N802"]

[tool.ty.environment]
python-version = "3.12"

[tool.ty.src]
exclude = [".claude/"]

[tool.ty.terminal]
error-on-warning = true

Expand Down
51 changes: 51 additions & 0 deletions src/lean_spec/forks/lstar/aggregation_select.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""Greedy proof selection for lstar block production."""

from lean_spec.subspecs.xmss.aggregation import TypeOneMultiSignature
from lean_spec.types import ValidatorIndex


def select_greedily(
*proof_sets: set[TypeOneMultiSignature] | None,
) -> tuple[list[TypeOneMultiSignature], set[ValidatorIndex]]:
"""
Greedy set-cover over Type-1 proofs maximizing validator coverage.

Iterates the proof sets in order, repeatedly picking the proof with the
most uncovered validators until no further coverage is possible.
Earlier proof sets are prioritized so gossip-fresh proofs win over
already-known ones.

The validator-index sets are materialized once per proof, not inside the
inner max key, so the loop runs in O(P * V) instead of O(P^2 * V).

Args:
*proof_sets: One or more sets of Type-1 proofs, ordered by priority.
None entries are skipped.

Returns:
The chosen proofs and the union of validator indices they cover.
"""
selected: list[TypeOneMultiSignature] = []
covered: set[ValidatorIndex] = set()

for proofs in proof_sets:
if not proofs:
continue

# Materialize each proof's validator index set once.
# The greedy loop below would otherwise recompute it on every comparison.
coverage_of: dict[TypeOneMultiSignature, set[ValidatorIndex]] = {
p: set(p.participants.to_validator_indices()) for p in proofs
}
remaining = list(proofs)

while remaining:
best = max(remaining, key=lambda p: len(coverage_of[p] - covered))
new_coverage = coverage_of[best] - covered
if not new_coverage:
break
selected.append(best)
covered |= new_coverage
remaining.remove(best)

return selected, covered
24 changes: 5 additions & 19 deletions src/lean_spec/forks/lstar/spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from collections.abc import Iterable, Sequence, Set as AbstractSet
from typing import Any, ClassVar

from lean_spec.forks.lstar.aggregation_select import select_greedily
from lean_spec.forks.lstar.containers import (
AggregatedAttestation,
AttestationData,
Expand Down Expand Up @@ -55,7 +56,6 @@
Uint8,
Uint64,
ValidatorIndex,
ValidatorIndices,
)

from ..protocol import ForkProtocol, SpecBlockType, SpecStateType
Expand Down Expand Up @@ -763,7 +763,7 @@ def build_block(

found_entries = True

selected, _ = TypeOneMultiSignature.select_greedily(proofs)
selected, _ = select_greedily(proofs)
aggregated_signatures.extend(selected)
for proof in selected:
aggregated_attestations.append(
Expand Down Expand Up @@ -836,7 +836,6 @@ def build_block(
for proof in proofs
]
sig = TypeOneMultiSignature.aggregate(
xmss_participants=None,
children=children,
raw_xmss=[],
message=hash_tree_root(att_data),
Expand Down Expand Up @@ -1661,9 +1660,7 @@ def aggregate(self, store: LstarStore) -> tuple[LstarStore, list[SignedAggregate
# New payloads go first because they represent uncommitted
# work — known payloads fill remaining gaps.

child_proofs, covered = TypeOneMultiSignature.select_greedily(
new.get(data), known.get(data)
)
child_proofs, covered = select_greedily(new.get(data), known.get(data))

# Phase 2: Fill
#
Expand All @@ -1689,17 +1686,6 @@ def aggregate(self, store: LstarStore) -> tuple[LstarStore, list[SignedAggregate
if not raw_entries and len(child_proofs) < 2:
continue

# Encode raw signers as a compact bitfield when present.
# Child-only aggregation (no raw signatures) must pass None.
if raw_entries:
xmss_participants = ValidatorIndices(
data=[vid for vid, _, _ in raw_entries]
).to_aggregation_bits()
raw_xmss = [(pk, sig) for _, pk, sig in raw_entries]
else:
xmss_participants = None
raw_xmss = []

# Phase 3: Aggregate
#
# Build the recursive proof tree.
Expand All @@ -1719,11 +1705,11 @@ def aggregate(self, store: LstarStore) -> tuple[LstarStore, list[SignedAggregate
]

# Hand everything to the XMSS subspec.
# Each fresh entry already carries its validator index alongside its key and signature.
# Out comes a single proof covering all selected validators.
proof = TypeOneMultiSignature.aggregate(
xmss_participants=xmss_participants,
children=children,
raw_xmss=raw_xmss,
raw_xmss=raw_entries,
message=hash_tree_root(data),
slot=data.slot,
)
Expand Down
7 changes: 3 additions & 4 deletions src/lean_spec/subspecs/sync/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -617,13 +617,13 @@ def _deconstruct_block_into_store(
continue

try:
# The split takes the bits from the block attestation this
# component binds, since the Rust binding does not return them.
block_t1 = type_two.split_by_msg(
message=data_root,
public_keys_per_message=public_keys_per_message,
participants=att.aggregation_bits,
)
# split_by_msg returns an empty participant bitfield; restore
# the bits from the block attestation this component binds.
block_t1 = block_t1.model_copy(update={"participants": att.aggregation_bits})

if local_proofs:
combined = TypeOneMultiSignature.aggregate(
Expand All @@ -638,7 +638,6 @@ def _deconstruct_block_into_store(
for child in (block_t1, *local_proofs)
],
raw_xmss=[],
xmss_participants=None,
message=data_root,
slot=data.slot,
)
Expand Down
8 changes: 3 additions & 5 deletions src/lean_spec/subspecs/validator/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
from lean_spec.subspecs.xmss import TARGET_SIGNATURE_SCHEME
from lean_spec.subspecs.xmss.aggregation import TypeOneMultiSignature, TypeTwoMultiSignature
from lean_spec.subspecs.xmss.containers import PublicKey, Signature
from lean_spec.types import ByteList512KiB, Bytes32, Slot, Uint64, ValidatorIndex, ValidatorIndices
from lean_spec.types import ByteList512KiB, Bytes32, Slot, Uint64, ValidatorIndex

from .constants import HYSTERESIS_BAND, NETWORK_STALL_THRESHOLD, SYNC_LAG_THRESHOLD
from .registry import ValidatorEntry, ValidatorRegistry
Expand Down Expand Up @@ -447,12 +447,10 @@ def _sign_block(
proposer_pubkey = validators[validator_index].get_proposal_pubkey()

# Wrap the proposer's raw XMSS signature into a singleton Type-1.
# The participant set is just the proposer index.
proposer_participants = ValidatorIndices(data=[validator_index]).to_aggregation_bits()
# The single fresh entry carries the proposer index alongside its key and signature.
proposer_type_1 = TypeOneMultiSignature.aggregate(
children=[],
raw_xmss=[(proposer_pubkey, proposer_signature)],
xmss_participants=proposer_participants,
raw_xmss=[(validator_index, proposer_pubkey, proposer_signature)],
message=block_root,
slot=block.slot,
)
Expand Down
10 changes: 6 additions & 4 deletions src/lean_spec/subspecs/xmss/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
"""
This package provides a Python specification for the Generalized XMSS
hash-based signature scheme.
"""Generalized XMSS hash-based signature scheme.
It exposes the core data structures and the main interface functions.
References:
- Hash-Based Multi-Signatures for Post-Quantum Ethereum.
https://eprint.iacr.org/2025/055.pdf
- Aborting Random Oracles, How to Build Them, How to Use Them.
https://eprint.iacr.org/2026/016.pdf
"""

from .containers import PublicKey, SecretKey
Expand Down
Loading
Loading