Skip to content

feat: add support for devnet 3#107

Merged
MegaRedHand merged 30 commits intomainfrom
devnet-3
Feb 27, 2026
Merged

feat: add support for devnet 3#107
MegaRedHand merged 30 commits intomainfrom
devnet-3

Conversation

@MegaRedHand
Copy link
Collaborator

@MegaRedHand MegaRedHand commented Feb 10, 2026

Closes #73

Summary

Upgrades ethlambda from pq-devnet-2 to pq-devnet-3, implementing the new 5-interval timing model, committee aggregation pipeline, and networking topology.

Timing model (5 intervals × 800ms = 4s slots)

The slot is now divided into 5 intervals of 800ms each (previously 4 × 1s):

Interval 0: Accept attestations (if proposal exists) + block proposal
Interval 1: Vote propagation (no-op)
Interval 2: Committee signature aggregation (aggregator nodes only)
Interval 3: Safe target update (2/3 threshold fork choice)
Interval 4: Accept accumulated attestations into fork choice

Aggregated attestation pipeline

Replaced the per-validator attestation maps (LatestNewAttestations / LatestKnownAttestations) with an aggregated payload pipeline:

Gossip attestation → AttestationDataByRoot + GossipSignatures
         ↓ (interval 2, aggregator only)
    aggregate_committee_signatures() → LatestNewAggregatedPayloads
         ↓ (intervals 0/4)
    promote → LatestKnownAggregatedPayloads → fork choice

For skip-signature-verification mode (tests), individual attestations bypass aggregation and insert directly into LatestNewAggregatedPayloads with dummy proofs.

Networking

  • Per-committee aggregation subnets: /leanconsensus/devnet3/attestation_{subnet_id}/ssz_snappy
  • New aggregation gossip topic: /leanconsensus/devnet3/aggregation/ssz_snappy
  • Network name: devnet0devnet3
  • SignedAggregatedAttestation type for aggregation gossip
  • --is-aggregator CLI flag

Block building

  • select_aggregated_proofs() (renamed from compute_aggregated_signatures) only selects existing proofs from the AggregatedPayloads table — no more inline gossip signature collection
  • build_block() sources attestations from LatestKnownAggregatedPayloads
  • produce_block_with_signatures() uses the same aggregated payload source

Fork choice

  • update_head() and update_safe_target() reconstruct per-validator votes from aggregated payloads via extract_attestations_from_aggregated_payloads()
  • on_gossip_aggregated_attestation() handles aggregated attestations from the network, expanding per-participant into payload entries

Test fixtures

Updated LEAN_SPEC_COMMIT_HASH to b39472e (leanSpec devnet-3 with INTERVALS_PER_SLOT=5). Regenerated all fixtures.

@github-actions
Copy link

🤖 Kimi Code Review

Security & Correctness Issues

1. Critical: Slot/Interval Calculation Bug (blockchain/src/store.rs:253)

let current_slot = store.time() / INTERVALS_PER_SLOT;

This is wrong - store.time() is in 800ms intervals, but INTERVALS_PER_SLOT is 5. This makes current_slot 5x larger than actual slots. This affects:

  • Attestation validation (lines 253, 267, 298)
  • Block production timing
  • All fork choice calculations

Fix: Use proper slot calculation:

let current_slot = store.time() / INTERVALS_PER_SLOT;

2. Race Condition in Aggregation (blockchain/src/store.rs:aggregate_committee_signatures)

The aggregation function deletes gossip signatures after processing, but there's no atomicity guarantee. If a validator sends a new attestation between aggregation and deletion, the signature could be lost.

Fix: Use a transaction or batch operation for the entire aggregation process.

3. Memory Leak in Aggregated Payloads (storage/src/store.rs:insert_*_aggregated_payload)

The functions append to vectors without deduplication. Over time, this could lead to unbounded growth of duplicate payloads for the same (validator_id, data_root) key.

Fix: Check for existing payloads before insertion or implement periodic cleanup.

4. Incorrect Network Name (net/p2p/src/lib.rs:158)

Changed from "devnet0" to "devnet3" without corresponding spec update. This could cause network partition.

Fix: Ensure this matches the expected network configuration.

Performance Issues

1. Inefficient Aggregation (blockchain/src/store.rs:aggregate_committee_signatures)

The function iterates all gossip signatures for every slot, even when most are for different slots. Consider indexing by slot.

2. Redundant Storage (storage/src/store.rs)

Multiple copies of the same attestation data are stored (by root, in payloads, etc.). This could be optimized with reference counting.

Code Quality Issues

1. Magic Numbers (blockchain/src/lib.rs)

Constants like INTERVALS_PER_SLOT = 5 and MILLISECONDS_PER_INTERVAL = 800 should be configurable or come from the chain spec.

2. Complexity in select_aggregated_proofs

The function has nested loops and complex logic that's hard to verify. Consider breaking into smaller functions.

3. Inconsistent Error Handling

Some functions use inspect_err for logging while others use expect. Standardize error handling patterns.

Minor Issues

  1. Typo in comment (blockchain/src/store.rs:290): "Vote propagation" should probably be "Vote preparation"
  2. Dead code: post_state variable in build_block is assigned but never used (line 915)
  3. Missing validation: No check that SignedAggregatedAttestation.proof actually covers the validators it claims to

Recommendations

  1. Add comprehensive tests for the new aggregation logic, especially edge cases
  2. Implement metrics for aggregation efficiency (signatures aggregated vs. total)
  3. Add configuration for committee counts and subnet topics
  4. Consider using a proper BLS library for signature aggregation instead of the current implementation

Automated review by Kimi (Moonshot AI) · custom prompt

@github-actions
Copy link

🤖 Claude Code Review

Based on my comprehensive review of PR #107, here's my code review:


PR #107 Review: Devnet 3 Support

Overview

This PR implements significant protocol changes for devnet 3, including:

  • 5-interval slots (800ms intervals instead of 1s)
  • Committee-based attestation subnets
  • Aggregated attestation gossip
  • Refactored attestation storage using aggregated payloads

