Skip to content
Merged
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
10 changes: 10 additions & 0 deletions bin/ethlambda/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,4 +97,14 @@ pub(crate) struct CliOptions {
/// coverage.
#[arg(long, default_value = "false")]
pub(crate) enable_proposer_aggregation: bool,
/// Maximum number of distinct attestations to pack when building a block.
///
/// Bounds how many distinct `AttestationData` entries the proposer includes
/// in a block it builds. This is a proposer-side self-limit only: it does
/// NOT change the consensus cap for accepting blocks from peers, which
/// stays at `MAX_ATTESTATIONS_DATA`. Values above `MAX_ATTESTATIONS_DATA`
/// are clamped to it, since a block carrying more would be rejected by
/// `on_block`.
#[arg(long, default_value = "3")]
pub(crate) max_attestations_per_block: usize,
Comment thread
pablodeymo marked this conversation as resolved.
}
6 changes: 5 additions & 1 deletion bin/ethlambda/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ use tokio_util::sync::CancellationToken;
use clap::Parser;
use cli::CliOptions;
use ethlambda_blockchain::MILLISECONDS_PER_SLOT;
use ethlambda_blockchain::block_builder::ProposerConfig;
use ethlambda_blockchain::key_manager::ValidatorKeyPair;
use ethlambda_network_api::{InitBlockChain, InitP2P, ToBlockChainToP2PRef, ToP2PToBlockChainRef};
use ethlambda_p2p::{Bootnode, P2P, PeerId, SwarmConfig, build_swarm, parse_enrs};
Expand Down Expand Up @@ -215,7 +216,10 @@ async fn main() -> eyre::Result<()> {
aggregator.clone(),
attestation_committee_count,
!options.disable_duty_sync_gate,
options.enable_proposer_aggregation,
ProposerConfig {
enable_proposer_aggregation: options.enable_proposer_aggregation,
max_attestations_per_block: options.max_attestations_per_block,
},
);

// Note: SwarmConfig.is_aggregator is intentionally a plain bool, not the
Expand Down
189 changes: 179 additions & 10 deletions crates/blockchain/src/block_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,21 @@ pub struct PostBlockCheckpoints {
pub finalized: Checkpoint,
}

/// Proposer-side block-building policy, seeded from the CLI at spawn.
#[derive(Debug, Clone, Copy)]
pub struct ProposerConfig {
/// How the proposer collapses same-`AttestationData` proofs (a block may
/// carry at most one entry per data). When true, they are merged via
/// recursive single-message aggregation into a union-coverage proof
/// (leanSpec #510); when false, only the single best-coverage proof per
/// data is kept, skipping the leanVM work.
pub enable_proposer_aggregation: bool,
/// Maximum number of distinct attestations to pack into a built block.
/// Proposer-side self-limit only; clamped to `MAX_ATTESTATIONS_DATA` during
/// selection so the block never exceeds the cap `on_block` enforces.
pub max_attestations_per_block: usize,
}

