Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 8 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ crates/
- Communication via `mpsc::unbounded_channel`
- Shared storage via `Arc<dyn StorageBackend>` (clone Store, share backend)

### Tick-Based Validator Duties (4-second slots, 5 intervals per slot)
### Tick-Based Validator Duties (5 intervals per slot; 4-second slots by default)
```
Interval 0: Block published (at the slot boundary). The build+publish code path is merged into the previous slot's interval 4 (see below) and aligned to publish here; no attestation acceptance happens at interval 0.
Interval 1: Attestation production (all validators, including proposer)
Expand Down Expand Up @@ -306,11 +306,18 @@ one port is supported and not a misconfiguration. See [`docs/rpc.md`](docs/rpc.m
**Genesis:** `config.yaml` (YAML format, cross-client compatible)
```yaml
GENESIS_TIME: 1770407233
MILLISECONDS_PER_SLOT: 4000 # optional, defaults to DEFAULT_MILLISECONDS_PER_SLOT
GENESIS_VALIDATORS:
- attestation_pubkey: "cd323f232b34ab26d6db7402c886e74ca81cfd3a..." # 52-byte XMSS pubkeys (hex)
proposal_pubkey: "b7b0f72e24801b02bda64073cb4de6699a416b37..."
```
- Validator indices are assigned sequentially (0, 1, 2, ...) based on array order
- `MILLISECONDS_PER_SLOT` must be a multiple of `INTERVALS_PER_SLOT` and at least
`MIN_MILLISECONDS_PER_SLOT`: the knob slows a network down, it does not speed one up,
since timings fixed in milliseconds (`EARLY_AGGREGATION_WINDOW`) are sized for the spec
cadence. It is persisted in the DB's `Metadata["config"]` and a resume with a different
value is refused. Other clients ignore the key and stay at their compile-time 4s, so it
only takes effect on an all-ethlambda network
- All genesis state fields (checkpoints, justified_slots, etc.) initialize to zero/empty defaults
- Matches Ream/Zeam format — no extra state fields in the config file

Expand Down
7 changes: 6 additions & 1 deletion bin/ethlambda/src/benchmark/corpus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use ethlambda_storage::{Store, backend::InMemoryBackend};
use ethlambda_types::{
attestation::{AggregationBits, HashedAttestationData},
block::SingleMessageAggregate,
constants::DEFAULT_MILLISECONDS_PER_SLOT,
state::{State, Validator, ValidatorPubkeyBytes},
};

Expand Down Expand Up @@ -45,7 +46,11 @@ impl SyntheticCorpus {
})
.collect();
let genesis_state = State::from_genesis(GENESIS_TIME, validators);
Store::from_anchor_state(Arc::new(InMemoryBackend::new()), genesis_state)
Store::from_anchor_state(
Arc::new(InMemoryBackend::new()),
genesis_state,
DEFAULT_MILLISECONDS_PER_SLOT,
)
}

/// Seed the pending ("new") pool with the full validator set's attestations
Expand Down
4 changes: 2 additions & 2 deletions bin/ethlambda/src/checkpoint_sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,7 @@ mod tests {
use ethlambda_types::block::BlockHeader;
use ethlambda_types::checkpoint::Checkpoint;
use ethlambda_types::primitives::H256;
use ethlambda_types::state::{ChainConfig, JustificationValidators, JustifiedSlots};
use ethlambda_types::state::{JustificationValidators, JustifiedSlots, StateConfig};
use libssz_types::SszList;

// Helper to create valid test state
Expand All @@ -364,7 +364,7 @@ mod tests {
slot: slot.saturating_sub(20),
root: H256::ZERO,
},
config: ChainConfig { genesis_time },
config: StateConfig { genesis_time },
historical_block_hashes: Default::default(),
justified_slots: JustifiedSlots::new(),
justifications_roots: Default::default(),
Expand Down
29 changes: 21 additions & 8 deletions bin/ethlambda/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ use tokio_util::sync::CancellationToken;

use cli::NodeOptions;
use command::Command;
use ethlambda_blockchain::MILLISECONDS_PER_SLOT;

use ethlambda_blockchain::block_builder::ProposerConfig;
use ethlambda_blockchain::key_manager::ValidatorKeyPair;
use ethlambda_crypto::signature::ValidatorSecretKey;
Expand Down Expand Up @@ -194,6 +194,7 @@ async fn run_node(options: NodeOptions) -> eyre::Result<()> {

info!(
genesis_time = genesis_config.genesis_time,
milliseconds_per_slot = genesis_config.milliseconds_per_slot,
validator_count = genesis_config.genesis_validators.len(),
"Loaded genesis configuration"
);
Expand Down Expand Up @@ -304,6 +305,7 @@ async fn run_node(options: NodeOptions) -> eyre::Result<()> {
validator_ids,
attestation_committee_count,
subscription_subnets: subscribed_subnets.clone(),
milliseconds_per_slot: genesis_config.milliseconds_per_slot,
})
.wrap_err("failed to build swarm")?;

Expand Down Expand Up @@ -747,7 +749,7 @@ async fn fetch_initial_state(
.expect("already past the unix epoch")
.as_millis() as u64;
let current_slot =
now_ms.saturating_sub(genesis.genesis_time * 1000) / MILLISECONDS_PER_SLOT;
now_ms.saturating_sub(genesis.genesis_time * 1000) / genesis.milliseconds_per_slot;
let head_slot = store.head_slot();
let gap = current_slot.saturating_sub(head_slot);
if gap <= MAX_RESUMABLE_DB_STATE_AGE {
Expand All @@ -771,7 +773,11 @@ async fn fetch_initial_state(
if checkpoint_urls.is_empty() {
info!("No checkpoint sync URL provided, initializing from genesis state");
let genesis_state = State::from_genesis(genesis.genesis_time, validators);
return Ok(Store::from_anchor_state(backend, genesis_state));
return Ok(Store::from_anchor_state(
backend,
genesis_state,
genesis.milliseconds_per_slot,
));
}

// Checkpoint sync path: try URLs in order, fail over to the next on error.
Expand All @@ -797,9 +803,14 @@ async fn fetch_initial_state(
// overlaps with what `get_forkchoice_store` already wrote, but it's
// idempotent and the only path that also stores `BlockProof`.
let anchor_root = signed_block.message.header().hash_tree_root();
let mut store = Store::get_forkchoice_store(backend, state, signed_block.message.clone())
.inspect_err(|err| error!(%err, "Failed to initialize store from anchor state and block"))
.map_err(|_| checkpoint_sync::CheckpointSyncError::AnchorPairingMismatch)?;
let mut store = Store::get_forkchoice_store(
backend,
state,
signed_block.message.clone(),
genesis.milliseconds_per_slot,
)
.inspect_err(|err| error!(%err, "Failed to initialize store from anchor state and block"))
.map_err(|_| checkpoint_sync::CheckpointSyncError::AnchorPairingMismatch)?;
store
.insert_signed_block(anchor_root, signed_block)
.inspect_err(|err| error!(%err, "Failed to insert anchor signed block into store"))
Expand All @@ -811,6 +822,7 @@ async fn fetch_initial_state(
mod tests {
use super::*;
use ethlambda_storage::backend::InMemoryBackend;
use ethlambda_types::constants::DEFAULT_MILLISECONDS_PER_SLOT;
use ethlambda_types::genesis::GenesisValidatorEntry;

/// Validator-config snippet matching `lean-quickstart`'s ansible-devnet
Expand Down Expand Up @@ -927,7 +939,7 @@ validators:
/// elapsed time, and a whole slot of it would have to pass between this
/// call and the read inside the function to shift the gap.
fn genesis_time_for_gap(gap: u64) -> u64 {
let seconds_per_slot = MILLISECONDS_PER_SLOT / 1_000;
let seconds_per_slot = DEFAULT_MILLISECONDS_PER_SLOT / 1_000;
now_secs() - (SEEDED_HEAD_SLOT + gap) * seconds_per_slot
}

Expand All @@ -936,6 +948,7 @@ validators:
fn test_genesis(genesis_time: u64) -> GenesisConfig {
GenesisConfig {
genesis_time,
milliseconds_per_slot: DEFAULT_MILLISECONDS_PER_SLOT,
genesis_validators: vec![GenesisValidatorEntry {
attestation_pubkey: [1u8; 52],
proposal_pubkey: [2u8; 52],
Expand All @@ -949,7 +962,7 @@ validators:
let mut anchor = State::from_genesis(genesis.genesis_time, genesis.validators());
anchor.slot = SEEDED_HEAD_SLOT;
anchor.latest_block_header.slot = SEEDED_HEAD_SLOT;
Store::from_anchor_state(backend, anchor);
Store::from_anchor_state(backend, anchor, DEFAULT_MILLISECONDS_PER_SLOT);
}

#[tokio::test]
Expand Down
48 changes: 30 additions & 18 deletions crates/blockchain/src/aggregation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ use ethlambda_types::{
ShortRoot,
attestation::{AggregationBits, AttestationData, HashedAttestationData},
block::{ByteList512KiB, SingleMessageAggregate},
constants::{INTERVALS_PER_SLOT, MIN_MILLISECONDS_PER_SLOT},
primitives::H256,
state::Validator,
};
Expand All @@ -36,16 +37,19 @@ use tokio_util::sync::CancellationToken;
use tracing::{info, trace, warn};

use crate::block_builder::{self, EntryScore};
use crate::{MILLISECONDS_PER_INTERVAL, metrics};
use crate::metrics;

/// Soft deadline for committee-signature aggregation measured from session
/// start. After this much wall time elapses, the actor signals the worker to
/// stop via its cancellation token. A session started exactly at interval 2
/// gets the full interval (interval 3 is one interval later); a session
/// started early (see `maybe_start_early_aggregation`) ends correspondingly
/// earlier. The deadline only stops new jobs from starting — a job mid-proof
/// finishes and publishes right after.
pub(crate) const AGGREGATION_DEADLINE: Duration = Duration::from_millis(800);
/// start: one full interval. After this much wall time elapses, the actor
/// signals the worker to stop via its cancellation token. A session started
/// exactly at interval 2 therefore runs until interval 3; a session started
/// early (see `maybe_start_early_aggregation`) ends correspondingly earlier.
/// The deadline only stops new jobs from starting — a job mid-proof finishes
/// and publishes right after.
pub(crate) fn aggregation_deadline(milliseconds_per_interval: u64) -> Duration {
Duration::from_millis(milliseconds_per_interval)
}

/// Upper bound we wait for a prior worker to exit if it is still running when
/// the next session is about to start. Reached only in pathological cases
/// (mismatched timers, stuck proofs); we warn before blocking.
Expand All @@ -54,16 +58,23 @@ pub(crate) const PRIOR_WORKER_JOIN_TIMEOUT: Duration = Duration::from_secs(2);
/// Width of the early-aggregation window: a session may start at most this
/// long before the interval-2 boundary, provided the signature threshold is
/// met (see the check in `maybe_start_early_aggregation`).
///
/// Fixed rather than scaled with the slot duration. What the window buys is
/// wall time for the leanVM proof to land before the block that carries it,
/// and a proof costs the same however long the network's slot is.
pub(crate) const EARLY_AGGREGATION_WINDOW: Duration = Duration::from_millis(600);

// The window must fit within one interval: `maybe_start_early_aggregation`
// subtracts it from the interval-2 offset, and the interval-1 tick schedules
// the check at `MILLISECONDS_PER_INTERVAL - EARLY_AGGREGATION_WINDOW`. Keep
// this invariant self-enforcing so a future bump to the window can't silently
// underflow either subtraction.
// the check at `milliseconds_per_interval - EARLY_AGGREGATION_WINDOW`. The
// slot duration is configurable, so the binding case is the narrowest interval
// a config file can ask for. Keep this invariant self-enforcing so a future
// bump to the window, or a lowered floor, can't silently underflow either
// subtraction.
const _: () = assert!(
EARLY_AGGREGATION_WINDOW.as_millis() <= MILLISECONDS_PER_INTERVAL as u128,
"EARLY_AGGREGATION_WINDOW must not exceed one interval"
EARLY_AGGREGATION_WINDOW.as_millis()
<= (MIN_MILLISECONDS_PER_SLOT / INTERVALS_PER_SLOT) as u128,
"EARLY_AGGREGATION_WINDOW must not exceed the shortest configurable interval"
);

/// A single pre-prepared aggregation group.
Expand Down Expand Up @@ -165,7 +176,7 @@ impl Message for AggregationDeadline {
}

/// One-shot self-message scheduled at the interval-1 tick; fires when the
/// early-aggregation window opens (T2 - EARLY_AGGREGATION_WINDOW) to run
/// early-aggregation window opens (T2 - [`EARLY_AGGREGATION_WINDOW`]) to run
/// the threshold check for signatures that all arrived before the window.
/// Arrivals inside the window are checked per insert instead.
pub(crate) struct EarlyAggregationCheck;
Expand All @@ -174,7 +185,7 @@ impl Message for EarlyAggregationCheck {
}

/// Maximum number of aggregation jobs selected per interval-2 session. Caps
/// leanVM prover work against [`AGGREGATION_DEADLINE`]: the greedy loop in
/// leanVM prover work against [`aggregation_deadline`]: the greedy loop in
/// [`snapshot_aggregation_inputs`] stops after this many rounds even if
/// scoring candidates remain.
pub(crate) const MAX_AGGREGATION_JOBS: usize = 2;
Expand Down Expand Up @@ -772,10 +783,11 @@ pub(crate) fn run_aggregation_worker(
mod tests {
use super::*;
use ethlambda_storage::backend::InMemoryBackend;
use ethlambda_types::constants::DEFAULT_MILLISECONDS_PER_SLOT;
use ethlambda_types::{
block::{Block, BlockBody, BlockHeader, MultiMessageAggregate, SignedBlock},
checkpoint::Checkpoint,
state::{ChainConfig, JustificationValidators, JustifiedSlots, State},
state::{JustificationValidators, JustifiedSlots, State, StateConfig},
};
use libssz_types::SszList;
use std::sync::Arc;
Expand Down Expand Up @@ -840,7 +852,7 @@ mod tests {
body_root: H256::ZERO,
};
State {
config: ChainConfig { genesis_time: 1000 },
config: StateConfig { genesis_time: 1000 },
slot: head_slot,
latest_block_header: head_header,
latest_justified: Checkpoint::default(),
Expand All @@ -855,7 +867,7 @@ mod tests {

fn new_test_store(head_state: State) -> Store {
let backend: Arc<dyn ethlambda_storage::StorageBackend> = Arc::new(InMemoryBackend::new());
Store::from_anchor_state(backend, head_state)
Store::from_anchor_state(backend, head_state, DEFAULT_MILLISECONDS_PER_SLOT)
}

/// Insert a header-only block at `root` so it shows up in
Expand Down
20 changes: 10 additions & 10 deletions crates/blockchain/src/block_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -972,7 +972,7 @@ mod tests {
fn build_block_caps_attestation_data_entries() {
use ethlambda_types::{
block::BlockHeader,
state::{ChainConfig, JustificationValidators, JustifiedSlots},
state::{JustificationValidators, JustifiedSlots, StateConfig},
};
use libssz::SszEncode;
use libssz_types::SszList;
Expand Down Expand Up @@ -1008,7 +1008,7 @@ mod tests {
};

let head_state = State {
config: ChainConfig { genesis_time: 1000 },
config: StateConfig { genesis_time: 1000 },
slot: HEAD_SLOT,
latest_block_header: head_header,
latest_justified: Checkpoint::default(),
Expand Down Expand Up @@ -1134,7 +1134,7 @@ mod tests {
fn build_block_respects_configured_attestation_limit() {
use ethlambda_types::{
block::BlockHeader,
state::{ChainConfig, JustificationValidators, JustifiedSlots},
state::{JustificationValidators, JustifiedSlots, StateConfig},
};
use libssz_types::SszList;

Expand Down Expand Up @@ -1165,7 +1165,7 @@ mod tests {
};

let head_state = State {
config: ChainConfig { genesis_time: 1000 },
config: StateConfig { genesis_time: 1000 },
slot: HEAD_SLOT,
latest_block_header: head_header,
latest_justified: Checkpoint::default(),
Expand Down Expand Up @@ -1266,7 +1266,7 @@ mod tests {
fn build_block_without_proposer_aggregation_keeps_single_best_proof_per_data() {
use ethlambda_types::{
block::BlockHeader,
state::{ChainConfig, JustificationValidators, JustifiedSlots},
state::{JustificationValidators, JustifiedSlots, StateConfig},
};
use libssz_types::SszList;

Expand Down Expand Up @@ -1294,7 +1294,7 @@ mod tests {
};

let head_state = State {
config: ChainConfig { genesis_time: 1000 },
config: StateConfig { genesis_time: 1000 },
slot: HEAD_SLOT,
latest_block_header: head_header,
latest_justified: Checkpoint::default(),
Expand Down Expand Up @@ -1568,7 +1568,7 @@ mod tests {
use ethlambda_state_transition::justified_slots_ops;
use ethlambda_types::{
block::BlockHeader,
state::{ChainConfig, JustificationValidators, JustifiedSlots},
state::{JustificationValidators, JustifiedSlots, StateConfig},
};
use libssz_types::SszList;

Expand Down Expand Up @@ -1601,7 +1601,7 @@ mod tests {
};

let head_state = State {
config: ChainConfig { genesis_time: 1000 },
config: StateConfig { genesis_time: 1000 },
slot: HEAD_SLOT,
latest_block_header: head_header,
latest_justified: Checkpoint {
Expand Down Expand Up @@ -1697,7 +1697,7 @@ mod tests {
fn build_block_cascades_projected_justification_across_rounds() {
use ethlambda_types::{
block::BlockHeader,
state::{ChainConfig, JustificationValidators, JustifiedSlots},
state::{JustificationValidators, JustifiedSlots, StateConfig},
};
use libssz_types::SszList;

Expand All @@ -1723,7 +1723,7 @@ mod tests {
body_root: BlockBody::default().hash_tree_root(),
};
let head_state = State {
config: ChainConfig { genesis_time: 1000 },
config: StateConfig { genesis_time: 1000 },
slot: HEAD_SLOT,
latest_block_header: head_header,
latest_justified: Checkpoint::default(),
Expand Down
7 changes: 6 additions & 1 deletion crates/blockchain/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,7 @@ fn checkpoint_state_root(store: &Store, root: H256) -> Option<H256> {
mod tests {
use super::*;
use ethlambda_storage::{ForkCheckpoints, backend::InMemoryBackend};
use ethlambda_types::constants::DEFAULT_MILLISECONDS_PER_SLOT;
use ethlambda_types::{
block::{Block, BlockBody, MultiMessageAggregate, SignedBlock},
state::State,
Expand Down Expand Up @@ -435,7 +436,11 @@ mod tests {

fn test_store() -> Store {
let genesis_state = State::from_genesis(1000, vec![]);
Store::from_anchor_state(Arc::new(InMemoryBackend::new()), genesis_state)
Store::from_anchor_state(
Arc::new(InMemoryBackend::new()),
genesis_state,
DEFAULT_MILLISECONDS_PER_SLOT,
)
}

/// Insert a header-only block at `root` so header reads (block root, slot,
Expand Down
Loading
Loading