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
8304b37
refactor(blockchain): group on_tick conditionals by interval
MegaRedHand Jun 19, 2026
10e209f
docs: remove unnecessary comment
MegaRedHand Jun 19, 2026
8459726
docs: add closing interval-4 marker in on_tick
MegaRedHand Jun 19, 2026
d003f85
refactor(blockchain): gate proposal at the call site, ungate the on_t…
MegaRedHand Jun 19, 2026
7db241f
Merge branch 'main' into refactor/group-on-tick-by-interval
MegaRedHand Jun 22, 2026
2c6eb1c
feat(blockchain): pre-build proposer block one interval early
MegaRedHand Jun 22, 2026
d940222
Merge branch 'main' into feat/proposer-prebuild-aggregation
MegaRedHand Jun 22, 2026
2733099
Merge branch 'main' into feat/proposer-prebuild-aggregation
MegaRedHand Jun 23, 2026
1e58c02
fix(blockchain): publish proposer block at interval 4, aligned to slo…
MegaRedHand Jun 23, 2026
3b453d1
Merge branch 'main' into feat/proposer-prebuild-aggregation
MegaRedHand Jun 23, 2026
e48f43c
docs(blockchain): clarify interval-4 proposal after review
MegaRedHand Jun 23, 2026
f34e81c
fix(blockchain): drop the proposer's interval-0 attestation accept
MegaRedHand Jun 23, 2026
337f2d8
docs: interval 0 has no duty after dropping its attestation accept
MegaRedHand Jun 23, 2026
993e606
docs: clarify interval-0 publish; drop its attestation accept
MegaRedHand Jun 23, 2026
59ce2e0
refactor(blockchain): advance store to interval 0 in the prebuild
MegaRedHand Jun 23, 2026
31f129a
Merge branch 'main' into feat/proposer-prebuild-aggregation
MegaRedHand Jun 24, 2026
805a587
Merge branch 'main' into feat/proposer-prebuild-aggregation
MegaRedHand Jun 24, 2026
f4ecf95
test: remove useless unit test
MegaRedHand Jun 24, 2026
0d5e8d4
refactor: inline function
MegaRedHand Jun 24, 2026
854c632
refactor: remove dead code
MegaRedHand Jun 24, 2026
f7a2a2d
refactor(blockchain): inline block build/assemble into propose_block
MegaRedHand Jun 24, 2026
ac48403
refactor(blockchain): keep get_our_proposer/produce_attestations abov…
MegaRedHand Jun 25, 2026
255109c
refactor(blockchain): rename produce_block_on_head back to produce_bl…
MegaRedHand Jun 25, 2026
f14751b
docs: update architecture infographic for produce_block_with_signatur…
MegaRedHand Jun 25, 2026
747655f
refactor(blockchain): move interval-0 advance back into produce_block…
MegaRedHand Jun 25, 2026
3c89678
refactor: remove unused clone
MegaRedHand Jun 25, 2026
1a02073
Merge branch 'main' into feat/proposer-prebuild-aggregation
MegaRedHand Jun 25, 2026
1e92b05
Apply suggestion from @pablodeymo
pablodeymo Jun 25, 2026
c091517
refactor: address review comments
MegaRedHand Jun 25, 2026
8068337
Merge branch 'main' into feat/proposer-prebuild-aggregation
MegaRedHand Jun 25, 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
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,17 +47,17 @@ crates/

### Tick-Based Validator Duties (4-second slots, 5 intervals per slot)
```
Interval 0: Block proposal → accept attestations if proposal exists
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
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)
```