/// Build a valid block on top of this state.
///
/// Selects attestations via `select_attestations`, collapses entries sharing
Expand All @@ -62,14 +77,19 @@ pub struct PostBlockCheckpoints {
///
/// Either way the output has one entry per `AttestationData` and the
/// attestation-to-proof correspondence stays 1:1.
///
/// `config.max_attestations_per_block` bounds how many distinct
/// `AttestationData` entries are packed (a proposer-side self-limit). It is
/// clamped to `MAX_ATTESTATIONS_DATA` so the block never exceeds the cap
/// `on_block` enforces on incoming blocks.
pub(crate) fn build_block(
head_state: &State,
slot: u64,
proposer_index: u64,
parent_root: H256,
known_block_roots: &HashSet<H256>,
aggregated_payloads: &HashMap<H256, (AttestationData, Vec<SingleMessageAggregate>)>,
enable_proposer_aggregation: bool,
config: ProposerConfig,
) -> Result<(Block, Vec<SingleMessageAggregate>, PostBlockCheckpoints), StoreError> {
info!(slot, proposer_index, "Building block");

Expand All @@ -80,6 +100,7 @@ pub(crate) fn build_block(
parent_root,
known_block_roots,
aggregated_payloads,
config.max_attestations_per_block,
);
metrics::observe_block_proposal_phase("select_payloads", select_start.elapsed());

Expand All @@ -93,7 +114,7 @@ pub(crate) fn build_block(
// work and keep only the single best-coverage proof per data. Both paths
// log the entry / unique-entry counts they already compute.
let compact_start = Instant::now();
let compacted = if enable_proposer_aggregation {
let compacted = if config.enable_proposer_aggregation {
compact_attestations(selected, head_state, slot)?
} else {
keep_best_proof_per_data(selected, slot)
Expand Down Expand Up @@ -143,15 +164,16 @@ pub(crate) fn build_block(
/// finalization are projected incrementally so dependent attestations become
/// eligible on the next round without re-running the STF.
///
/// Stops at `MAX_ATTESTATIONS_DATA` distinct data entries or when no
/// remaining candidate has a positive score. Within-entry proof selection is
/// delegated to `extend_proofs_greedily`.
/// Stops at `max_attestations_per_block` distinct data entries (clamped to
/// `MAX_ATTESTATIONS_DATA`) or when no remaining candidate has a positive
/// score. Within-entry proof selection is delegated to `extend_proofs_greedily`.
fn select_attestations(
head_state: &State,
slot: u64,
parent_root: H256,
known_block_roots: &HashSet<H256>,
aggregated_payloads: &HashMap<H256, (AttestationData, Vec<SingleMessageAggregate>)>,
max_attestations_per_block: usize,
) -> Vec<(AggregatedAttestation, SingleMessageAggregate)> {
let mut selected: Vec<(AggregatedAttestation, SingleMessageAggregate)> = Vec::new();
if aggregated_payloads.is_empty() {
Expand Down Expand Up @@ -186,7 +208,10 @@ fn select_attestations(
};
let mut processed_data_roots: HashSet<H256> = HashSet::new();

for _round in 0..MAX_ATTESTATIONS_DATA {
// A block may carry at most `MAX_ATTESTATIONS_DATA` distinct entries
// (`on_block` rejects more), so the proposer-side limit never exceeds it.
let max_rounds = max_attestations_per_block.min(MAX_ATTESTATIONS_DATA);
for _round in 0..max_rounds {
let Some((data_root, score, new_voters)) =
pick_best_candidate(&chain, &processed_data_roots, &projected)
else {
Expand Down Expand Up @@ -991,7 +1016,10 @@ mod tests {
parent_root,
&known_block_roots,
&aggregated_payloads,
true,
ProposerConfig {
enable_proposer_aggregation: true,
max_attestations_per_block: MAX_ATTESTATIONS_DATA,
},
)
.expect("build_block should succeed");

Expand Down Expand Up @@ -1028,6 +1056,138 @@ mod tests {
);
}

/// A proposer-side `max_attestations_per_block` below `MAX_ATTESTATIONS_DATA`
/// must cap how many distinct `AttestationData` entries the built block
/// carries. The pool holds more selectable entries than the limit, so the
/// limit (not candidate exhaustion) is the binding constraint: building the
/// same pool at `MAX_ATTESTATIONS_DATA` packs strictly more.
#[test]
fn build_block_respects_configured_attestation_limit() {
use ethlambda_types::{
block::BlockHeader,
state::{ChainConfig, JustificationValidators, JustifiedSlots},
};
use libssz_types::SszList;

const NUM_VALIDATORS: usize = 50;
const NUM_PAYLOAD_ENTRIES: usize = 10;
const CONFIGURED_LIMIT: usize = 3;

const HEAD_SLOT: u64 = 51;
const TARGET_SLOT: u64 = 5;

let validators: Vec<_> = (0..NUM_VALIDATORS)
.map(|i| ethlambda_types::state::Validator {
attestation_pubkey: [i as u8; 52],
proposal_pubkey: [i as u8; 52],
index: i as u64,
})
.collect();

let hashes: Vec<H256> = (0..HEAD_SLOT).map(|i| H256([(i + 1) as u8; 32])).collect();
let historical_block_hashes = SszList::try_from(hashes.clone()).unwrap();

let head_header = BlockHeader {
slot: HEAD_SLOT,
proposer_index: 0,
parent_root: H256::ZERO,
state_root: H256::ZERO,
body_root: BlockBody::default().hash_tree_root(),
};

let head_state = State {
config: ChainConfig { genesis_time: 1000 },
slot: HEAD_SLOT,
latest_block_header: head_header,
latest_justified: Checkpoint::default(),
latest_finalized: Checkpoint::default(),
historical_block_hashes,
justified_slots: JustifiedSlots::new(),
validators: SszList::try_from(validators).unwrap(),
justifications_roots: Default::default(),
justifications_validators: JustificationValidators::new(),
};

let mut header_for_root = head_state.latest_block_header.clone();
header_for_root.state_root = head_state.hash_tree_root();
let parent_root = header_for_root.hash_tree_root();

let slot = HEAD_SLOT + 1;
let proposer_index = slot % NUM_VALIDATORS as u64;

// Common source / target / head so every payload passes the chain-match
// filter; distinct attestation slots give distinct data_roots, and one
// fresh validator per entry keeps each candidate scoring (adds a voter).
let source = Checkpoint {
root: hashes[0],
slot: 0,
};
let target = Checkpoint {
root: hashes[TARGET_SLOT as usize],
slot: TARGET_SLOT,
};
let head = Checkpoint {
root: hashes[0],
slot: 0,
};

let mut known_block_roots = HashSet::new();
known_block_roots.insert(parent_root);
known_block_roots.insert(hashes[0]);

let mut aggregated_payloads: HashMap<H256, (AttestationData, Vec<SingleMessageAggregate>)> =
HashMap::new();
for i in 0..NUM_PAYLOAD_ENTRIES {
let att_data = AttestationData {
slot: (i + 1) as u64,
head,
target,
source,
};
let data_root = att_data.hash_tree_root();

let mut bits = AggregationBits::with_length(NUM_VALIDATORS).unwrap();
bits.set(i % NUM_VALIDATORS, true).unwrap();
let proof_data = SszList::try_from(vec![0xABu8; 8]).expect("proof fits in ByteListMiB");
let proof = SingleMessageAggregate::new(bits, proof_data);

aggregated_payloads.insert(data_root, (att_data, vec![proof]));
}

let build = |limit: usize| {
build_block(
&head_state,
slot,
proposer_index,
parent_root,
&known_block_roots,
&aggregated_payloads,
ProposerConfig {
enable_proposer_aggregation: false,
max_attestations_per_block: limit,
},
)
.expect("build_block should succeed")
.0
.body
.attestations
.len()
};

let limited = build(CONFIGURED_LIMIT);
let unlimited = build(MAX_ATTESTATIONS_DATA);

assert!(
(1..=CONFIGURED_LIMIT).contains(&limited),
"configured limit should cap attestations to {CONFIGURED_LIMIT}: got {limited}"
);
assert!(
unlimited > CONFIGURED_LIMIT,
"the pool must offer more than {CONFIGURED_LIMIT} selectable entries so the limit \
is the binding constraint: MAX_ATTESTATIONS_DATA build packed {unlimited}"
);
}

/// With proposer aggregation disabled, `build_block` must still emit at
/// most one entry per `AttestationData` (`on_block` rejects duplicates),
/// keeping the single best-coverage proof and dropping the rest rather than
Expand Down Expand Up @@ -1128,7 +1288,10 @@ mod tests {
parent_root,
&known_block_roots,
&aggregated_payloads,
false,
ProposerConfig {
enable_proposer_aggregation: false,
max_attestations_per_block: MAX_ATTESTATIONS_DATA,
},
)
.expect("build_block should succeed");

Expand Down Expand Up @@ -1264,7 +1427,10 @@ mod tests {
parent_root,
&known_block_roots,
&aggregated_payloads,
true,
ProposerConfig {
enable_proposer_aggregation: true,
max_attestations_per_block: MAX_ATTESTATIONS_DATA,
},
)
.expect("build_block should succeed");

Expand Down Expand Up @@ -1397,7 +1563,10 @@ mod tests {
parent_root,
&known_block_roots,
&aggregated_payloads,
true,
ProposerConfig {
enable_proposer_aggregation: true,
max_attestations_per_block: MAX_ATTESTATIONS_DATA,
},
)
.expect("build_block should succeed");

Expand Down
17 changes: 6 additions & 11 deletions crates/blockchain/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ use spawned_concurrency::tasks::{Actor, ActorRef, ActorStart, Context, Handler,
use tokio_util::sync::CancellationToken;
use tracing::{debug, error, info, trace, warn};

use crate::block_builder::ProposerConfig;
use crate::store::StoreError;

pub mod aggregation;
Expand Down Expand Up @@ -82,7 +83,7 @@ impl BlockChain {
aggregator: AggregatorController,
attestation_committee_count: u64,
gate_duties: bool,
enable_proposer_aggregation: bool,
proposer_config: ProposerConfig,
) -> BlockChain {
metrics::set_is_aggregator(aggregator.is_enabled());
metrics::set_node_sync_status(metrics::SyncStatus::Idle);
Expand All @@ -107,7 +108,7 @@ impl BlockChain {
current_aggregation: None,
last_tick_instant: None,
attestation_committee_count,
enable_proposer_aggregation,
proposer_config,
pre_merge_coverage: None,
sync_status: SyncStatusTracker::new(gate_duties),
}
Expand Down Expand Up @@ -168,14 +169,8 @@ pub struct BlockChainServer {
/// attestation aggregate coverage emission.
attestation_committee_count: u64,

/// How the proposer collapses same-data attestations during block building
/// (a block may carry at most one entry per `AttestationData`). When true,
/// same-data proofs are merged via recursive single-message aggregation
/// into a union-coverage proof (leanSpec #510); when false (the default),
/// only the single best-coverage proof per data is kept, skipping the
/// per-data leanVM aggregation. Seeded from the CLI
/// `--enable-proposer-aggregation` flag at spawn.
enable_proposer_aggregation: bool,
/// Proposer-side block-building policy
proposer_config: ProposerConfig,

/// Pre-merge `new_payloads` snapshot for the attestation aggregate coverage
/// report. Captured at the end-of-slot promote (interval 4), read at the
Expand Down Expand Up @@ -512,7 +507,7 @@ impl BlockChainServer {
&mut self.store,
slot,
validator_id,
self.enable_proposer_aggregation,
self.proposer_config,
)
.inspect_err(|err| error!(%slot, %validator_id, %err, "Failed to build block"))
else {
Expand Down
6 changes: 3 additions & 3 deletions crates/blockchain/src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ use tracing::{info, trace, warn};
use crate::{
GOSSIP_DISPARITY_INTERVALS, INTERVALS_PER_SLOT, MAX_ATTESTATIONS_DATA,
MILLISECONDS_PER_INTERVAL, MILLISECONDS_PER_SLOT,
block_builder::{PostBlockCheckpoints, build_block},
block_builder::{PostBlockCheckpoints, ProposerConfig, build_block},
metrics,
};

Expand Down Expand Up @@ -789,7 +789,7 @@ pub fn produce_block_with_signatures(
store: &mut Store,
slot: u64,
validator_index: u64,
enable_proposer_aggregation: bool,
config: ProposerConfig,
) -> Result<(Block, Vec<SingleMessageAggregate>, PostBlockCheckpoints), StoreError> {
// Get parent block and state to build upon
let head_root = get_proposal_head(store, slot);
Expand Down Expand Up @@ -823,7 +823,7 @@ pub fn produce_block_with_signatures(
head_root,
&known_block_roots,
&aggregated_payloads,
enable_proposer_aggregation,
config,
)?
};

Expand Down