Skip to content

Commit 34f9c85

Browse files
authored
Integrate xmss (leanEthereum#117)
* feat: integrate xmss signatures * fix: non deterministic tests * fix: address comments * fix: address comments * feat: add xmss caching * fix: lint * feat: parallelize fill test fixtures
1 parent e734af7 commit 34f9c85

10 files changed

Lines changed: 312 additions & 41 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,4 +76,4 @@ jobs:
7676
run: uv sync --no-progress
7777

7878
- name: Fill test fixtures
79-
run: uv run fill --fork=Devnet --clean
79+
run: uv run fill --fork=Devnet --clean -n auto

CLAUDE.md

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,17 @@ subspecifications that the Lean Ethereum protocol relies on.
1616
## Development Workflow
1717

1818
### Running Tests
19+
1920
```bash
2021
uv sync # Install dependencies
2122
uv run pytest # Run unit tests
22-
uv run fill --fork=devnet --clean # Generate test vectors
23+
uv run fill --fork=devnet --clean -n auto # Generate test vectors
2324
# Note: execution layer support is planned for future, infrastructure is ready
2425
# for now, `--layer=consensus` is default and the only value used.
2526
```
2627

2728
### Code Quality
29+
2830
```bash
2931
uv run ruff format src tests packages # Format code
3032
uv run ruff check --fix src tests packages # Lint and fix
@@ -34,13 +36,15 @@ uvx tox # Everything (checks + tests + docs)
3436
```
3537

3638
### Common Tasks
39+
3740
- **Main specs**: `src/lean_spec/`
3841
- **Subspecs**: `src/lean_spec/subspecs/{subspec}/`
3942
- **Unit tests**: `tests/lean_spec/` (mirrors source structure)
4043
- **Consensus spec tests**: `tests/consensus/` (generates test vectors)
4144
- **Execution spec tests**: `tests/execution/` (future - infrastructure ready)
4245

4346
## Code Style
47+
4448
- Line length: 100 characters, type hints everywhere
4549
- Google docstring style (no docstrings for `__init__`)
4650
- Test files/functions must start with `test_`
@@ -54,15 +58,17 @@ uvx tox # Everything (checks + tests + docs)
5458
- *Note: `tests/execution/` infrastructure is ready for future execution layer work*
5559

5660
**Test Filling Framework:**
61+
5762
- Layer-agnostic pytest plugin in `packages/testing/src/framework/pytest_plugins/filler.py`
5863
- Layer-specific packages: `consensus_testing` (active) and `execution_testing` (future)
5964
- Write consensus spec tests using `state_transition_test` or `fork_choice_test` fixtures
6065
- These fixtures are type aliases that create test vectors when called
61-
- Run `uv run fill --fork=Devnet --clean` to generate consensus fixtures
66+
- Run `uv run fill --fork=Devnet --clean -n auto` to generate consensus fixtures
6267
- Use `--layer=execution` flag when execution layer is implemented
6368
- Output goes to `fixtures/{layer}/{format}/{test_path}/...`
6469

6570
**Example spec test:**
71+
6672
```python
6773
def test_block(state_transition_test: StateTransitionTestFiller) -> None:
6874
state_transition_test(
@@ -73,6 +79,7 @@ def test_block(state_transition_test: StateTransitionTestFiller) -> None:
7379
```
7480

7581
**How it works:**
82+
7683
1. Test function receives a fixture class (not instance) as parameter
7784
2. Calling it creates a `FixtureWrapper` that runs `make_fixture()`
7885
3. `make_fixture()` executes the spec code (state transitions, fork choice steps)
@@ -81,12 +88,14 @@ def test_block(state_transition_test: StateTransitionTestFiller) -> None:
8188
6. Writes fixtures at session end to `fixtures/{layer}/{format}/{test_path}/...`
8289

8390
**Layer-specific architecture:**
91+
8492
- `framework/` - Shared infrastructure (base classes, pytest plugin, CLI)
8593
- `consensus_testing/` - Consensus layer fixtures, forks, builders
8694
- `execution_testing/` - Execution layer fixtures, forks, builders
8795
- Regular pytest runs (`uv run pytest`) ignore spec tests - they only run via `fill` command
8896

8997
**Serialization requirements:**
98+
9099
- All spec types (State, Block, Uint64, etc.) must be Pydantic models
91100
- Custom types need `@field_serializer` or `model_serializer` for JSON output
92101
- SSZ types typically serialize to hex strings (e.g., `"0x1234..."`)
@@ -97,6 +106,7 @@ def test_block(state_transition_test: StateTransitionTestFiller) -> None:
97106
- Test the serialization: `fixture.model_dump(mode="json")` must produce valid JSON
98107

99108
**Key fixture types:**
109+
100110
- `StateTransitionTest` - Tests state transitions with blocks
101111
- `ForkChoiceTest` - Tests fork choice with steps (tick/block/attestation)
102112
- Selective validation via `StateExpectation` and `StoreChecks` (only validates fields you specify)
@@ -113,18 +123,21 @@ def test_block(state_transition_test: StateTransitionTestFiller) -> None:
113123
When creating SSZ types, follow these established patterns:
114124

115125
### Domain-Specific Types (Preferred)
126+
116127
- Use meaningful names that describe the purpose: `JustificationValidators`, `HistoricalBlockHashes`, `Attestations`
117128
- Define domain-specific types in modular structure (see Architecture section below)
118129
- Avoid generic names with numbers like `Bitlist68719476736` or `SignedAttestationList4096`
119130

120131
### SSZType vs SSZModel Design Decision
121132

122133
**SSZType (IS-A pattern)**: Use for types that *are* data
134+
123135
- Primitive scalars: `Uint64`, `Boolean`, `Bytes32`
124136
- These inherit directly from their underlying Python types
125137
- Example: `Uint64(42)` *is* the integer 42 with SSZ serialization
126138

127139
**SSZModel (HAS-A pattern)**: Use for types that *have* data
140+
128141
- Collections: `SSZList`, `SSZVector`, bitfields
129142
- Containers: `State`, `Block`, etc.
130143
- These use Pydantic models with a `data` field for contents
@@ -150,6 +163,7 @@ src/lean_spec/subspecs/containers/
150163
```
151164

152165
**Key principles:**
166+
153167
- **Base types** (BaseBitlist, SSZList, etc.) stay in general scope (`src/lean_spec/types/`)
154168
- **Spec-specific types** go in their respective modules (`state/types.py`, `block/types.py`)
155169
- **Public API** exposed through `__init__.py` files for backward compatibility
@@ -158,6 +172,7 @@ src/lean_spec/subspecs/containers/
158172
### Examples
159173

160174
**Good domain-specific types:**
175+
161176
```python
162177
# In state/types.py
163178
HISTORICAL_ROOTS_LIMIT = 262144
@@ -174,6 +189,7 @@ class Attestations(SSZList):
174189
```
175190

176191
**Avoid generic types:**
192+
177193
```python
178194
# Don't do this:
179195
class Bitlist68719476736(BaseBitlist): ...
@@ -183,5 +199,6 @@ class SignedAttestationList4096(SSZList): ...
183199
### API Compatibility
184200

185201
When refactoring, maintain backward compatibility:
202+
186203
- Keep existing import paths working through `__init__.py` exports
187204
- Preserve method signatures and behavior
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
"""XMSS key management utilities for testing."""
2+
3+
from typing import Optional
4+
5+
from lean_spec.subspecs.containers import Attestation, Signature
6+
from lean_spec.subspecs.containers.slot import Slot
7+
from lean_spec.subspecs.ssz.hash import hash_tree_root
8+
from lean_spec.subspecs.xmss.containers import PublicKey, SecretKey
9+
from lean_spec.subspecs.xmss.interface import DEFAULT_SIGNATURE_SCHEME
10+
from lean_spec.types import ValidatorIndex
11+
12+
13+
class XmssKeyManager:
14+
"""
15+
Manages XMSS keys for test validators.
16+
17+
Generates and manages XMSS key pairs for validators on demand.
18+
Keys are generated to be valid up to the specified max_slot.
19+
"""
20+
21+
DEFAULT_MAX_SLOT = Slot(100)
22+
"""Default maximum slot for key generation."""
23+
24+
max_slot: Slot
25+
public_keys: dict[ValidatorIndex, PublicKey]
26+
secret_keys: dict[ValidatorIndex, SecretKey]
27+
28+
def __init__(
29+
self,
30+
max_slot: Optional[Slot] = None,
31+
) -> None:
32+
"""
33+
Initialize the XMSS key manager.
34+
35+
Args:
36+
max_slot: Maximum slot for which keys should be valid. Keys will be
37+
generated with enough capacity to sign messages up to this slot.
38+
Defaults to 100 slots.
39+
"""
40+
self.max_slot = max_slot if max_slot is not None else self.DEFAULT_MAX_SLOT
41+
self.public_keys: dict[ValidatorIndex, PublicKey] = {}
42+
self.secret_keys: dict[ValidatorIndex, SecretKey] = {}
43+
44+
def create_and_store_key_pair(
45+
self, validator_index: ValidatorIndex
46+
) -> tuple[PublicKey, SecretKey]:
47+
"""
48+
Create an XMSS key pair for the given validator index.
49+
50+
Args:
51+
validator_index: The index of the validator to create a key for.
52+
53+
Returns:
54+
A tuple containing the public and secret keys for the given validator index.
55+
"""
56+
if validator_index not in self.public_keys:
57+
# Use max_slot + 1 as num_active_epochs since slots are used as epochs in the spec.
58+
# +1 to include genesis slot
59+
num_active_epochs = self.max_slot.as_int() + 1
60+
self.public_keys[validator_index], self.secret_keys[validator_index] = (
61+
DEFAULT_SIGNATURE_SCHEME.key_gen(0, num_active_epochs)
62+
)
63+
return self.public_keys[validator_index], self.secret_keys[validator_index]
64+
65+
def sign_attestation(self, attestation: Attestation) -> Signature:
66+
"""
67+
Sign an attestation with the given validator index.
68+
69+
Args:
70+
attestation: The attestation to sign.
71+
72+
Returns:
73+
A signature for the given attestation.
74+
"""
75+
validator_id = attestation.validator_id
76+
77+
sk = self.secret_keys[validator_id]
78+
message = bytes(hash_tree_root(attestation))
79+
epoch = int(attestation.data.slot)
80+
xmss_sig = DEFAULT_SIGNATURE_SCHEME.sign(sk, epoch, message)
81+
82+
signature_bytes = xmss_sig.to_bytes(DEFAULT_SIGNATURE_SCHEME.config)
83+
# Pad to 3100 bytes (Signature.LENGTH) with zeros on the right
84+
# Padding only occurs with TEST_CONFIG(796 bytes) and not PROD_CONFIG(3100 bytes).
85+
padded_bytes = signature_bytes.ljust(Signature.LENGTH, b"\x00")
86+
signature = Signature(padded_bytes)
87+
return signature
88+
89+
def __contains__(self, validator_index: ValidatorIndex) -> bool:
90+
"""
91+
Check if a validator has a registered key.
92+
93+
Args:
94+
validator_index: The index of the validator to check.
95+
96+
Returns:
97+
True if the validator has a registered key, False otherwise.
98+
"""
99+
return validator_index in self.secret_keys
100+
101+
def __len__(self) -> int:
102+
"""Return the number of registered keys."""
103+
return len(self.secret_keys)

0 commit comments

Comments
 (0)