Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
f3cd1aa
feat: implement devnet 3 changes
MegaRedHand Feb 10, 2026
aacd731
chore: address review comments
MegaRedHand Feb 10, 2026
ef5e8e8
fix: merge proof lists on promote and use known payloads for block bu…
MegaRedHand Feb 10, 2026
be79701
Merge branch 'main' into devnet-3
MegaRedHand Feb 11, 2026
d512ffa
Merge branch 'main' into devnet-3
MegaRedHand Feb 12, 2026
09b85e7
chore: remove from_ssz_bytes_compat for devnet-3 serialization compat…
pablodeymo Feb 12, 2026
5108734
refactor: pass milliseconds to on_tick instead of seconds
MegaRedHand Feb 13, 2026
1702d6a
docs: document `--is-aggregator` flag requirement for finalization (#…
pablodeymo Feb 13, 2026
c92d175
Run cargo fmt
pablodeymo Feb 13, 2026
e9a2b66
Merge branch 'main' into devnet-3
pablodeymo Feb 13, 2026
c01cc2e
Merge branch 'main' into devnet-3
pablodeymo Feb 18, 2026
b24ebeb
Merge branch 'main' into devnet-3
pablodeymo Feb 24, 2026
2eae9a0
Bump leanSpec commit to 8b7636b (#134)
pablodeymo Feb 25, 2026
8842eee
Port leanSpec on_tick and attestation validation changes (#135)
pablodeymo Feb 25, 2026
11dc85c
fix: gossip config for devnet interop (#139)
pablodeymo Feb 25, 2026
c32eab7
Add --attestation-committee-count CLI parameter (#141)
pablodeymo Feb 25, 2026
8ed0304
Merge remote-tracking branch 'origin/main' into devnet-3
MegaRedHand Feb 25, 2026
ac1057c
fix: use relative fixture path for signature spec tests
MegaRedHand Feb 25, 2026
59c6f3e
Add fork choice tree visualization (#142)
pablodeymo Feb 25, 2026
6a5e9cb
Merge branch 'main' into devnet-3
MegaRedHand Feb 25, 2026
3649402
Add ASCII fork choice tree to terminal logs (#143)
pablodeymo Feb 25, 2026
085a13d
Merge branch 'main' into devnet-3
MegaRedHand Feb 25, 2026
0833b8c
chore: remove duplicate hashing
MegaRedHand Feb 25, 2026
1d9b318
refactor: remove unused variable
MegaRedHand Feb 25, 2026
9928702
Merge branch 'main' into devnet-3
pablodeymo Feb 26, 2026
c114e7e
Merge branch 'main' into devnet-3
pablodeymo Feb 27, 2026
e80b3b1
fix: subscribe to subnet when aggregating only (#160)
MegaRedHand Feb 27, 2026
5918e8b
Merge branch 'main' into devnet-3
pablodeymo Feb 27, 2026
6bbed39
Merge branch 'main' into devnet-3
MegaRedHand Feb 27, 2026
472e174
refactor: remove unused parameter and function
MegaRedHand Feb 27, 2026
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
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,13 @@ cargo test -p ethlambda-blockchain --test forkchoice_spectests -- --test-threads

## Common Gotchas

### Aggregator Flag Required for Finalization
- At least one node **must** be started with `--is-aggregator` to finalize blocks in production (without `skip-signature-verification`)
- 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`
- The attestation pipeline: gossip → verify signature → store gossip signature (only if `is_aggregator`) → aggregate at interval 2 → promote to known → pack into blocks
- With `skip-signature-verification` (tests only), attestations bypass aggregation and go directly to `new_aggregated_payloads`, so the flag is not needed
- **Symptom**: `justified_slot=0` and `finalized_slot=0` indefinitely despite healthy block production and attestation gossip

### Signature Verification
- Fork choice tests use `on_block_without_verification()` to skip signature checks
- Signature spec tests use `on_block()` which always verifies
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ docker-build: ## 🐳 Build the Docker image
-t ghcr.io/lambdaclass/ethlambda:$(DOCKER_TAG) .
@echo

LEAN_SPEC_COMMIT_HASH:=4edcf7bc9271e6a70ded8aff17710d68beac4266
LEAN_SPEC_COMMIT_HASH:=8b7636bb8a95fe4bec414cc4c24e74079e6256b6

leanSpec:
git clone https://github.com/leanEthereum/leanSpec.git --single-branch
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ make run-devnet
This generates fresh genesis files and starts all configured clients with metrics enabled.
Press `Ctrl+C` to stop all nodes.

> **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.

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.

## Philosophy
Expand Down
18 changes: 15 additions & 3 deletions bin/ethlambda/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ struct CliOptions {
/// When set, skips genesis initialization and syncs from checkpoint.
#[arg(long)]
checkpoint_sync_url: Option<String>,
/// Whether this node acts as a committee aggregator
#[arg(long, default_value = "false")]
is_aggregator: bool,
/// Number of attestation committees (subnets) per slot
#[arg(long, default_value = "1", value_parser = clap::value_parser!(u64).range(1..))]
attestation_committee_count: u64,
}

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

let (p2p_tx, p2p_rx) = tokio::sync::mpsc::unbounded_channel();
let blockchain = BlockChain::spawn(store.clone(), p2p_tx, validator_keys);
// Use first validator ID for subnet subscription
let first_validator_id = validator_keys.keys().min().copied();
let blockchain =
BlockChain::spawn(store.clone(), p2p_tx, validator_keys, options.is_aggregator);

let p2p_handle = tokio::spawn(start_p2p(
node_p2p_key,
Expand All @@ -123,6 +132,9 @@ async fn main() -> eyre::Result<()> {
blockchain,
p2p_rx,
store.clone(),
first_validator_id,
options.attestation_committee_count,
options.is_aggregator,
));

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

tokio::select! {
_ = p2p_handle => {
panic!("P2P node task has exited unexpectedly");
result = p2p_handle => {
panic!("P2P node task has exited unexpectedly: {result:?}");
}
_ = tokio::signal::ctrl_c() => {
// Ctrl-C received, shutting down
Expand Down
100 changes: 85 additions & 15 deletions crates/blockchain/fork_choice/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,45 @@ use std::collections::HashMap;

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

/// Compute per-block attestation weights for the fork choice tree.
///
/// For each validator attestation, walks backward from the attestation's head
/// through the parent chain, incrementing weight for each block above start_slot.
pub fn compute_block_weights(
start_slot: u64,
blocks: &HashMap<H256, (u64, H256)>,
attestations: &HashMap<u64, AttestationData>,
) -> HashMap<H256, u64> {
let mut weights: HashMap<H256, u64> = HashMap::new();

for attestation_data in attestations.values() {
let mut current_root = attestation_data.head.root;
while let Some(&(slot, parent_root)) = blocks.get(&current_root)
&& slot > start_slot
{
*weights.entry(current_root).or_default() += 1;
current_root = parent_root;
}
}

weights
}

/// Compute the LMD GHOST head of the chain, given a starting root, a set of blocks,
/// a set of attestations, and a minimum score threshold.
///
/// Returns the head root and the per-block attestation weights used for selection.
///
/// This is the same implementation from leanSpec
// TODO: add proto-array implementation
pub fn compute_lmd_ghost_head(
mut start_root: H256,
blocks: &HashMap<H256, (u64, H256)>,
attestations: &HashMap<u64, AttestationData>,
min_score: u64,
) -> H256 {
) -> (H256, HashMap<H256, u64>) {
if blocks.is_empty() {
return start_root;
return (start_root, HashMap::new());
}
if start_root.is_zero() {
start_root = *blocks
Expand All @@ -24,19 +50,9 @@ pub fn compute_lmd_ghost_head(
.expect("we already checked blocks is non-empty");
}
let Some(&(start_slot, _)) = blocks.get(&start_root) else {
return start_root;
return (start_root, HashMap::new());
};
let mut weights: HashMap<H256, u64> = HashMap::new();

for attestation_data in attestations.values() {
let mut current_root = attestation_data.head.root;
while let Some(&(slot, parent_root)) = blocks.get(&current_root)
&& slot > start_slot
{
*weights.entry(current_root).or_default() += 1;
current_root = parent_root;
}
}
let weights = compute_block_weights(start_slot, blocks, attestations);

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

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

head
(head, weights)
}

#[cfg(test)]
mod tests {
use super::*;
use ethlambda_types::state::Checkpoint;

fn make_attestation(head_root: H256, slot: u64) -> AttestationData {
AttestationData {
slot,
head: Checkpoint {
root: head_root,
slot,
},
target: Checkpoint::default(),
source: Checkpoint::default(),
}
}

#[test]
fn test_compute_block_weights() {
// Chain: root_a (slot 0) -> root_b (slot 1) -> root_c (slot 2)
let root_a = H256::from([1u8; 32]);
let root_b = H256::from([2u8; 32]);
let root_c = H256::from([3u8; 32]);

let mut blocks = HashMap::new();
blocks.insert(root_a, (0, H256::ZERO));
blocks.insert(root_b, (1, root_a));
blocks.insert(root_c, (2, root_b));

// Two validators: one attests to root_c, one attests to root_b
let mut attestations = HashMap::new();
attestations.insert(0, make_attestation(root_c, 2));
attestations.insert(1, make_attestation(root_b, 1));

let weights = compute_block_weights(0, &blocks, &attestations);

// root_c: 1 vote (validator 0)
assert_eq!(weights.get(&root_c).copied().unwrap_or(0), 1);
// root_b: 2 votes (validator 0 walks through it + validator 1 attests directly)
assert_eq!(weights.get(&root_b).copied().unwrap_or(0), 2);
// root_a: at slot 0 = start_slot, so not counted
assert_eq!(weights.get(&root_a).copied().unwrap_or(0), 0);
}

#[test]
fn test_compute_block_weights_empty() {
let blocks = HashMap::new();
let attestations = HashMap::new();

let weights = compute_block_weights(0, &blocks, &attestations);
assert!(weights.is_empty());
}
}
Loading
Loading