### Attestation Pipeline
```
Gossip → Signature verification → new_attestations (pending)
↓ (intervals 0/4)
↓ (interval 4)
promote → known_attestations (fork choice active)
Fork choice head update
Expand Down
199 changes: 132 additions & 67 deletions crates/blockchain/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -252,71 +252,96 @@ impl BlockChainServer {
self.pre_merge_coverage = Some(snapshot);
}

let scheduled_proposer = (interval == 0 && slot > 0)
// Whether one of our validators proposes this slot. Drives the store's
// interval-0 attestation acceptance.
let is_proposer = (interval == 0 && slot > 0)
.then(|| self.get_our_proposer(slot))
.flatten();
let is_proposer = scheduled_proposer.is_some();
.flatten()
.is_some();

// Tick the store first - this accepts attestations at interval 0 if we have a proposal
store::on_tick(&mut self.store, timestamp_ms, is_proposer);

// ==== interval 0 ====

// Now build and publish the block (after attestations have been accepted)
if let Some(validator_id) = scheduled_proposer {
if self.sync_status.duties_allowed() {
self.propose_block(slot, validator_id);
} else {
info!(%slot, %validator_id, "Skipping block proposal while syncing");
// Per-interval duties for this tick. Intervals 0 (block publish) and 3
// (safe-target update) are driven inside `store::on_tick` above, so they
// carry only a note below.
match interval {
// ==== interval 0 ====
//
// No actor work at interval 0. The block is published here
// conceptually (at the slot boundary), but the build+publish code
// path runs at interval 4 of the previous slot — where it also
// advances the store to this slot's interval 0 before building (see
// `propose_block`). The real interval-0 tick is then skipped by the
// idempotency guard above, since the store clock is already here.
0 => {}

// ==== interval 1 ====
//
// Produce attestations at interval 1 (all validators including
// proposer). Reuse the same snapshot so self-delivery decisions
// match the rest of the tick.
1 => {
// Emit the post-block coverage report for the previous slot.
// Fired at interval 1 (not 0) so the block carrying `slot - 1`'s
// votes — proposed at interval 0 of this slot — has typically
// been received and processed, letting the `block` section see
// the same round.
if slot > 0 {
coverage::emit_post_block_coverage(
&self.store,
self.pre_merge_coverage.as_ref(),
self.attestation_committee_count,
slot - 1,
);
}
if self.sync_status.duties_allowed() {
self.produce_attestations(slot, is_aggregator);
} else if !self.key_manager.validator_ids().is_empty() {
info!(%slot, "Skipping attestations while syncing");
}
}
}

// ==== interval 1 ====

// Produce attestations at interval 1 (all validators including proposer).
// Reuse the same snapshot so self-delivery decisions match the rest
// of the tick.
if interval == 1 {
// Emit the post-block coverage report for the previous slot. Fired
// at interval 1 (not 0) so the block carrying `slot - 1`'s votes —
// proposed at interval 0 of this slot — has typically been received
// and processed, letting the `block` section see the same round.
if slot > 0 {
coverage::emit_post_block_coverage(
&self.store,
self.pre_merge_coverage.as_ref(),
self.attestation_committee_count,
slot - 1,
);
}
if self.sync_status.duties_allowed() {
self.produce_attestations(slot, is_aggregator);
} else if !self.key_manager.validator_ids().is_empty() {
info!(%slot, "Skipping attestations while syncing");
// ==== interval 2 ====
2 => {
if is_aggregator {
coverage::emit_agg_start_new_coverage(
&self.store,
self.attestation_committee_count,
);
self.start_aggregation_session(slot, ctx).await;
} else {
metrics::inc_aggregator_skipped_not_aggregator();
}
}
}

// ==== interval 2 ====

if interval == 2 {
if is_aggregator {
coverage::emit_agg_start_new_coverage(
&self.store,
self.attestation_committee_count,
);
self.start_aggregation_session(slot, ctx).await;
} else {
metrics::inc_aggregator_skipped_not_aggregator();
// ==== interval 3 ====
//
// Safe-target update is handled inside `store::on_tick`.
3 => {}

// ==== interval 4 ====
//
// Build and publish the NEXT slot's block here, one interval early,
// so the heavy leanVM work happens during this otherwise-idle
// interval. `propose_block` blocks the actor for the build and aligns
// publication to the slot boundary. Doing the whole proposal here —
// rather than stashing it for the interval-0 tick — keeps it robust:
// `on_tick` skips the interval-0 tick whenever this build overruns
// its interval.
4 => {
let next_slot = slot + 1;
let next_proposer = self
.get_our_proposer(next_slot)
.filter(|_| self.sync_status.duties_allowed());

if let Some(validator_id) = next_proposer {
self.propose_block(next_slot, validator_id).await;
}
}
}

// ==== interval 3 ====

// Interval 3 (safe-target update) is handled inside `store::on_tick`.

// ==== interval 4 ====

// Handled by the pre-tick snapshot above.
_ => {}
}

// Update safe target slot metric (updated by store.on_tick at interval 3)
metrics::update_safe_target_slot(self.store.safe_target_slot());
Expand Down Expand Up @@ -441,13 +466,33 @@ impl BlockChainServer {
}
}

/// Build and publish a block for the given slot and validator.
fn propose_block(&mut self, slot: u64, validator_id: u64) {
/// Build the target slot's block and publish it, one interval early.
///
/// Runs at the previous slot's interval 4, blocking the actor for the build
/// (the expensive part is the leanVM Type-1 → Type-2 merge). It first
/// advances the store to the target slot's interval 0 (accepting
/// attestations) so the block is built on exactly the interval-0 state a
/// non-prebuilding proposer would see, then builds and publishes — aligned
/// to the slot boundary: if the build finishes before the slot opens we wait
/// out the remainder so the block is not published early; if it overran (the
/// common case under load) we publish at once. The whole proposal is
/// self-contained here, so it never depends on the interval-0 tick — which
/// `handle_tick` skips whenever this build overruns its interval.
async fn propose_block(&mut self, slot: u64, validator_id: u64) {
info!(%slot, %validator_id, "We are the proposer for this slot");

let _timing = metrics::time_block_building();

// Build the block with attestation signatures
let genesis_time_ms = self.store.config().genesis_time * 1000;
let slot_start_ms = genesis_time_ms + slot * MILLISECONDS_PER_SLOT;

// Build the block. `produce_block_with_signatures` advances the store to
// this slot's interval 0 (accepting attestations) before building — one
// interval ahead of the interval-4 tick we are running in — so the block
// is built on the interval-0 state rather than the previous slot's end
// state. Building early is safe because we publish below (nothing is
// stashed for a later tick), and the real interval-0 tick is then skipped
// by the idempotency guard in `on_tick`, since the store clock is already
// here.
let timing = metrics::time_block_building();
let Ok((block, type_one_proofs, _post_checkpoints)) =
store::produce_block_with_signatures(&mut self.store, slot, validator_id)
.inspect_err(|err| error!(%slot, %validator_id, %err, "Failed to build block"))
Expand All @@ -473,9 +518,8 @@ impl BlockChainServer {
return;
};

// Assemble SignedBlock: wrap the proposer's raw XMSS signature into a
// singleton Type-1 SNARK, then merge it with every attestation Type-1
// into the block's single Type-2 proof.
// Wrap the proposer's raw XMSS signature into a singleton Type-1 SNARK,
// then merge it with every attestation Type-1 into the single Type-2.
let head_state = self.store.head_state();
let validators = &head_state.validators;
let Some(proposer_validator) = validators.get(validator_id as usize) else {
Expand Down Expand Up @@ -565,23 +609,44 @@ impl BlockChainServer {
return;
}
};
// `type_one_proofs` is no longer needed past this point.
drop(type_one_proofs);
let signed_block = SignedBlock {
message: block,
proof,
};

// Process the block locally before publishing
// Stop timing here: the build is done, and the alignment wait below must
// not count toward the block-building metric.
drop(timing);

let now_ms = unix_now_ms();

// Align publication to the slot boundary. If the build finished before
// the slot opened, wait out the remainder so the block is not published
// early; if it overran, publish immediately.
if now_ms < genesis_time_ms + slot * crate::MILLISECONDS_PER_SLOT {
let wait_ms = slot_start_ms.saturating_sub(now_ms);
tokio::time::sleep(Duration::from_millis(wait_ms)).await;
}

self.process_and_publish_block(slot, validator_id, signed_block);
}

/// Import a freshly built block locally, then publish it to gossip. On
/// import failure, logs and counts it, and returns without publishing.
fn process_and_publish_block(
&mut self,
slot: u64,
validator_id: u64,
signed_block: SignedBlock,
) {
if let Err(err) = self.process_block(signed_block.clone()) {
error!(%slot, %validator_id, %err, "Failed to process built block");
metrics::inc_block_building_failures();
return;
};
}

metrics::inc_block_building_success();

// Publish to gossip network
if let Some(ref p2p) = self.p2p {
let _ = p2p
.publish_block(signed_block)
Expand Down
3 changes: 1 addition & 2 deletions crates/blockchain/src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -797,8 +797,7 @@ pub fn produce_block_with_signatures(
.ok_or(StoreError::MissingParentState {
parent_root: head_root,
slot,
})?
.clone();
})?;

// Validate proposer authorization for this slot
let num_validators = head_state.validators.len() as u64;
Expand Down