Skip to content
Draft
58 changes: 51 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,15 +52,59 @@ 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 (4-second slots, 4 intervals of 1000ms)
```
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)
Interval 2: Aggregation (aggregators create proofs from gossip signatures)
Interval 3: Safe target update (fork choice)
Interval 4: Accept accumulated attestations; build the NEXT slot's block and publish it aligned to that slot's interval 0 (build and publish merged into this tick)
Interval 0: Block published (at the slot boundary). The build+publish code path is merged into the previous slot's interval 3 (see below) and aligned to publish here. Block import merges the block's slot-(T-1) committee bits into the heartbeat vote store; new payloads are promoted to known.
Interval 1: Attestation production (all validators, including proposer). Committee members republish the same signature to the global heartbeat topic. The early-aggregation window check is scheduled.
Interval 2: Safe target update from current-slot heartbeat votes; the aggregation session starts (or already started early) and its aggregates begin publishing.
Interval 3: lagging_head (RLMD window) then fast_head (GHOST-Eph); accept accumulated attestations; build the NEXT slot's block and publish it aligned to that slot's interval 0 (build and publish merged into this tick)
```

The slot stays 4000ms across the 5β†’4 interval change: the XMSS epoch is the slot, so
key lifetime is untouched, `GENESIS_TIME` configs stay valid, and slot numbers still
line up with other clients even while the interval grid inside the slot diverges.

### Two-Tier Fork Choice (heartbeat)

| value | interval | base | vote window | pool | `min_score` |
|---|---|---|---|---|---|
| `safe_target` | 2 | `latest_justified` | `slot == S` only | heartbeat (gossip) | `ceil(3K'/4)` |
| `lagging_head` | 3 | `latest_justified` | `[S-N, S)` | heartbeat βˆͺ `known_payloads` | `ceil(2n/3)` |
| `fast_head` | 3 | `lagging_head` | `slot == S-1`, expanding | heartbeat | `0` |

### Aggregation triggers

One session per slot, started at interval 2 or earlier once a threshold is met.
Two roles trigger it:

| role | early threshold | jobs | subnet ordering |
|---|---|---|---|
| committee aggregator | 2/3 of signatures expected from subscribed subnets | up to `MAX_AGGREGATION_JOBS` from the subnet pool | `SlotOrdering::TierOnly` |
| next slot's proposer | `ceil(3K'/4)` heartbeat votes (the safe-target threshold) | exactly 1, over the heartbeat committee votes | `SlotOrdering::CurrentSlotFirst` (fallback only) |

The proposer's job is built by `heartbeat_fold::heartbeat_aggregation_snapshot`,
which picks the `AttestationData` with the most buffered committee signers and
reduces the raw signer set to `B \ A` (signers not already covered by an existing
type-1) before calling `aggregate_mixed`. It falls back to a single subnet job when
nothing is foldable. The result flows through the ordinary `AggregateProduced` path
into `new_payloads`, is promoted at interval 3, and reaches the builder as one
candidate among many; `Tier::Heartbeat` is what makes it win.

Heartbeat signatures are the proposer's alone: `heartbeat_aggregation_snapshot` is
the only reader of that buffer. An aggregator that does not propose the next slot
sees the same committee votes only where they duplicate into its subnet pool, and
`SlotOrdering::TierOnly` denies them the recency bucket there, so they are
aggregated only when they win on consensus value (Finalize > Justify > Build).
Recency is worth a queue jump only to the proposer, which is the one node that has
to pack those votes; everyone else already has them raw off the global topic.

`K` is `HEARTBEAT_COMMITTEE_SIZE` from the genesis config (default 16); `K' = min(K, n)`
and every threshold is denominated in `K'`, never the raw `K`. `N` is
`RLMD_LOOKBACK_LIMIT` (8). Heartbeat votes ride in `body.attestations` β€” there is no
dedicated `BlockBody` field β€” and are extracted on import by
`store::extract_heartbeat_votes`, whose `data.slot == block.slot - 1` gate exactly
mirrors the packer's `Tier::Heartbeat`.

### Attestation Pipeline
```
Gossip β†’ Signature verification β†’ new_payloads (pending)
Expand Down Expand Up @@ -275,7 +319,7 @@ actual_slot = finalized_slot + 1 + relative_index
### Protocols
- **Transport**: QUIC over UDP (TLS 1.3)
- **Gossipsub**: Blocks + Attestations (snappy raw compression)
- Topic: `/leanconsensus/{fork_digest}/{block|aggregation|attestation_N}/ssz_snappy`
- Topic: `/leanconsensus/{fork_digest}/{block|aggregation|heartbeat|attestation_N}/ssz_snappy`
- `fork_digest` is a 4-byte hex string (no `0x` prefix); currently the dummy `12345678` agreed across clients
- Mesh size: 8 (6-12 bounds), heartbeat: 700ms
- **Req/Resp**: Status, BlocksByRoot, BlocksByRange (snappy frame compression + varint length)
Expand Down
19 changes: 18 additions & 1 deletion bin/ethlambda/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -205,10 +205,22 @@ async fn main() -> eyre::Result<()> {
.filter(|url| !url.is_empty())
.collect();

let store = fetch_initial_state(&clean_checkpoint_urls, &genesis_config, backend.clone())
let mut store = fetch_initial_state(&clean_checkpoint_urls, &genesis_config, backend.clone())
.await
.inspect_err(|err| error!(%err, "Failed to initialize state"))?;

// Adopt the genesis config's HEARTBEAT_COMMITTEE_SIZE on first boot only; a
// persisted value wins thereafter, because committee membership decides which
// bits of an imported block count as heartbeat votes and a restart must not
// change that silently. A mismatch is warned about, not applied.
store
.reconcile_heartbeat_committee_size(genesis_config.heartbeat_committee_size)
.inspect_err(|err| error!(%err, "Failed to persist heartbeat committee size"))?;
info!(
heartbeat_committee_size = store.heartbeat_committee_size(),
"Heartbeat committee configured"
);

let validator_ids: Vec<u64> = validator_keys.keys().copied().collect();

// Shared, runtime-mutable aggregator flag. Seeded from the CLI and
Expand Down Expand Up @@ -250,6 +262,9 @@ async fn main() -> eyre::Result<()> {
proposer_config: ProposerConfig {
enable_proposer_aggregation: options.enable_proposer_aggregation,
max_attestations_per_block: options.max_attestations_per_block,
// Read once here rather than per build: the persisted value is
// authoritative from first boot and cannot change at runtime.
heartbeat_committee_size: store.heartbeat_committee_size(),
},
};

Expand Down Expand Up @@ -758,6 +773,7 @@ async fn fetch_initial_state(
mod tests {
use super::*;
use ethlambda_storage::backend::InMemoryBackend;
use ethlambda_types::constants::DEFAULT_HEARTBEAT_COMMITTEE_SIZE;
use ethlambda_types::genesis::GenesisValidatorEntry;

/// Validator-config snippet matching `lean-quickstart`'s ansible-devnet
Expand Down Expand Up @@ -883,6 +899,7 @@ validators:
fn test_genesis(genesis_time: u64) -> GenesisConfig {
GenesisConfig {
genesis_time,
heartbeat_committee_size: DEFAULT_HEARTBEAT_COMMITTEE_SIZE,
genesis_validators: vec![GenesisValidatorEntry {
attestation_pubkey: [1u8; 52],
proposal_pubkey: [2u8; 52],
Expand Down
Loading
Loading