Skip to content

Commit 75193e7

Browse files
authored
Merge branch 'main' into feat/multi-server-devnet-skill
2 parents 42ef4f3 + e492080 commit 75193e7

13 files changed

Lines changed: 667 additions & 120 deletions

File tree

bin/ethlambda/src/checkpoint_sync.rs

Lines changed: 14 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use std::time::Duration;
22

33
use ethlambda_types::block::SignedBlock;
4+
use ethlambda_types::genesis::{GenesisMismatch, verify_state_genesis};
45
use ethlambda_types::primitives::HashTreeRoot as _;
56
use ethlambda_types::state::{State, Validator, anchor_pair_is_consistent};
67
use libssz::{DecodeError, SszDecode};
@@ -45,20 +46,13 @@ pub enum CheckpointSyncError {
4546
SlotIsZero,
4647
#[error("checkpoint state has no validators")]
4748
NoValidators,
48-
#[error("genesis time mismatch: expected {expected}, got {got}")]
49-
GenesisTimeMismatch { expected: u64, got: u64 },
50-
#[error("validator count mismatch: expected {expected}, got {got}")]
51-
ValidatorCountMismatch { expected: usize, got: usize },
52-
#[error(
53-
"validator at position {position} has non-sequential index (expected {expected}, got {got})"
54-
)]
55-
NonSequentialValidatorIndex {
56-
position: usize,
57-
expected: u64,
58-
got: u64,
59-
},
60-
#[error("validator {index} pubkey mismatch (attestation or proposal key)")]
61-
ValidatorPubkeyMismatch { index: usize },
49+
#[error("checkpoint state does not match the configured genesis: {0}")]
50+
Genesis(#[from] GenesisMismatch),
51+
/// Reading the persisted store failed, including the case where the data
52+
/// directory holds another network's chain. Startup aborts: the operator
53+
/// has to point at the right data directory or remove it.
54+
#[error("failed to load persisted DB state: {0}")]
55+
DbState(#[from] ethlambda_storage::Error),
6256
#[error("finalized slot cannot exceed state slot")]
6357
FinalizedExceedsStateSlot,
6458
#[error("justified slot cannot precede finalized slot")]
@@ -197,7 +191,8 @@ fn verify_checkpoint_state(
197191
expected_genesis_time: u64,
198192
expected_validators: &[Validator],
199193
) -> Result<(), CheckpointSyncError> {
200-
// Slot sanity check
194+
// Slot sanity check. Checkpoint-specific: unlike a state loaded from our
195+
// own data directory, a downloaded anchor at genesis is never legitimate.
201196
if state.slot == 0 {
202197
return Err(CheckpointSyncError::SlotIsZero);
203198
}
@@ -207,46 +202,10 @@ fn verify_checkpoint_state(
207202
return Err(CheckpointSyncError::NoValidators);
208203
}
209204

210-
// Genesis time matches
211-
if state.config.genesis_time != expected_genesis_time {
212-
return Err(CheckpointSyncError::GenesisTimeMismatch {
213-
expected: expected_genesis_time,
214-
got: state.config.genesis_time,
215-
});
216-
}
217-
218-
// Validator count matches
219-
if state.validators.len() != expected_validators.len() {
220-
return Err(CheckpointSyncError::ValidatorCountMismatch {
221-
expected: expected_validators.len(),
222-
got: state.validators.len(),
223-
});
224-
}
225-
226-
// Validator indices are sequential (0, 1, 2, ...)
227-
for (position, validator) in state.validators.iter().enumerate() {
228-
if validator.index != position as u64 {
229-
return Err(CheckpointSyncError::NonSequentialValidatorIndex {
230-
position,
231-
expected: position as u64,
232-
got: validator.index,
233-
});
234-
}
235-
}
236-
237-
// Validator pubkeys match (critical security check)
238-
for (i, (state_val, expected_val)) in state
239-
.validators
240-
.iter()
241-
.zip(expected_validators.iter())
242-
.enumerate()
243-
{
244-
if state_val.attestation_pubkey != expected_val.attestation_pubkey
245-
|| state_val.proposal_pubkey != expected_val.proposal_pubkey
246-
{
247-
return Err(CheckpointSyncError::ValidatorPubkeyMismatch { index: i });
248-
}
249-
}
205+
// Genesis time and the full validator registry match our config. Shared
206+
// with the resume-from-disk path so both entry points agree on what makes a
207+
// state ours.
208+
verify_state_genesis(state, expected_genesis_time, expected_validators)?;
250209

251210
// Finalized slot sanity
252211
if state.latest_finalized.slot > state.slot {

bin/ethlambda/src/main.rs

Lines changed: 39 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -688,7 +688,7 @@ async fn fetch_initial_state(
688688
// have. Tried before the checkpoint-sync and genesis paths so that a restart
689689
// without `--checkpoint-sync-url` keeps the chain instead of writing a
690690
// slot-0 anchor over it.
691-
if let Ok(Some(store)) = Store::from_db_state(backend.clone(), genesis.genesis_time) {
691+
if let Some(store) = Store::from_db_state(backend.clone(), genesis)? {
692692
let now_ms = SystemTime::UNIX_EPOCH
693693
.elapsed()
694694
.expect("already past the unix epoch")
@@ -978,26 +978,50 @@ validators:
978978
);
979979
}
980980

981-
/// A DB from another network is not resumable, so the no-URL path still
982-
/// falls back to genesis.
983-
///
984-
/// This pins current behavior, not a desired one. `from_db_state` treats a
985-
/// `GENESIS_TIME` mismatch as an empty DB and only warns, so with no
986-
/// checkpoint URL the node writes a genesis anchor over a populated
987-
/// foreign-network directory: the same data loss the resume ordering
988-
/// removes everywhere else. Left as-is deliberately, and this test is here
989-
/// to make the change visible when someone fixes it.
981+
/// A DB from another network aborts startup rather than being re-anchored:
982+
/// writing genesis on top would leave the foreign blocks in place, and
983+
/// slot-indexed reads would serve them to peers.
990984
#[tokio::test]
991-
async fn initializes_from_genesis_when_db_genesis_time_differs() {
985+
async fn fails_when_db_genesis_time_differs() {
992986
let seeded_genesis = test_genesis(now_secs());
993987
let backend = Arc::new(InMemoryBackend::default());
994988
seed_db(backend.clone(), &seeded_genesis);
995989

996990
let other_genesis = test_genesis(seeded_genesis.genesis_time + 1);
997-
let store = fetch_initial_state(&[], &other_genesis, backend)
998-
.await
999-
.unwrap();
991+
// `Store` is not `Debug`, so unwrap the error by pattern.
992+
let Err(err) = fetch_initial_state(&[], &other_genesis, backend.clone()).await else {
993+
panic!("a foreign DB must not be silently re-anchored");
994+
};
1000995

1001-
assert_eq!(store.head_slot(), 0);
996+
assert!(
997+
matches!(err, checkpoint_sync::CheckpointSyncError::DbState(_)),
998+
"unexpected error: {err}"
999+
);
1000+
// The foreign chain is left untouched, not overwritten with a new anchor.
1001+
let store = Store::from_db_state(backend, &seeded_genesis)
1002+
.expect("original DB still loads under its own genesis")
1003+
.expect("store exists");
1004+
assert_eq!(store.head_slot(), SEEDED_HEAD_SLOT);
1005+
}
1006+
1007+
/// Same genesis time, different validator registry: the case the previous
1008+
/// `genesis_time`-only check could not see.
1009+
#[tokio::test]
1010+
async fn fails_when_db_validator_set_differs() {
1011+
let genesis_time = now_secs();
1012+
let seeded_genesis = test_genesis(genesis_time);
1013+
let backend = Arc::new(InMemoryBackend::default());
1014+
seed_db(backend.clone(), &seeded_genesis);
1015+
1016+
let mut other_genesis = test_genesis(genesis_time);
1017+
other_genesis.genesis_validators[0].attestation_pubkey = [9u8; 52];
1018+
let Err(err) = fetch_initial_state(&[], &other_genesis, backend).await else {
1019+
panic!("a foreign validator set must not be silently re-anchored");
1020+
};
1021+
1022+
assert!(
1023+
matches!(err, checkpoint_sync::CheckpointSyncError::DbState(_)),
1024+
"unexpected error: {err}"
1025+
);
10021026
}
10031027
}

crates/blockchain/src/lib.rs

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use std::collections::{HashMap, HashSet, VecDeque};
22
use std::time::{Duration, Instant, SystemTime};
33

44
use ethlambda_crypto::signature::{ValidatorPublicKey, ValidatorSignature};
5-
use ethlambda_network_api::{BlockChainToP2PRef, InitP2P};
5+
use ethlambda_network_api::{BlockChainToP2PRef, BlockSource, InitP2P};
66
use ethlambda_state_transition::is_proposer;
77
use ethlambda_storage::{ALL_TABLES, Store};
88
use ethlambda_types::{
@@ -109,6 +109,20 @@ impl SlotInterval {
109109
_ => unreachable!("slots only have 5 intervals"),
110110
}
111111
}
112+
113+
/// Milliseconds from genesis to the start of this interval in `slot`.
114+
///
115+
/// Inverse of [`Self::from_ms_since_genesis`].
116+
pub(crate) fn to_ms_since_genesis(self, slot: u64) -> u64 {
117+
let interval = match self {
118+
Self::BlockPublication => 0,
119+
Self::AttestationProduction => 1,
120+
Self::Aggregation => 2,
121+
Self::SafeTargetUpdate => 3,
122+
Self::EndOfSlot => 4,
123+
};
124+
slot * MILLISECONDS_PER_SLOT + interval * MILLISECONDS_PER_INTERVAL
125+
}
112126
}
113127

114128
/// Milliseconds until the next interval boundary, measured relative to genesis.
@@ -1367,16 +1381,37 @@ impl Handler<InitP2P> for BlockChainServer {
13671381

13681382
impl Handler<NewBlock> for BlockChainServer {
13691383
async fn handle(&mut self, msg: NewBlock, _ctx: &Context<Self>) {
1370-
self.events.emit(ChainEvent::BlockGossip {
1371-
slot: msg.block.message.slot,
1372-
block: msg.block.message.hash_tree_root(),
1373-
});
1384+
let arrival_ms = unix_now_ms();
1385+
// Gate both the event and the arrival metric on BlockSource::Gossip for
1386+
// two reasons: `ChainEvent::BlockGossip` is documented (events.rs) as "a
1387+
// block seen on gossip, before import", yet without this gate it also
1388+
// fired for req/resp sync blocks; and sync backfill delivers blocks many
1389+
// slots after they were due, which would swamp the arrival histogram
1390+
// with stale deltas that reflect catch-up speed, not gossip timeliness.
1391+
// `self.on_block(msg.block)` still runs for every source below: it is
1392+
// the import path and must not be gated.
1393+
if msg.source == BlockSource::Gossip {
1394+
let slot = msg.block.message.slot;
1395+
self.events.emit(ChainEvent::BlockGossip {
1396+
slot,
1397+
block: msg.block.message.hash_tree_root(),
1398+
});
1399+
let genesis_ms = self.store.config().expect("config exists").genesis_time * 1000;
1400+
metrics::observe_gossip_block_arrival(arrival_ms, genesis_ms, slot);
1401+
}
13741402
self.on_block(msg.block);
13751403
}
13761404
}
13771405

13781406
impl Handler<NewAttestation> for BlockChainServer {
13791407
async fn handle(&mut self, msg: NewAttestation, ctx: &Context<Self>) {
1408+
let arrival_ms = unix_now_ms();
1409+
let genesis_ms = self.store.config().expect("config exists").genesis_time * 1000;
1410+
metrics::observe_gossip_attestation_arrival(
1411+
arrival_ms,
1412+
genesis_ms,
1413+
msg.attestation.data.slot,
1414+
);
13801415
self.on_gossip_attestation(&msg.attestation);
13811416
// Early aggregation only advances the current slot's group counts, so a
13821417
// late- or future-slot attestation can never cross the threshold; skip
@@ -1390,6 +1425,9 @@ impl Handler<NewAttestation> for BlockChainServer {
13901425

13911426
impl Handler<NewAggregatedAttestation> for BlockChainServer {
13921427
async fn handle(&mut self, msg: NewAggregatedAttestation, _ctx: &Context<Self>) {
1428+
let arrival_ms = unix_now_ms();
1429+
let genesis_ms = self.store.config().expect("config exists").genesis_time * 1000;
1430+
metrics::observe_gossip_aggregation_arrival(arrival_ms, genesis_ms);
13931431
self.on_gossip_aggregated_attestation(msg.attestation);
13941432
}
13951433
}
@@ -1400,6 +1438,8 @@ impl Handler<NewAggregatedAttestation> for BlockChainServer {
14001438

14011439
impl Handler<AggregateProduced> for BlockChainServer {
14021440
async fn handle(&mut self, msg: AggregateProduced, _ctx: &Context<Self>) {
1441+
let arrival_ms = unix_now_ms();
1442+
14031443
// Drop results from a prior session (or from an unexpected late worker).
14041444
// Current session may be None if the actor already cleaned it up; accept
14051445
// the message only when ids match.
@@ -1413,6 +1453,17 @@ impl Handler<AggregateProduced> for BlockChainServer {
14131453
return;
14141454
}
14151455

1456+
// Count our own aggregate in the same series as gossip-received ones,
1457+
// so an aggregator does not report an empty aggregate arrival profile.
1458+
// Delivery of this message is held to the interval-2 boundary upstream,
1459+
// so a local aggregate lands near zero unless proving overran the
1460+
// interval. Sharing one series with received aggregates is deliberate
1461+
// and costs little in practice: a late aggregate is late for every node
1462+
// at once, so both populations are dominated by production time rather
1463+
// than propagation and their distributions look alike.
1464+
let genesis_ms = self.store.config().expect("config exists").genesis_time * 1000;
1465+
metrics::observe_gossip_aggregation_arrival(arrival_ms, genesis_ms);
1466+
14161467
// Publish alignment is enforced upstream: the worker delays delivery of
14171468
// this message until the interval-2 boundary, so by the time it lands
14181469
// the aggregate is safe to apply and gossip immediately.

0 commit comments

Comments
 (0)