Skip to content

Commit b279176

Browse files
feat: add support for devnet 3 (#107)
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: `devnet0` → `devnet3` - `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. --------- Co-authored-by: Pablo Deymonnaz <pdeymon@fi.uba.ar>
1 parent cb1be84 commit b279176

29 files changed

Lines changed: 2273 additions & 694 deletions

CLAUDE.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,13 @@ cargo test -p ethlambda-blockchain --test forkchoice_spectests -- --test-threads
308308

309309
## Common Gotchas
310310

311+
### Aggregator Flag Required for Finalization
312+
- At least one node **must** be started with `--is-aggregator` to finalize blocks in production (without `skip-signature-verification`)
313+
- Without this flag, attestations pass signature verification and are logged as "Attestation processed", but the signature is never stored for aggregation (`store.rs:368`), so blocks are always built with `attestation_count=0`
314+
- The attestation pipeline: gossip → verify signature → store gossip signature (only if `is_aggregator`) → aggregate at interval 2 → promote to known → pack into blocks
315+
- With `skip-signature-verification` (tests only), attestations bypass aggregation and go directly to `new_aggregated_payloads`, so the flag is not needed
316+
- **Symptom**: `justified_slot=0` and `finalized_slot=0` indefinitely despite healthy block production and attestation gossip
317+
311318
### Signature Verification
312319
- Fork choice tests use `on_block_without_verification()` to skip signature checks
313320
- Signature spec tests use `on_block()` which always verifies

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ docker-build: ## 🐳 Build the Docker image
2424
-t ghcr.io/lambdaclass/ethlambda:$(DOCKER_TAG) .
2525
@echo
2626

27-
LEAN_SPEC_COMMIT_HASH:=4edcf7bc9271e6a70ded8aff17710d68beac4266
27+
LEAN_SPEC_COMMIT_HASH:=8b7636bb8a95fe4bec414cc4c24e74079e6256b6
2828

2929
leanSpec:
3030
git clone https://github.com/leanEthereum/leanSpec.git --single-branch

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ make run-devnet
4040
This generates fresh genesis files and starts all configured clients with metrics enabled.
4141
Press `Ctrl+C` to stop all nodes.
4242

43+
> **Important:** When running nodes manually (outside `make run-devnet`), at least one node must be started with `--is-aggregator` for attestations to be aggregated and included in blocks. Without this flag, the network will produce blocks but never finalize.
44+
4345
For custom devnet configurations, go to `lean-quickstart/local-devnet/genesis/validator-config.yaml` and edit the file before running the command above. See `lean-quickstart`'s documentation for more details on how to configure the devnet.
4446

4547
## Philosophy

bin/ethlambda/src/main.rs

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,12 @@ struct CliOptions {
5151
/// When set, skips genesis initialization and syncs from checkpoint.
5252
#[arg(long)]
5353
checkpoint_sync_url: Option<String>,
54+
/// Whether this node acts as a committee aggregator
55+
#[arg(long, default_value = "false")]
56+
is_aggregator: bool,
57+
/// Number of attestation committees (subnets) per slot
58+
#[arg(long, default_value = "1", value_parser = clap::value_parser!(u64).range(1..))]
59+
attestation_committee_count: u64,
5460
}
5561

5662
#[tokio::main]
@@ -114,7 +120,10 @@ async fn main() -> eyre::Result<()> {
114120
.inspect_err(|err| error!(%err, "Failed to initialize state"))?;
115121

116122
let (p2p_tx, p2p_rx) = tokio::sync::mpsc::unbounded_channel();
117-
let blockchain = BlockChain::spawn(store.clone(), p2p_tx, validator_keys);
123+
// Use first validator ID for subnet subscription
124+
let first_validator_id = validator_keys.keys().min().copied();
125+
let blockchain =
126+
BlockChain::spawn(store.clone(), p2p_tx, validator_keys, options.is_aggregator);
118127

119128
let p2p_handle = tokio::spawn(start_p2p(
120129
node_p2p_key,
@@ -123,6 +132,9 @@ async fn main() -> eyre::Result<()> {
123132
blockchain,
124133
p2p_rx,
125134
store.clone(),
135+
first_validator_id,
136+
options.attestation_committee_count,
137+
options.is_aggregator,
126138
));
127139

128140
ethlambda_rpc::start_rpc_server(metrics_socket, store)
@@ -132,8 +144,8 @@ async fn main() -> eyre::Result<()> {
132144
info!("Node initialized");
133145

134146
tokio::select! {
135-
_ = p2p_handle => {
136-
panic!("P2P node task has exited unexpectedly");
147+
result = p2p_handle => {
148+
panic!("P2P node task has exited unexpectedly: {result:?}");
137149
}
138150
_ = tokio::signal::ctrl_c() => {
139151
// Ctrl-C received, shutting down

crates/blockchain/fork_choice/src/lib.rs

Lines changed: 85 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,45 @@ use std::collections::HashMap;
22

33
use ethlambda_types::{attestation::AttestationData, primitives::H256};
44

5+
/// Compute per-block attestation weights for the fork choice tree.
6+
///
7+
/// For each validator attestation, walks backward from the attestation's head
8+
/// through the parent chain, incrementing weight for each block above start_slot.
9+
pub fn compute_block_weights(
10+
start_slot: u64,
11+
blocks: &HashMap<H256, (u64, H256)>,
12+
attestations: &HashMap<u64, AttestationData>,
13+
) -> HashMap<H256, u64> {
14+
let mut weights: HashMap<H256, u64> = HashMap::new();
15+
16+
for attestation_data in attestations.values() {
17+
let mut current_root = attestation_data.head.root;
18+
while let Some(&(slot, parent_root)) = blocks.get(&current_root)
19+
&& slot > start_slot
20+
{
21+
*weights.entry(current_root).or_default() += 1;
22+
current_root = parent_root;
23+
}
24+
}
25+
26+
weights
27+
}
28+
529
/// Compute the LMD GHOST head of the chain, given a starting root, a set of blocks,
630
/// a set of attestations, and a minimum score threshold.
731
///
32+
/// Returns the head root and the per-block attestation weights used for selection.
33+
///
834
/// This is the same implementation from leanSpec
935
// TODO: add proto-array implementation
1036
pub fn compute_lmd_ghost_head(
1137
mut start_root: H256,
1238
blocks: &HashMap<H256, (u64, H256)>,
1339
attestations: &HashMap<u64, AttestationData>,
1440
min_score: u64,
15-
) -> H256 {
41+
) -> (H256, HashMap<H256, u64>) {
1642
if blocks.is_empty() {
17-
return start_root;
43+
return (start_root, HashMap::new());
1844
}
1945
if start_root.is_zero() {
2046
start_root = *blocks
@@ -24,19 +50,9 @@ pub fn compute_lmd_ghost_head(
2450
.expect("we already checked blocks is non-empty");
2551
}
2652
let Some(&(start_slot, _)) = blocks.get(&start_root) else {
27-
return start_root;
53+
return (start_root, HashMap::new());
2854
};
29-
let mut weights: HashMap<H256, u64> = HashMap::new();
30-
31-
for attestation_data in attestations.values() {
32-
let mut current_root = attestation_data.head.root;
33-
while let Some(&(slot, parent_root)) = blocks.get(&current_root)
34-
&& slot > start_slot
35-
{
36-
*weights.entry(current_root).or_default() += 1;
37-
current_root = parent_root;
38-
}
39-
}
55+
let weights = compute_block_weights(start_slot, blocks, attestations);
4056

4157
let mut children_map: HashMap<H256, Vec<H256>> = HashMap::new();
4258

@@ -62,5 +78,59 @@ pub fn compute_lmd_ghost_head(
6278
.expect("checked it's not empty");
6379
}
6480

65-
head
81+
(head, weights)
82+
}
83+
84+
#[cfg(test)]
85+
mod tests {
86+
use super::*;
87+
use ethlambda_types::state::Checkpoint;
88+
89+
fn make_attestation(head_root: H256, slot: u64) -> AttestationData {
90+
AttestationData {
91+
slot,
92+
head: Checkpoint {
93+
root: head_root,
94+
slot,
95+
},
96+
target: Checkpoint::default(),
97+
source: Checkpoint::default(),
98+
}
99+
}
100+
101+
#[test]
102+
fn test_compute_block_weights() {
103+
// Chain: root_a (slot 0) -> root_b (slot 1) -> root_c (slot 2)
104+
let root_a = H256::from([1u8; 32]);
105+
let root_b = H256::from([2u8; 32]);
106+
let root_c = H256::from([3u8; 32]);
107+
108+
let mut blocks = HashMap::new();
109+
blocks.insert(root_a, (0, H256::ZERO));
110+
blocks.insert(root_b, (1, root_a));
111+
blocks.insert(root_c, (2, root_b));
112+
113+
// Two validators: one attests to root_c, one attests to root_b
114+
let mut attestations = HashMap::new();
115+
attestations.insert(0, make_attestation(root_c, 2));
116+
attestations.insert(1, make_attestation(root_b, 1));
117+
118+
let weights = compute_block_weights(0, &blocks, &attestations);
119+
120+
// root_c: 1 vote (validator 0)
121+
assert_eq!(weights.get(&root_c).copied().unwrap_or(0), 1);
122+
// root_b: 2 votes (validator 0 walks through it + validator 1 attests directly)
123+
assert_eq!(weights.get(&root_b).copied().unwrap_or(0), 2);
124+
// root_a: at slot 0 = start_slot, so not counted
125+
assert_eq!(weights.get(&root_a).copied().unwrap_or(0), 0);
126+
}
127+
128+
#[test]
129+
fn test_compute_block_weights_empty() {
130+
let blocks = HashMap::new();
131+
let attestations = HashMap::new();
132+
133+
let weights = compute_block_weights(0, &blocks, &attestations);
134+
assert!(weights.is_empty());
135+
}
66136
}

0 commit comments

Comments
 (0)