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
347 changes: 330 additions & 17 deletions crates/blockchain/src/aggregation.rs

Large diffs are not rendered by default.

1,082 changes: 1,006 additions & 76 deletions crates/blockchain/src/block_builder.rs

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion crates/blockchain/src/reaggregate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@
//! 1. Only deconstructing when the chain is in sync — backfilling nodes
//! must not flood gossip with rederived aggregates.
//! 2. Skipping attestations whose target is at or behind the store's
//! justified checkpoint — they carry no fork-choice value.
//! justified checkpoint. Note this is NOT because such a vote is worthless:
//! selection deliberately packs one for the LMD-GHOST weight it carries.
//! It is because the vote is already ON CHAIN, in the very block being
//! imported, so splitting it back into the pool would let it be repacked
//! indefinitely while paying a SNARK for each round.
//! 3. Skipping attestations whose participants are already a subset of the
//! local union for that data — nothing to recover.
//! 4. Capping the number of splits per imported block at
Expand Down
25 changes: 22 additions & 3 deletions crates/blockchain/src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ use tracing::{info, trace, warn};

use crate::{
GOSSIP_DISPARITY_INTERVALS, INTERVALS_PER_SLOT, MAX_ATTESTATIONS_DATA, SlotInterval,
block_builder::{PostBlockCheckpoints, ProposerConfig, build_block},
block_builder::{
HEAD_VOTE_WINDOW_BLOCKS, PostBlockCheckpoints, ProposalInputs, ProposerConfig, build_block,
},
metrics,
};

Expand Down Expand Up @@ -968,15 +970,32 @@ pub fn produce_block_with_signatures(

let known_block_roots = store.get_block_roots().unwrap();

// The recent blocks of the branch we are extending and the votes they
// carry, so selection can value an entry for the head weight it would ADD
// and not only for the justification voters it brings.
//
// Deliberately not `extract_latest_known_attestations`: that map is written
// in lockstep with the aggregated-payload pool this block is built from, so
// every entry would score zero new head voters and the axis would be dead.
// And read from `head_root` rather than kept as a running map, because a
// map fed by every block import cannot tell a vote this branch carries from
// one a sibling we abandoned carried.
let head_window = store.extract_head_vote_window(head_root, HEAD_VOTE_WINDOW_BLOCKS);

let inputs = ProposalInputs {
known_block_roots: &known_block_roots,
aggregated_payloads: &aggregated_payloads,
head_window,
};

let (block, signatures, post_checkpoints) = {
let _timing = metrics::time_block_building_payload_aggregation();
build_block(
&head_state,
slot,
validator_index,
head_root,
&known_block_roots,
&aggregated_payloads,
inputs,
config,
)?
};
Expand Down
72 changes: 72 additions & 0 deletions crates/blockchain/state_transition/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -970,6 +970,78 @@ mod tests {
);
}

/// A vote whose target is already justified must be SKIPPED, not rejected:
/// `is_valid_vote` returns `Ok(false)` and the loop does `continue`, so the
/// block carrying it still applies cleanly.
///
/// The block builder relies on this. It deliberately packs such votes,
/// because they carry no justification value but still move LMD-GHOST
/// (`insert_signed_block` records every attestation a block carries as a
/// fork-choice vote, whatever this function decides). If the transition
/// ever started erroring here instead, every proposer packing a settled
/// target would produce blocks the network rejects.
#[test]
fn process_attestations_skips_an_already_justified_target_without_rejecting_the_block() {
const NUM_VALIDATORS: usize = 4;
let r1 = H256([1u8; 32]);
let r2 = H256([2u8; 32]);

let mut justified_slots = JustifiedSlots::new();
justified_slots_ops::extend_to_slot(&mut justified_slots, 0, 1);
justified_slots_ops::set_justified(&mut justified_slots, 0, 1);

let mut state = State {
config: StateConfig { genesis_time: 0 },
slot: 3,
latest_block_header: BlockHeader {
slot: 2,
proposer_index: 0,
parent_root: r1,
state_root: H256::ZERO,
body_root: BlockBody::default().hash_tree_root(),
},
latest_justified: Checkpoint { slot: 1, root: r1 },
latest_finalized: Checkpoint {
slot: 0,
root: H256::ZERO,
},
historical_block_hashes: SszList::try_from(vec![H256::ZERO, r1, r2]).unwrap(),
justified_slots,
validators: SszList::try_from(make_validators(NUM_VALIDATORS)).unwrap(),
justifications_roots: SszList::try_from(vec![]).unwrap(),
justifications_validators: JustificationValidators::new(),
};

// Target slot 1 is already justified above; source is genesis.
let vote = AggregatedAttestation {
aggregation_bits: make_bits(&[0, 1, 2], NUM_VALIDATORS),
data: AttestationData {
slot: 2,
head: Checkpoint { slot: 1, root: r1 },
target: Checkpoint { slot: 1, root: r1 },
source: Checkpoint {
slot: 0,
root: H256::ZERO,
},
},
};

let before = state.latest_justified;
let atts: AggregatedAttestations = vec![vote].try_into().unwrap();

process_attestations(&mut state, &atts)
.expect("an already-justified target is skipped, not an error");

assert_eq!(
state.latest_justified, before,
"the skipped vote must not move justification"
);
assert!(
state.justifications_roots.is_empty(),
"the skipped vote must not open a tally for a settled target"
);
}