Critical Issues

1. Time Conversion Bug (HIGH SEVERITY)

Location: crates/blockchain/src/lib.rs:134

let interval = (time_since_genesis % SECONDS_PER_SLOT) * 1000 / MILLISECONDS_PER_INTERVAL;

Problem: This calculation computes the interval from the timestamp's seconds component, but on_tick is called at 800ms boundaries. This creates a mismatch:

  • At timestamp 4.8s: (4 % 4) * 1000 / 800 = 0 (should be interval 1)
  • The calculation assumes tick happens exactly at second boundaries

Impact: Interval calculation will be incorrect for all non-second-aligned ticks, causing proposers/attesters to act at wrong intervals.

Recommendation: Remove this calculation entirely. The store.time() already tracks intervals correctly (set by on_tick). Use:

let slot = store.time() / INTERVALS_PER_SLOT;
let interval = store.time() % INTERVALS_PER_SLOT;

2. Non-Aggregator Nodes Miss Attestations (HIGH SEVERITY)

Location: crates/blockchain/src/store.rs:371-377

} else if is_aggregator {
    let signature = ValidatorSignature::from_bytes(&signed_attestation.signature)
        .map_err(|_| StoreError::SignatureDecodingFailed)?;
    store.insert_gossip_signature(&attestation.data, validator_id, signature);
}

Problem: Non-aggregator nodes only store attestation data by root but never insert into new_aggregated_payloads. Fork choice will not see these attestations.

Impact: Non-aggregator validator nodes will make incorrect fork choice decisions due to missing attestation data.

Recommendation: Always store in new_aggregated_payloads with an appropriate proof (either fresh aggregation or a single-validator proof).

3. Unused Variable (MEDIUM)

Location: crates/blockchain/src/store.rs:915

