Skip to content
Draft
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
17 changes: 15 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -824,8 +824,9 @@ transitions are in `ethlambda-types`, per the section above. Nothing above
payload via the execution-chain walk; an engine error keeps the envelope and
retries once a slot, probing with the oldest), and a gloas head sends
`engine_forkchoiceUpdatedV4` with the custody columns (head hash by the head
node's FULL or EMPTY status, finalized and safe from the checkpoint blocks'
`bid.parent_block_hash`, safe being a fallback for fast confirmation). Building
node's FULL or EMPTY status, finalized from the finalized block's and safe
from the Fast Confirmation Rule's confirmed block's `bid.parent_block_hash`).
Building
a gloas payload for `produceBlockV4` uses `PayloadAttributesV4` (V3's fields
plus `slot_number` and `target_gas_limit`, the parent bid's gas limit) through
`forkchoice_updated_v4_with_attributes` and `get_payload_v6`
Expand Down Expand Up @@ -859,6 +860,18 @@ transitions are in `ethlambda-types`, per the section above. Nothing above
[`docs/beacon_stf.md`](docs/beacon_stf.md) has the design and
[`docs/spec_deviations.md`](docs/spec_deviations.md) the deliberate departures
from the spec, in particular the fulu-to-gloas fork-choice boundary rule.
- **Fast Confirmation Rule** (`beacon/fast_confirmation.rs`, consensus-specs
`master` @ `f993cf2`, not the pinned release): run once per slot by the beacon
actor's tick after the head recompute and before `forkchoiceUpdated`; its
confirmed root is the engine's safe hash and feeds the `fast_confirmation`
event. State lives in `Store` (`fast_confirmation`), per-run sums are shared
in a `RunContext`. It runs on the actor, so it must stay about as cheap as
`get_head`: it reads the fork choice's caches rather than the registry or
every vote (balance sources from the per-checkpoint `JustifiedBalances`,
attestation scores from the proto-array when the source is the justified
checkpoint's), and `get_current_target_score` walks only current-epoch votes
(an older vote's root may be below the live index, where each parent hop
decodes a block). See [`docs/beacon_stf.md`](docs/beacon_stf.md#fast-confirmation-rule).
- Nothing is ignored for being unimplemented in the state transition or fork
choice. Ignored cases are the `LightClient*` containers (a different layer,
out of scope), `networking/gossip_*` cases outside what the node validates
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.

179 changes: 173 additions & 6 deletions crates/blockchain/src/beacon_payloads.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,28 @@ pub fn checkpoint_hash(store: &Store, root: H256) -> ExecutionBlockHash {
store.beacon_el_block_hash(root).unwrap_or(H256::ZERO)
}

/// `get_safe_execution_block_hash` (bellatrix `fast-confirmation.md`, modified
/// by gloas): the execution block hash of the most recent block the Fast
/// Confirmation Rule confirmed, which `forkchoiceUpdated` carries as the safe
/// block.
///
/// Bellatrix through fulu name the confirmed block's own payload hash; gloas
/// names its `bid.parent_block_hash`, since only the parent payload of a
/// confirmed beacon block is safe. [`checkpoint_hash`] is exactly that pair,
/// including for a pre-gloas block under a gloas head, and it answers zero for
/// a block with no known payload (a pre-bellatrix block), the way the
/// finalized hash does.
///
/// The rule has not run yet (or was reset while the node is syncing) when the
/// store holds no confirmed root, and then its own initialization applies: the
/// finalized block.
pub(crate) fn get_safe_execution_block_hash(store: &Store) -> ExecutionBlockHash {
let confirmed_root = store
.fast_confirmed_root()
.unwrap_or_else(|| store.beacon_finalized_checkpoint().root);
checkpoint_hash(store, confirmed_root)
}

/// What `forkchoiceUpdated` should carry for the current head, or `None` when
/// there is nothing to say yet.
pub(crate) struct ForkchoicePlan {
Expand All @@ -109,11 +131,10 @@ pub(crate) struct ForkchoicePlan {
/// hash is zero, as before.
///
/// Finalized: the finalized block's `bid.parent_block_hash` (spec). Safe: the
/// spec's `get_safe_execution_block_hash` is fast-confirmation based, which
/// this follower does not run, so the justified checkpoint block's
/// `bid.parent_block_hash` stands in (see `docs/spec_deviations.md`). A
/// pre-gloas checkpoint block, including one under a gloas head, uses its own
/// payload hash.
/// spec's `get_safe_execution_block_hash`, the payload hash of the most recent
/// block the Fast Confirmation Rule confirmed ([`get_safe_execution_block_hash`]).
/// Before the rule's first run its confirmed root is the finalized block, as
/// its initialization sets it.
pub(crate) fn forkchoice_plan(store: &Store, head_root: H256) -> Option<ForkchoicePlan> {
let head_block = store.get_signed_block(&head_root).ok().flatten();
let head_bid = head_block.as_ref().and_then(bid_of);
Expand All @@ -129,7 +150,7 @@ pub(crate) fn forkchoice_plan(store: &Store, head_root: H256) -> Option<Forkchoi
}
let state = ForkchoiceStateV1 {
head_block_hash,
safe_block_hash: checkpoint_hash(store, store.beacon_justified_checkpoint().root),
safe_block_hash: get_safe_execution_block_hash(store),
finalized_block_hash: checkpoint_hash(store, store.beacon_finalized_checkpoint().root),
};
Some(ForkchoicePlan {
Expand Down Expand Up @@ -424,3 +445,149 @@ impl BlockChainServer {
removed
}
}

#[cfg(test)]
mod tests {
use ethlambda_types::beacon::containers::{SignedBeaconBlock, gloas};
use ethlambda_types::beacon::fork_choice::FastConfirmationVariables;

use super::*;
use crate::tests::{beacon_store, fulu_block};

/// Makes `head_root` the store's head, which `head_payload_status` needs
/// to agree with before it answers. The zero anchor gets a block of its
/// own so the canonical-index diff can walk down to it.
fn make_head(store: &mut Store, head_root: H256, status: PayloadStatus) {
store
.insert_pending_block(H256::ZERO, fulu_block(H256::ZERO, 0, 0))
.expect("insert anchor");
store
.update_checkpoints(ethlambda_storage::ForkCheckpoints::head_only(head_root))
.expect("move head");
store.set_head_payload_status(head_root, status);
}

fn confirm(store: &Store, root: H256) {
let checkpoint = ethlambda_types::beacon::containers::Checkpoint { epoch: 0, root };
store.set_fast_confirmation(FastConfirmationVariables::at_finalized(checkpoint));
}

fn gloas_block(
slot: u64,
parent_root: H256,
parent_hash: H256,
hash: H256,
) -> SignedBeaconBlock {
SignedBeaconBlock::Gloas(gloas::SignedBeaconBlock {
message: gloas::BeaconBlock {
slot,
parent_root,
body: gloas::BeaconBlockBody {
signed_execution_payload_bid: gloas::SignedExecutionPayloadBid {
message: gloas::ExecutionPayloadBid {
parent_block_hash: parent_hash,
block_hash: hash,
..Default::default()
},
..Default::default()
},
..Default::default()
},
..Default::default()
},
signature: Default::default(),
})
}

/// `get_safe_execution_block_hash` before gloas: the confirmed block's own
/// payload hash, whatever the justified checkpoint says.
#[test]
fn safe_follows_the_confirmed_root_for_a_pre_gloas_head() {
let mut store = beacon_store(0, 0);
let (hash_1, hash_2) = (H256::repeat_byte(1), H256::repeat_byte(2));
let block_1 = fulu_block(H256::ZERO, 1, 0);
let root_1 = block_1.message_hash_tree_root();
let block_2 = fulu_block(root_1, 2, 0);
let root_2 = block_2.message_hash_tree_root();
for (root, block, slot, hash) in
[(root_1, block_1, 1, hash_1), (root_2, block_2, 2, hash_2)]
{
store.insert_pending_block(root, block).expect("insert");
store.insert_beacon_el_block_hash(root, slot, hash);
}

// The rule has not run: its own initialization, the finalized block
// (here the unknown zero anchor, which has no payload).
let plan = forkchoice_plan(&store, root_2).expect("a plan");
assert_eq!(plan.state.head_block_hash, hash_2);
assert_eq!(plan.state.safe_block_hash, plan.state.finalized_block_hash);

// Once it has, safe follows the confirmed root.
confirm(&store, root_1);
let plan = forkchoice_plan(&store, root_2).expect("a plan");
assert_eq!(plan.state.safe_block_hash, hash_1);
confirm(&store, root_2);
let plan = forkchoice_plan(&store, root_2).expect("a plan");
assert_eq!(plan.state.safe_block_hash, hash_2);
// Finalized is the checkpoint's, untouched.
assert_eq!(plan.state.finalized_block_hash, H256::ZERO);
assert!(!plan.gloas);
}

/// Gloas modifies it: only the parent payload of a confirmed beacon block
/// is safe, so the bid's `parent_block_hash`.
#[test]
fn safe_is_the_confirmed_blocks_parent_payload_for_a_gloas_head() {
let mut store = beacon_store(0, 0);
let (parent_hash, hash_1, hash_2) = (
H256::repeat_byte(0x10),
H256::repeat_byte(0x11),
H256::repeat_byte(0x12),
);
let block_1 = gloas_block(1, H256::ZERO, parent_hash, hash_1);
let root_1 = block_1.message_hash_tree_root();
let block_2 = gloas_block(2, root_1, hash_1, hash_2);
let root_2 = block_2.message_hash_tree_root();
store.insert_pending_block(root_1, block_1).expect("insert");
store.insert_pending_block(root_2, block_2).expect("insert");
make_head(&mut store, root_2, PayloadStatus::Full);

confirm(&store, root_2);
let plan = forkchoice_plan(&store, root_2).expect("a plan");
assert!(plan.gloas);
assert_eq!(plan.state.head_block_hash, hash_2, "a FULL head");
assert_eq!(
plan.state.safe_block_hash, hash_1,
"the confirmed bid's parent"
);

confirm(&store, root_1);
let plan = forkchoice_plan(&store, root_2).expect("a plan");
assert_eq!(plan.state.safe_block_hash, parent_hash);
}

/// A pre-gloas confirmed block under a gloas head keeps its own payload
/// hash, as the finalized hash does for a pre-gloas finalized block.
#[test]
fn a_pre_gloas_confirmed_block_under_a_gloas_head_uses_its_own_hash() {
let mut store = beacon_store(0, 0);
let fulu_hash = H256::repeat_byte(0x20);
let fulu = fulu_block(H256::ZERO, 1, 0);
let fulu_root = fulu.message_hash_tree_root();
store.insert_pending_block(fulu_root, fulu).expect("insert");
store.insert_beacon_el_block_hash(fulu_root, 1, fulu_hash);
let gloas = gloas_block(2, fulu_root, fulu_hash, H256::repeat_byte(0x21));
let gloas_root = gloas.message_hash_tree_root();
store
.insert_pending_block(gloas_root, gloas)
.expect("insert");
make_head(&mut store, gloas_root, PayloadStatus::Full);

confirm(&store, fulu_root);
let plan = forkchoice_plan(&store, gloas_root).expect("a plan");
assert_eq!(plan.state.safe_block_hash, fulu_hash);
// A confirmed block with no known payload is zero, like finalized.
confirm(&store, H256::repeat_byte(0x99));
assert_eq!(get_safe_execution_block_hash(&store), H256::ZERO);
}
}
69 changes: 69 additions & 0 deletions crates/blockchain/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ pub enum Topic {
ExecutionPayloadAvailable,
ExecutionPayloadBid,
PayloadAttestationMessage,
/// Beacon only, and one of the few on this side of the line that is
/// emitted: once per run of the Fast Confirmation Rule.
FastConfirmation,
ProposerPreferences,
}
Expand Down Expand Up @@ -309,6 +311,8 @@ pub enum ChainEvent {
BeaconAttestation(Box<BeaconAttestationEvent>),
/// Beacon `data_column_sidecar`: a column this node custodies was stored.
DataColumnSidecar(DataColumnSidecarEvent),
/// Beacon `fast_confirmation`: the Fast Confirmation Rule ran.
BeaconFastConfirmation(BeaconFastConfirmationEvent),
}

impl ChainEvent {
Expand All @@ -325,6 +329,7 @@ impl ChainEvent {
ChainEvent::Aggregate { .. } => Topic::Aggregate,
ChainEvent::ChainReorg { .. } | ChainEvent::BeaconChainReorg(_) => Topic::ChainReorg,
ChainEvent::DataColumnSidecar(_) => Topic::DataColumnSidecar,
ChainEvent::BeaconFastConfirmation(_) => Topic::FastConfirmation,
}
}
}
Expand Down Expand Up @@ -421,6 +426,20 @@ impl From<&SignedAggregateAndProof> for BeaconAttestationEvent {
}
}

/// The Beacon API `fast_confirmation` payload.
///
/// Emitted every time the rule runs, whether or not the confirmed block
/// changed: `block` and `slot` are the most recent confirmed block's, and
/// `current_slot` is the wall-clock slot of the run.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct BeaconFastConfirmationEvent {
pub block: H256,
#[serde(serialize_with = "quoted_or_bare::serialize")]
pub slot: u64,
#[serde(serialize_with = "quoted_or_bare::serialize")]
pub current_slot: u64,
}

/// The Beacon API `data_column_sidecar` payload.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct DataColumnSidecarEvent {
Expand Down Expand Up @@ -601,6 +620,17 @@ impl ChainEvents {
self.bus.has_subscribers().then_some(previous)
}

/// Beacon `fast_confirmation` for one run of the Fast Confirmation Rule.
pub(crate) fn publish_fast_confirmation(&self, block: H256, slot: u64, current_slot: u64) {
self.bus.emit(ChainEvent::BeaconFastConfirmation(
BeaconFastConfirmationEvent {
block,
slot,
current_slot,
},
));
}

/// `block`: `block` was imported at `slot`, in the store's chain's shape.
pub(crate) fn publish_block(&self, store: &Store, slot: u64, block: H256) {
let event = match store.chain() {
Expand Down Expand Up @@ -1511,6 +1541,45 @@ mod tests {
);
}

#[test]
fn beacon_fast_confirmation_matches_the_specification_example() {
let event = ChainEvent::BeaconFastConfirmation(BeaconFastConfirmationEvent {
block: root("0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2"),
slot: 1,
current_slot: 2,
});
assert_matches_example(
event,
Topic::FastConfirmation,
r#"{"block": "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", "slot": "1", "current_slot": "2"}"#,
);
}

#[test]
fn a_fast_confirmation_run_is_published_even_when_nothing_changed() {
let store = crate::tests::beacon_store(0, 0);
let events = ChainEvents::new(EventBus::default(), &store);
let mut receiver = events.subscribe();
let block = H256::repeat_byte(5);
events.publish_fast_confirmation(block, 7, 9);
events.publish_fast_confirmation(block, 7, 10);
for current_slot in [9, 10] {
match receiver.try_recv().expect("an event per run") {
ChainEvent::BeaconFastConfirmation(event) => {
assert_eq!(
event,
BeaconFastConfirmationEvent {
block,
slot: 7,
current_slot
}
);
}
other => panic!("unexpected event {other:?}"),
}
}
}

#[test]
fn beacon_block_gossip_matches_the_specification_example() {
let event = ChainEvent::BeaconBlockGossip(BeaconBlockGossipEvent {
Expand Down
3 changes: 3 additions & 0 deletions crates/blockchain/src/import_timing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,9 @@ pub struct CascadeTimings {
pub struct HeadTimings {
pub head_start: Option<Instant>,
pub head_end: Option<Instant>,
/// Whether fork choice failed to find a head, leaving the stored one
/// where it was. The Fast Confirmation Rule is not run on a stale head.
pub head_failed: bool,
pub fcu_start: Option<Instant>,
pub fcu_end: Option<Instant>,
}
Expand Down
Loading
Loading