/// leanSpec #1178: `process_attestations` on a state with no validators is
/// rejected with a typed error. Belt-and-suspenders: the header stage already
/// rejects an empty registry first in the normal flow, but the flat-vote
Expand Down
68 changes: 68 additions & 0 deletions crates/common/types/src/attestation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,28 @@ pub struct AttestationData {
pub source: Checkpoint,
}

impl AttestationData {
/// Whether this vote supersedes `other` as a validator's latest message.
///
/// The LMD-GHOST latest-message rule: the later slot wins, and a tie is
/// broken by data root. Breaking the tie on a total order rather than on
/// arrival matters because the seen-votes map is written from more than
/// one place (block import, gossip payload insertion, the aggregation
/// worker), so an order-dependent rule would let two nodes that saw the
/// same votes in different orders disagree about the head. The on-chain
/// vote map is the exception, written only on block import, and relies on
/// the same total order to stay independent of import interleaving.
///
/// Lives here rather than beside fork choice because the vote map is
/// maintained in the storage layer, which does not depend on the fork
/// choice crate; `ethlambda-types` is what storage, blockchain and fork
/// choice all share.
pub fn supersedes(&self, other: &AttestationData) -> bool {
self.slot > other.slot
|| (self.slot == other.slot && self.hash_tree_root() > other.hash_tree_root())
}
}

/// Validator attestation bundled with its signature.
///
/// <div class="warning">
Expand Down Expand Up @@ -205,6 +227,52 @@ impl From<AttestationData> for HashedAttestationData {
mod tests {
use super::*;

fn att_data(slot: u64, head_root: u8) -> AttestationData {
AttestationData {
slot,
head: Checkpoint {
slot,
root: H256([head_root; 32]),
},
target: Checkpoint::default(),
source: Checkpoint::default(),
}
}

#[test]
fn supersedes_prefers_the_later_slot() {
let earlier = att_data(4, 1);
let later = att_data(5, 1);

assert!(later.supersedes(&earlier));
assert!(!earlier.supersedes(&later));
}

#[test]
fn supersedes_is_irreflexive() {
let vote = att_data(4, 1);

assert!(
!vote.supersedes(&vote),
"a vote does not replace an identical one, or record_vote would clone every duplicate"
);
}

/// Same slot: the data root decides, so two nodes that saw the same votes
/// in different orders still agree on which one is a validator's latest.
#[test]
fn supersedes_breaks_a_slot_tie_on_data_root_and_is_antisymmetric() {
let a = att_data(4, 1);
let b = att_data(4, 2);

assert_ne!(a.hash_tree_root(), b.hash_tree_root());
assert_eq!(
a.supersedes(&b),
!b.supersedes(&a),
"exactly one of the two must win the tie"
);
}

/// Build an `AggregationBits` of `len` bits with the indices in `set` flipped on.
fn bits(len: usize, set: &[usize]) -> AggregationBits {
let mut b = AggregationBits::with_length(len).unwrap();
Expand Down
3 changes: 2 additions & 1 deletion crates/storage/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@ pub use api::{ALL_TABLES, StorageBackend, StorageReadView, StorageWriteBatch, Ta
/// callers can match on it (e.g. to distinguish [`Error::GenesisMismatch`]).
pub use error::Error;
pub use store::{
ForkCheckpoints, GetForkchoiceStoreError, MAX_RESUMABLE_DB_STATE_AGE, NEW_PAYLOAD_CAP, Store,
ForkCheckpoints, GetForkchoiceStoreError, HeadVoteWindow, MAX_RESUMABLE_DB_STATE_AGE,
NEW_PAYLOAD_CAP, Store,
};
Loading
Loading