let _post_state = loop {

Problem: Variable prefixed with _ but the value is used at line 845 (Ok((final_block, post_state, aggregated_signatures))), causing a compilation error since post_state is not in scope.

Impact: Code won't compile.

4. Validator Index Out of Bounds Risk (MEDIUM SEVERITY)

Location: crates/blockchain/src/store.rs:164

let Ok(pubkey) = validators[*vid as usize].get_pubkey() else {
    continue;
};

Problem: No bounds check before indexing. If a malicious peer sends an attestation with an invalid validator ID and it gets stored in gossip_signatures, this will panic.

Impact: Potential panic causing node crash.

Recommendation: Add bounds check:

if *vid as usize >= validators.len() {
    continue;
}

5. Signature Parsing Duplication (LOW SEVERITY)

Location: crates/blockchain/src/store.rs:374-376

The signature is already parsed and verified at lines 351-355. Parsing it again at 374 is redundant.

Recommendation: Store the already-parsed signature instead of re-parsing.

Design Concerns

6. Aggregation Architecture

The two-table approach (LatestNewAggregatedPayloads and LatestKnownAggregatedPayloads) stores one entry per validator per attestation data root. With subnets, this could lead to storage amplification.

Observation: This is acceptable for devnet 3 with ATTESTATION_COMMITTEE_COUNT=1, but may need revisiting for production with many committees.

7. Gossip Signature Deletion Timing

Location: crates/blockchain/src/store.rs:196

Gossip signatures are deleted immediately after aggregation at interval 2. If aggregation fails partially (e.g., due to pubkey errors), those signatures are lost and won't be retried.

Recommendation: Only delete signatures that were successfully aggregated, not all attempted ones.

8. Pruning Efficiency

Location: crates/storage/src/store.rs:533-567

Pruning functions iterate over entire tables, decode every entry, check slots, then batch delete. For large devnets, this could be expensive.

Consideration: Not a bug, but monitor performance as network grows.

Minor Issues

9. Comment Accuracy

Location: crates/blockchain/src/lib.rs:164

// Update safe target slot metric (updated by store.on_tick at interval 3)

Should be "interval 3" (correct in code, line 298-300).

10. Network Name Hardcoded

Location: crates/net/p2p/src/lib.rs:157

let network = "devnet3";

Should be configurable or match genesis config network field.

11. Metrics Variable Assignment

Location: crates/net/p2p/src/metrics.rs:20

The refactored code is cleaner but the variable name name_registry was removed. Not an issue, just a style change.

Security Analysis

Signature verification: Properly maintained in gossip path
Bounds checking: Good validator index validation in on_gossip_attestation
⚠️ Array indexing: Missing bounds check in aggregate_committee_signatures (issue #4)
State transition: Properly called with updated intervals
Fork choice: Architecture correctly separates new/known attestations

Testing Recommendations

  1. Time interval edge cases: Test ticks at non-second boundaries (e.g., 0.8s, 1.6s)
  2. Non-aggregator fork choice: Verify non-aggregator nodes correctly participate in consensus
  3. Invalid validator IDs: Fuzz test with out-of-bounds validator indices
  4. Partial aggregation failures: Test behavior when some validators have invalid pubkeys

Positive Aspects

✅ Comprehensive refactoring to support committee subnets
✅ Clean separation of aggregation concerns
✅ Good use of content-addressed storage for attestation data
✅ Proper RAII patterns maintained
✅ Logging patterns follow project conventions
✅ Error handling with inspect_err is idiomatic

Summary

Blockers (must fix before merge):

Recommended fixes:

The architectural approach is sound, but the critical bugs need resolution before this can safely deploy to devnet 3.


Automated review by Claude (Anthropic) · custom prompt

@github-actions
Copy link

🤖 Codex Code Review

Findings (ordered by severity)

  • Critical: interval tracking is inconsistent and likely skips interval 4 entirely. BlockChainServer::on_tick computes intervals from second-granularity timestamps and passes timestamp.as_secs() into store::on_tick, which converts seconds into 800ms intervals. This causes interval counts to advance in 1s jumps and skip the 4th interval (e.g., 3s→interval 3, 4s→interval 5), so accept_new_attestations at interval 4 never runs and the “never skips intervals” assumption is violated. This can break fork choice weight updates, aggregation cadence, and safe-target timing. Fix by feeding ms-precision timestamps into store and computing interval from milliseconds consistently. Files: crates/blockchain/src/lib.rs:128-151, crates/blockchain/src/store.rs:258-307.
  • Critical: aggregated attestations from gossip are not verified. on_gossip_aggregated_attestation stores payloads and therefore influences fork choice without checking the aggregated signature/proof or participant indices. A malicious peer can inject weight and bias LMD GHOST. You should verify the aggregated proof against participant pubkeys (and ensure all participant indices are in range) before inserting payloads. Files: crates/blockchain/src/store.rs:397-429, and fork-choice usage via extract_attestations_from_aggregated_payloads in crates/blockchain/src/store.rs:93-121.
  • High: store::on_tick subtracts timestamp - genesis_time without saturation. If local time is before genesis, this underflows and yields a huge time_delta_ms, forcing a large fast-forward and corrupting state timing. Use saturating_sub or guard with a check. File: crates/blockchain/src/store.rs:258-262.
  • Medium: time validation is inconsistent. validate_attestation_data allows slot <= current_slot + 1 for clock skew, but on_gossip_attestation immediately rejects any slot > current_slot, eliminating that skew tolerance and contradicting the comment. Align these policies to avoid dropping valid attestations when local time lags slightly. Files: crates/blockchain/src/store.rs:240-335.
  • Medium: aggregate_committee_signatures indexes validators[*vid as usize] without bounds checks. If gossip-signature entries are corrupted or from a previous buggy version, this can panic and DoS the node. Add bounds checks and drop invalid entries (and consider pruning them). File: crates/blockchain/src/store.rs:163-169.

If you want, I can sanity-check the expected 800ms schedule and propose a concrete patch for the tick/time plumbing and aggregated attestation verification.


Automated review by OpenAI Codex · custom prompt

MegaRedHand and others added 11 commits February 10, 2026 17:33
…ilding

promote_new_aggregated_payloads was overwriting existing known entries
instead of appending, causing loss of previously accumulated proofs.
Block building was reading from the legacy AggregatedPayloads table
which was never populated by the new gossip pipeline, so proposers
couldn't use freshly aggregated proofs.

Fix both by merging payload lists during promotion and switching block
building to read from LatestKnownAggregatedPayloads. Remove the now-
unused AggregatedPayloads table and all associated methods.
Resolve conflict: keep both is_aggregator field and pending_block_parents
from main's storage refactor.
…ibility (#127)

Change BlocksByRootRequest::from_ssz_bytes_compat to use
SszDecode::from_ssz_bytes with the payload.

We don't need to maintain compatibility with devnet-2 format, so we
remove the previous serialization.
Move the seconds-to-milliseconds conversion from inside on_tick to
its callers, so both BlockChainServer::on_tick and store::on_tick
work natively with millisecond timestamps.
)

## Motivation

During devnet-3 debugging we found that the network was producing blocks
but never finalizing (`justified_slot=0`, `finalized_slot=0` after 450+
slots). The root cause: no node was started with `--is-aggregator`, so
attestation signatures were silently dropped after verification
(`store.rs:368`), resulting in all blocks having `attestation_count=0`.

## Description

- **CLAUDE.md**: Add "Aggregator Flag Required for Finalization" to the
Common Gotchas section, documenting the full attestation pipeline, root
cause, and symptom
- **README.md**: Add a note in the devnet section warning that at least
one node must use `--is-aggregator` when running manually

## How to Test

- Read the docs changes for accuracy
- Verify by running a devnet without `--is-aggregator` (blocks produce,
no finalization) vs. with it on at least one node (finalization
advances)
pablodeymo and others added 14 commits February 25, 2026 12:16
## Motivation

The leanSpec project updated its XMSS `Signature` class serialization in
commit [`0340cc1`](leanEthereum/leanSpec#380)
(part of
[leanSpec#403](leanEthereum/leanSpec#403)). The
`model_serializer` now emits signatures as hex-encoded SSZ bytes
(`"0x..."`) instead of the previous structured JSON format with `path`,
`rho`, and `hashes` sub-objects.

When bumping `LEAN_SPEC_COMMIT_HASH` to `8b7636b` and regenerating
fixtures, all 8 `verify_signatures` spec tests broke with
deserialization errors because our test types (`ProposerSignature`,
`SignaturePath`, `HashElement`, `RhoData`, `HashesData`) expected the
old structured format.

## Changes

**Bump leanSpec commit** (`ce8572e`):
- Update `LEAN_SPEC_COMMIT_HASH` in Makefile to `8b7636b`

**Fix signature fixture deserialization** (`1f69319`):
- Add `deser_xmss_hex` helper (follows the existing `deser_pubkey_hex`
pattern): strips `0x` prefix, hex-decodes to 3112 bytes, constructs
`XmssSignature`
- Change `TestSignatureBundle.proposer_signature` from
`ProposerSignature` to `XmssSignature` with the new deserializer
- Remove ~130 lines of dead code: `ProposerSignature` struct and its SSZ
reconstruction logic, `SignaturePath`, `HashElement`, `RhoData`,
`HashesData`, `SszXmssSignature`, `SszHashTreeOpening`, `HashDigest`,
`Rho` type aliases, and their now-unused imports (`SszDecode`,
`SszEncode`, `FixedVector`, `U28`, `U32`)

Note: `attestationSignatures` format is unchanged.

## Test plan

- [x] `make test` — all workspace tests pass
- [x] 8/8 signature spec tests pass
- [x] 14 STF spec tests pass
- [x] 26 fork choice spec tests pass
## Motivation

The leanSpec bump to `8b7636b` (#134) introduced several spec changes
beyond the signature
serialization fix. This PR ports three remaining behavioral changes to
align ethlambda
with the reference implementation:

1. **Stricter attestation validation** — leanSpec added two checks we
were missing
2. **Safe target attestation pool merge** — leanSpec changed which
attestations are visible during safe target computation
3. **Aggregated attestation broadcasting** — leanSpec now broadcasts
aggregated attestations after aggregation

## Description

### 1. Stricter attestation validation (`validate_attestation_data`)

Two new validation rules reject malformed attestations:

- **Head >= target check**: Reject attestations where the head
checkpoint slot is older than the target slot. Well-formed attestations
already satisfy `head.slot >= target.slot`, but without this check we'd
accept malformed ones.
- **Head slot consistency check**: Reject attestations where the head
checkpoint's `slot` field doesn't match the actual block's slot. This
mirrors the existing checks for source and target checkpoints.

New `StoreError` variants: `HeadOlderThanTarget`, `HeadSlotMismatch`.

### 2. Merge attestation pools in `update_safe_target`

Previously, `update_safe_target` (called at interval 3) only looked at
"new" attestation payloads. However, the migration from "new" to "known"
happens at interval 4, and some attestations enter "known" directly
without passing through "new":

- Proposer's own attestation bundled in block body → lands in "known"
directly via `on_block`
- Node's own self-attestation → recorded locally in "known"

Without merging both pools, these attestations were invisible to the
safe target calculation, causing it to undercount support. Now both
pools are merged before computing the safe target. No double-counting
risk since `extract_attestations_from_aggregated_payloads` deduplicates
by validator ID (latest slot wins).

### 3. Broadcast aggregated attestations after aggregation

After aggregating committee signatures at interval 2, the node now
broadcasts the resulting `SignedAggregatedAttestation` messages to the
network. The `P2PMessage::PublishAggregatedAttestation` variant already
existed but was never sent from the aggregation path.

Changes:
- `aggregate_committee_signatures` returns
`Vec<SignedAggregatedAttestation>`
- `store::on_tick` returns `Vec<SignedAggregatedAttestation>`
- `BlockChainServer::on_tick` publishes each aggregate via the P2P
channel

Test call sites (`forkchoice_spectests`, `signature_spectests`) ignore
the return value — `Vec<T>` is not `#[must_use]`, so this compiles
without warnings.

## How to Test

```bash
make fmt    # Passes
make lint   # Passes
make test   # All 26 fork choice + 14 STF + 8 signature spec tests pass
```

Change 3 (broadcasting) is a runtime behavior validated in devnet
testing.

## Related Issues

Stacked on #134 (leanSpec bump to `8b7636b`).

---------

Co-authored-by: Tomás Grüner <47506558+MegaRedHand@users.noreply.github.com>
## Motivation

Devnet-3 runs with ethlambda and Zeam showed gossip messages not being
delivered properly between clients, contributing to
justification/finalization failures.

## Description

- **Remove `validate_messages()` from gossipsub config** — was causing
messages to be silently dropped.
- **Remove `flood_publish(false)`** — was preventing reliable message
propagation across clients.
- **Change network identifier from `devnet3` to `devnet0`** — aligns
with the network name used by other clients, ensuring gossipsub topic
strings match (e.g., `/leanconsensus/devnet0/block/ssz_snappy`).

## How to test

Full validation requires a multi-client devnet with Zeam nodes to verify
gossip interop.
## Motivation

`ATTESTATION_COMMITTEE_COUNT` was hardcoded to `1` in
`crates/blockchain/src/lib.rs`, meaning all validators shared a single
attestation subnet. Future devnets will use multiple committees (other
clients like Zeam and Lantern already support this parameter), so the
value needs to be configurable at runtime.

## Description

Add a `--attestation-committee-count` CLI argument (default `1`) that
controls how many attestation subnets exist. Validators are assigned to
subnets via `validator_id % attestation_committee_count`.

**Changes:**
- **`bin/ethlambda/src/main.rs`**: Add `--attestation-committee-count`
CLI argument with default value `1`, pass it to `start_p2p()`
- **`crates/blockchain/src/lib.rs`**: Remove the
`ATTESTATION_COMMITTEE_COUNT` constant (no longer needed since the value
comes from CLI)
- **`crates/net/p2p/src/lib.rs`**: Accept `attestation_committee_count`
as a function parameter instead of importing the constant, use it for
subnet ID calculation

The hardcoded constant is removed entirely since clap provides the
default. The blockchain crate doesn't store the value because it has no
internal use for it — only the P2P layer needs it for subnet
subscription.

## How to test

- Run with explicit value: `--attestation-committee-count 1` (should
behave identically to before)
- Run without the flag (default `1`, same behavior)
- `make lint` and `make test` pass with no warnings

---------

Co-authored-by: Tomás Grüner <47506558+MegaRedHand@users.noreply.github.com>
# Conflicts:
#	crates/blockchain/tests/signature_types.rs
The previous path (../../../ethlambda/leanSpec/...) only resolved
correctly from the canonical repo location. Using ../../leanSpec/...
(relative to the package root) works in both the main repo and git
worktrees.
## Motivation

Understanding the fork choice tree at runtime is essential for debugging
devnets and demonstrating how LMD GHOST works. Currently there is no way
to observe the tree, attestation weights, or checkpoint progression
without reading logs. This PR adds a browser-based real-time
visualization served from the existing RPC server, with zero new
external dependencies.

## Description

### New JSON API endpoint

`GET /lean/v0/fork_choice` returns a JSON snapshot of the fork choice
tree:

```json
{
  "nodes": [
    {
      "root": "0xabcd1234...",
      "slot": 42,
      "parent_root": "0x5678efgh...",
      "proposer_index": 3,
      "weight": 5
    }
  ],
  "head": "0xabcd1234...",
  "justified": { "root": "0x...", "slot": 10 },
  "finalized": { "root": "0x...", "slot": 5 },
  "safe_target": "0xdeadbeef...",
  "validator_count": 8
}
```

### D3.js visualization page

`GET /lean/v0/fork_choice/ui` serves a self-contained HTML page that
renders the fork tree in real time:

- **Tree layout**: Y axis = slot (time flows downward), X axis = fork
spreading via D3 tree layout
- **Color-coded blocks**: green (finalized), blue (justified), yellow
(safe target), orange (head), gray (default)
- **Weight visualization**: circle radius scaled by `weight /
validator_count`
- **Info bar**: head slot, justified slot, finalized slot, validator
count
- **Tooltips**: hover any block to see root (truncated), slot, proposer,
weight
- **Auto-polling**: refreshes every 2 seconds
- **Auto-scroll**: keeps the head node visible
- **Dark theme**: monospace font, suitable for terminal-oriented
developers

### Refactoring

Extracted `compute_block_weights()` from `compute_lmd_ghost_head()` in
the fork choice crate. The weight computation logic was previously
inlined and discarded after head selection — now it's a public function
reusable by both fork choice and the RPC handler. The existing
`compute_lmd_ghost_head` calls `compute_block_weights` internally, so
behavior is unchanged.

Moved the `extract_attestations` logic from both the blockchain and RPC
crates into `Store::extract_latest_attestations()` and
`Store::extract_latest_known_attestations()` to eliminate code
duplication. The function naturally belongs on `Store` since it only
depends on Store methods.

### Documentation

Added `docs/fork_choice_visualization.md` with usage guide covering
endpoints, local devnet and standalone setup, color coding reference,
layout guide, and JSON API schema.

## How to Use

### 1. Start a local devnet

```bash
make run-devnet
```

Or start a node manually:

```bash
cargo run --release -- \
  --custom-network-config-dir ./config \
  --node-key ./keys/node.key \
  --node-id 0 \
  --metrics-port 5054
```

### 2. Open the visualization

Navigate to `http://localhost:5054/lean/v0/fork_choice/ui` in your
browser.

The page will start polling automatically and render the fork tree as
blocks are produced.

### 3. Use the JSON API directly

```bash
curl -s http://localhost:5054/lean/v0/fork_choice | jq .
```

Useful for scripting, monitoring, or building custom tooling on top of
the fork choice data.

### What to look for

- **Single chain**: all blocks in a vertical line — no forks occurring
- **Forks**: horizontal branching — competing chains with different
attestation weights
- **Color transitions**: watch blocks turn green as finalization
advances
- **Weight distribution**: larger circles = more validators attesting
through that block

## Changes

| File | Action | Description |
|------|--------|-------------|
| `crates/blockchain/fork_choice/src/lib.rs` | Modified | Extract
`compute_block_weights()`, refactor `compute_lmd_ghost_head` to use it,
add 2 unit tests |
| `crates/net/rpc/Cargo.toml` | Modified | Add `ethlambda-fork-choice`
dependency |
| `crates/net/rpc/src/lib.rs` | Modified | Add `mod fork_choice`,
register 2 new routes |
| `crates/net/rpc/src/fork_choice.rs` | Created | Response types, JSON
handler, HTML handler, 2 integration tests |
| `crates/net/rpc/static/fork_choice.html` | Created | D3.js v7
visualization (~495 lines, self-contained) |
| `crates/storage/src/store.rs` | Modified | Add
`extract_latest_attestations()` and
`extract_latest_known_attestations()` methods |
| `crates/blockchain/src/store.rs` | Modified | Remove duplicated
`extract_attestations_from_aggregated_payloads`, use Store methods |
| `docs/fork_choice_visualization.md` | Created | Usage documentation
for the visualization endpoints and JSON API |
| `Cargo.lock` | Modified | Updated lockfile |

## Test plan

- [x] `make fmt` — clean
- [x] `make lint` — no warnings
- [x] `make test` — all 84 tests pass
- [x] Fork choice spec tests (26 tests) — no regression from refactoring
- [x] New unit tests: `test_compute_block_weights`,
`test_compute_block_weights_empty`
- [x] New RPC tests: `test_get_fork_choice_returns_json`,
`test_get_fork_choice_ui_returns_html`
- [x] Manual: run local devnet, open visualization in browser
## Motivation

This PR adds an ASCII tree directly to terminal logs so developers can
monitor fork choice state without leaving the terminal.

## Description

Adds a new `fork_choice_tree` module that renders the fork choice tree
as ASCII art and logs it once per slot at interval 4 (end-of-slot
attestation acceptance).

### Output format

```
Fork Choice Tree:
  Finalized: slot 38 | root 5678abcd
  Justified: slot 40 | root 1234efgh
  Head:      slot 45 | root abcd1234

  5678abcd(38)── 9abc0123(39)── 1234efgh(40)── a1b2c3d4(41) ─ 2 branches
  ├── dead0001(42)── beef0002(43)── abcd1234(44) *  [w:5]
  └── fade0003(42)── [ ]── cafe0004(44)  [w:2]
```

### Conventions

- `root(slot)` format using ShortRoot (8 hex chars)
- `──` connectors for linear chains
- `├──` / `└──` Unicode tree characters for forks
- `*` marker on the head block
- `[w:N]` weight annotation on leaf nodes
- `[ ]` for single missing slot, `[N]` for N consecutive missing slots
- Branches sorted by weight (heaviest first), tiebreaker on root hash
- Depth truncation with `...` when chain exceeds 20 nodes

### Changes

| File | Action | Description |
|------|--------|-------------|
| `crates/blockchain/src/fork_choice_tree.rs` | **New** |
`format_fork_choice_tree()` pure formatter + `TreeRenderer` struct for
recursive rendering |
| `crates/blockchain/src/lib.rs` | Modified | Add `pub(crate) mod
fork_choice_tree` declaration |
| `crates/blockchain/src/store.rs` | Modified | Add `log_tree` parameter
to `update_head`/`accept_new_attestations`; log tree at interval 4 only
|

### Logging frequency

To avoid excessive output, the tree is only printed at **interval 4**
(end-of-slot), giving one tree per slot. Block imports (interval 0) and
`on_block` calls pass `log_tree: false`.

### No new dependencies

The formatter uses only `std::fmt::Write` and types already available in
the blockchain crate (`H256`, `ShortRoot`, `Checkpoint`).

## How to Test

```bash
make fmt    # Passes
make lint   # Passes (0 warnings)
make test   # All tests pass (including 9 new unit tests for the formatter)
```

### Unit tests

- Linear chain renders correctly with all nodes in sequence
- Fork with two branches shows `├──` / `└──` connectors and branch count
- Single missing slot shows `[ ]` indicator
- Multiple missing slots show `[N]` indicator
- Empty blocks map returns minimal `(empty)` output
- Head marker `*` appears on the correct node
- Single-block chain (genesis) renders correctly
- Depth truncation triggers `...` at MAX_DISPLAY_DEPTH (20)
- Nested forks (sub-fork within a branch) render both levels of
branching
Resolve conflicts:
- store.rs: merge both import additions (AggregationBits from main, SignedAggregatedAttestation from devnet-3)
- block.rs: keep participant_indices() method, drop redundant pub field getters removed in main
MegaRedHand and others added 2 commits February 27, 2026 12:10
## Summary

- Non-aggregator nodes no longer subscribe to attestation subnet topics,
reducing unnecessary gossip traffic
- Aggregator nodes retain current behavior (subscribe and collect raw
attestations)
- Non-aggregators can still publish their own attestations via
gossipsub's fanout mechanism

## Changes

| File | Change |
|------|--------|
| `bin/ethlambda/src/main.rs` | Pass `is_aggregator` flag to
`start_p2p()` |
| `crates/net/p2p/src/lib.rs` | Add `is_aggregator` param; wrap
attestation `.subscribe()` in conditional |

## How it works

Gossipsub's fanout mechanism allows publishing to topics without
subscribing. When a node publishes to an unsubscribed topic, libp2p
maintains a temporary fanout set of peers that *are* subscribed,
forwarding the message through them. This means non-aggregators only
need outbound delivery (publishing), not inbound collection
(subscribing).

## Test plan

- [x] `cargo fmt --all -- --check`
- [x] `cargo clippy --workspace -- -D warnings`
- [x] `cargo test --workspace --release` (all unit tests pass; spec
tests require fixtures not in worktree)
- [ ] Multi-client devnet: verify aggregator nodes still collect
attestations and finalize; non-aggregators publish attestations but
don't receive them

Closes #159

---------

Co-authored-by: Pablo Deymonnaz <pdeymon@fi.uba.ar>
@MegaRedHand MegaRedHand marked this pull request as ready for review February 27, 2026 19:14
@greptile-apps
Copy link
Contributor

greptile-apps bot commented Feb 27, 2026

Greptile Summary

This PR implements a major protocol upgrade from pq-devnet-2 to pq-devnet-3, introducing a new 5-interval timing model (5 × 800ms = 4s slots) and replacing the per-validator attestation architecture with an aggregated payload pipeline.

Key architectural changes:

  • Timing model: Migrated from 4 × 1s intervals to 5 × 800ms intervals with reorganized slot phases (interval 0: attestation acceptance, interval 1: vote propagation, interval 2: aggregation, interval 3: safe target update, interval 4: final acceptance)
  • Attestation aggregation: Replaced LatestNewAttestations/LatestKnownAttestations tables with LatestNewAggregatedPayloads/LatestKnownAggregatedPayloads, centralizing signature aggregation at aggregator nodes at interval 2
  • Network topology: Added per-committee attestation subnets (/leanconsensus/devnet3/attestation_{subnet_id}/ssz_snappy) and a new aggregation gossip topic for distributing aggregated attestations
  • Fork choice: Updated to reconstruct per-validator attestations from aggregated payloads via content-addressed attestation data storage (AttestationDataByRoot)

Implementation quality:

  • The timing calculations correctly handle millisecond-to-interval conversions
  • Storage layer properly separates "new" (pending) and "known" (active) aggregated payloads
  • Validation logic strengthened with additional head/target/source consistency checks
  • Fork choice tree visualization added for debugging

Issues found:

  • Network name remains "devnet0" in code despite PR description stating it should be "devnet3" - needs clarification
  • Inconsistent error handling in topic subscriptions (mix of .unwrap() and ?)
  • Removed .validate_messages() from gossipsub config, which may reduce security posture

The changes are comprehensive and architecturally sound for a devnet upgrade, with proper separation of concerns between aggregators and non-aggregators.

Confidence Score: 4/5

  • This PR is safe to merge with minor configuration verification needed
  • The implementation is architecturally sound with proper timing calculations, storage layer refactoring, and network topology changes. However, there are minor inconsistencies (network name, error handling, gossipsub validation removal) that should be verified before merge. The changes are well-tested with updated fixtures from the leanSpec devnet-3 branch.
  • Pay attention to crates/net/p2p/src/lib.rs for network name and error handling consistency verification

Important Files Changed

Filename Overview
crates/blockchain/src/lib.rs Updated timing model to 5 intervals of 800ms, added aggregated attestation handling, and refactored interval-based tick scheduling
crates/blockchain/src/store.rs Major refactor replacing per-validator attestation maps with aggregated payload pipeline, added committee signature aggregation at interval 2
crates/storage/src/store.rs Replaced attestation storage tables with aggregated payload tables, added attestation-data-by-root content-addressed storage
crates/storage/src/api/tables.rs Updated table definitions removing old attestation tables, adding new aggregated payload tables and attestation data by root
crates/net/p2p/src/lib.rs Added per-committee attestation subnets, aggregation topic, and conditional subscription logic for aggregators vs non-aggregators, but has inconsistent error handling
crates/net/p2p/src/gossipsub/handler.rs Added aggregation topic handler and updated attestation handling to support subnet-based routing

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Gossip: Individual Attestation] -->|Interval 1| B{Is Aggregator?}
    B -->|Yes| C[Store in GossipSignatures]
    B -->|No| D[Discard - Wait for Aggregated]
    
    C -->|Interval 2| E[aggregate_committee_signatures]
    E --> F[Create SignedAggregatedAttestation]
    F --> G[Publish to Aggregation Topic]
    
    G --> H[All Nodes Receive]
    H --> I[Store in LatestNewAggregatedPayloads]
    
    I -->|Interval 0 with proposal| J[promote_new_aggregated_payloads]
    I -->|Interval 4| J
    
    J --> K[LatestKnownAggregatedPayloads]
    K --> L[extract_latest_known_attestations]
    L --> M[Fork Choice: compute_lmd_ghost_head]
    
    N[Block Building] --> O[select_aggregated_proofs]
    K --> O
    O --> P[Block with Aggregated Attestations]
Loading

Last reviewed commit: 5918e8b

Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

29 files reviewed, 3 comments

Edit Code Review Agent Settings | Greptile

We weren't actually using the attestation processing without verification
function, so we deleted it.
We also aren't expecting any unaggregated attestations from gossip
unless we are an aggregator, so having an is_aggregator parameter is not needed
@MegaRedHand MegaRedHand merged commit b279176 into main Feb 27, 2026
3 of 6 checks passed
@MegaRedHand MegaRedHand deleted the devnet-3 branch February 27, 2026 21:29
pablodeymo added a commit that referenced this pull request Mar 2, 2026
…tations, and consolidate test utilities (#172)

## Summary

Code quality cleanup after the devnet 3 merge (#107). Ran systematic
reviews (code reuse, quality, efficiency) across all changed files and
fixed the actionable findings. Net result: no behavior changes, reduced
I/O round-trips on hot paths.

- Deduplicate copy-pasted storage methods for aggregated payloads
- Eliminate 4 redundant `tree_hash_root()` computations on hot paths
- Remove duplicate test helpers in favor of existing public APIs
- Add a `data_root` cache to avoid N+1 DB lookups in attestation
extraction
- Single-pass over known aggregated payloads during block production
(was scanning the table twice)
- Move `Checkpoint` to its own module with direct imports (no
re-exports)
- Batch aggregated payload inserts (single commit instead of N
round-trips per validator)
- Deduplicate prune functions via shared `prune_by_slot` helper
- Narrow `extract_latest_attestations` API to accept only keys (avoids
cloning large proof data)
- Extract ~250 lines of duplicated test fixture types into shared
`ethlambda-test-fixtures` crate

## Changes in detail

### 1. Storage method deduplication (`crates/storage/src/store.rs`)

`iter_known_aggregated_payloads` / `iter_new_aggregated_payloads` and
`insert_known_aggregated_payload` / `insert_new_aggregated_payload` were
character-for-character identical except for the `Table` enum variant.

**Fix:** Extracted two private helpers parameterized by `Table`:
- `iter_aggregated_payloads(table)` — shared iterator logic
- `insert_aggregated_payload(table, key, payload)` — shared
read-modify-write logic

### 2. Redundant `tree_hash_root()` elimination
(`crates/blockchain/src/store.rs`)

Four call sites were computing `tree_hash_root()` on data that had
already been hashed:

| Location | What was redundant |
|----------|-------------------|
| `on_gossip_aggregated_attestation` |
`aggregated.data.tree_hash_root()` computed twice |
| `on_block_core` | `block.tree_hash_root()` computed before
verification, then recomputed on the clone after |
| `aggregate_committee_signatures` | `data.tree_hash_root()` recomputed
on data already fetched by its `data_root` key |
| `on_gossip_attestation` → `insert_gossip_signature` |
`attestation.data.tree_hash_root()` computed in caller, then recomputed
inside |

**Fix:** Reuse the pre-computed value in each case. For
`insert_gossip_signature`, changed the signature to accept `(data_root,
slot)` instead of `&AttestationData`.

### 3. Test helper deduplication

**`forkchoice_spectests.rs`:** Removed 20-line duplicate
`extract_attestations()` — calls the public
`Store::extract_latest_attestations()` directly.

**`rpc/fork_choice.rs` + `rpc/lib.rs`:** Extracted shared
`test_utils::create_test_state()`.

### 4. `data_root` cache in `extract_latest_attestations`
(`crates/storage/src/store.rs`)

Many validators attest to the same data (often just 1-3 distinct roots).
Added a `HashMap<H256, Option<AttestationData>>` cache to deduplicate
`get_attestation_data_by_root` lookups.

### 5. Single-pass in `produce_block_with_signatures`
(`crates/blockchain/src/store.rs`)

Was calling `store.iter_known_aggregated_payloads()` twice — once for
attestation data, then again for proofs. Now collects once and derives
both from it.

### 6. Move `Checkpoint` to dedicated module
(`crates/common/types/src/checkpoint.rs`)

Extracted `Checkpoint` struct and `deser_dec_str` helper from
`state.rs`. All import sites updated to use `checkpoint::Checkpoint`
directly — no `pub use` re-exports.

### 7. Batch aggregated payload inserts (`crates/storage/src/store.rs`)

Each `insert_aggregated_payload` call performed its own
read-modify-write-commit cycle. When storing an aggregated attestation
covering N validators, this meant N separate storage round-trips.

**Fix:** New `insert_aggregated_payloads_batch` performs a single
read-write-commit cycle for all entries. Groups by key to handle
duplicate keys correctly. Updated all loop callers in
`aggregate_committee_signatures`, `on_gossip_aggregated_attestation`,
and `on_block_core`.

### 8. Deduplicate prune functions (`crates/storage/src/store.rs`)

`prune_gossip_signatures` and `prune_attestation_data_by_root` were
structurally identical (iterate table, decode, check slot, collect keys,
batch delete). Extracted shared `prune_by_slot(table, finalized_slot,
get_slot)` helper parameterized by a slot-extraction closure.

### 9. Narrow `extract_latest_attestations` API

Changed from `impl Iterator<Item = (SignatureKey,
Vec<StoredAggregatedPayload>)>` to `impl Iterator<Item = SignatureKey>`.
The function only uses the keys — passing full payloads forced
`produce_block_with_signatures` to clone all
`Vec<StoredAggregatedPayload>` (containing multi-KB XMSS proof data)
unnecessarily.

### 10. Extract shared test fixture types into `ethlambda-test-fixtures`
crate

`blockchain/tests/common.rs` and `state_transition/tests/types.rs`
contained ~250 lines of identical serde `Deserialize` structs and `From`
impls for converting JSON test fixtures into domain types. Since these
are integration tests in different crates, they couldn't share code via
`mod`.

**Fix:** New `crates/common/test-fixtures/` crate holds the 12 shared
types (`Container`, `Config`, `Checkpoint`, `BlockHeader`, `Validator`,
`TestState`, `Block`, `BlockBody`, `AggregatedAttestation`,
`AggregationBits`, `AttestationData`, `TestInfo`) plus the
`deser_pubkey_hex` helper. Both consumer files now re-export from the
crate and keep only their test-specific types (`ProposerAttestation` in
forkchoice, `StateTransitionTestVector`/`PostState` in STF).

### 11. Stale comment fix (`crates/blockchain/src/lib.rs`)

Comment said "intervals 0/3" but the 5-interval model promotes
attestations at intervals 0 and 4.

## How to test

All existing tests cover these changes — no new behavior was introduced.

```bash
cargo test --workspace --release
cargo clippy --workspace -- -D warnings
cargo fmt --all -- --check
```

All 102 tests pass.
MegaRedHand pushed a commit that referenced this pull request Mar 2, 2026
…unify validator_indices, and clean up time constants (#174)

## Summary

Follow-up cleanup from reviewing the devnet 3 merge (#107). Four
targeted optimizations and deduplication fixes — no behavior changes.

- Skip payload deserialization in `update_safe_target` (runs every slot)
- Batch `insert_attestation_data_by_root` commits in `on_block_core`
(runs every block)
- Unify duplicated `aggregation_bits_to_validator_indices` into shared
`validator_indices()`
- Derive `MILLISECONDS_PER_SLOT` from base constants, remove redundant
`SECONDS_PER_SLOT`

## Changes in detail

### 1. Key-only iterators for `update_safe_target`
(`crates/storage/src/store.rs`, `crates/blockchain/src/store.rs`)

`update_safe_target` calls `iter_known_aggregated_payloads()` and
`iter_new_aggregated_payloads()`, which deserialize
`Vec<StoredAggregatedPayload>` per entry (each containing multi-KB
`AggregatedSignatureProof` objects). It then immediately discards the
values and passes only the keys to `extract_latest_attestations`.

**Fix:** Added `iter_known_aggregated_payload_keys()` and
`iter_new_aggregated_payload_keys()` backed by a shared
`iter_aggregated_payload_keys(table)` helper that decodes only the key
bytes, skipping value deserialization entirely. `update_safe_target` now
uses these through a `HashSet` for deduplication.

This runs every slot (every 4 seconds) and scales with validator count.

### 2. Batch attestation data inserts in `on_block_core`
(`crates/storage/src/store.rs`, `crates/blockchain/src/store.rs`)

`on_block_core` called `insert_attestation_data_by_root` once per block
body attestation plus once for the proposer attestation. Each call
opened a separate write batch and committed — N+1 round-trips per block.

**Fix:** Added `insert_attestation_data_by_root_batch` that writes all
entries in a single batch-commit. `on_block_core` now collects all
attestation data entries (body + proposer) and inserts them in one go.
The existing `insert_attestation_data_by_root` is kept for the
single-entry callers (`on_gossip_attestation`,
`on_gossip_aggregated_attestation`).

### 3. Unify `aggregation_bits_to_validator_indices`
(`crates/common/types/src/attestation.rs`,
`crates/common/types/src/block.rs`, `crates/blockchain/src/store.rs`)

`store.rs` had a free function
`aggregation_bits_to_validator_indices(&AggregationBits) -> Vec<u64>`
that was structurally identical to
`AggregatedSignatureProof::participant_indices()` in `block.rs`. Both
iterate a bitfield and collect set-bit indices.

**Fix:** Added `validator_indices(&AggregationBits) -> impl
Iterator<Item = u64>` in `ethlambda_types::attestation` (where
`AggregationBits` is defined). `participant_indices()` now delegates to
it. The local function in `store.rs` is removed; all 5 call sites use
the shared function.

### 4. Derive `MILLISECONDS_PER_SLOT` and remove `SECONDS_PER_SLOT`
(`crates/blockchain/src/lib.rs`, test files)

Three independent time constants were defined:
```rust
pub const SECONDS_PER_SLOT: u64 = 4;
pub const MILLISECONDS_PER_SLOT: u64 = 4_000;
pub const MILLISECONDS_PER_INTERVAL: u64 = 800;
pub const INTERVALS_PER_SLOT: u64 = 5;
```

`SECONDS_PER_SLOT` was only used in two test files and was redundant.
`MILLISECONDS_PER_SLOT` was independently defined rather than derived,
creating a consistency risk if either base constant changes.

**Fix:** Removed `SECONDS_PER_SLOT`. Made `MILLISECONDS_PER_SLOT =
MILLISECONDS_PER_INTERVAL * INTERVALS_PER_SLOT`. Updated test callers
(`forkchoice_spectests.rs`, `signature_spectests.rs`) to use
`genesis_time * 1000 + slot * MILLISECONDS_PER_SLOT`, matching the
production arithmetic in `get_proposal_head`.

## How to test

All existing tests cover these changes — no new behavior was introduced.

```bash
cargo test --workspace --release
cargo clippy --workspace --tests -- -D warnings
cargo fmt --all -- --check
```

All 102 tests pass.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add support for pq-devnet-3

2 participants