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
1 change: 1 addition & 0 deletions application/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ commonware-cryptography.workspace = true
commonware-runtime.workspace = true
commonware-macros.workspace = true
commonware-utils.workspace = true
commonware-codec.workspace = true

futures.workspace = true
governor.workspace = true
Expand Down
146 changes: 134 additions & 12 deletions application/src/actor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use crate::{
ingress::{Mailbox, Message},
};
use anyhow::{Context, Result, anyhow};
use commonware_codec::EncodeSize;
use commonware_macros::select;
use commonware_runtime::{Clock, ContextCell, Handle, Metrics, Spawner, Storage, spawn_cell};
use commonware_utils::SystemTimeExt;
Expand Down Expand Up @@ -76,6 +77,7 @@ pub struct Actor<
mailbox: mpsc::Receiver<Message<P>>,
engine_client: C,
genesis_hash: [u8; 32],
max_message_size_bytes: u32,
epocher: ES,
cancellation_token: CancellationToken,
leader_timeout: Duration,
Expand Down Expand Up @@ -108,6 +110,7 @@ impl<
mailbox: rx,
engine_client: cfg.engine_client,
genesis_hash,
max_message_size_bytes: cfg.max_message_size_bytes,
epocher: cfg.epocher,
cancellation_token: cfg.cancellation_token,
leader_timeout: cfg.leader_timeout,
Expand Down Expand Up @@ -288,6 +291,7 @@ impl<
let mut finalizer_clone = finalizer.clone();
let mut engine_client = self.engine_client.clone();
let genesis_hash = self.genesis_hash;
let max_message_size_bytes = self.max_message_size_bytes;
move |context| async move {
// Subscribe inside the task: the enqueue goes into the
// bounded syncer mailbox, so doing it on the application
Expand All @@ -299,6 +303,23 @@ impl<
return false;
};

if let Some((block_size_bytes, max_block_size_bytes)) =
block_size_limit_violation(
&block,
max_message_size_bytes,
)
{
warn!(
?round,
height = block.height(),
block_size_bytes,
max_block_size_bytes,
max_message_size_bytes,
"certify: block violates P2P block size limit"
);
return false;
}

// Wait for parent to be executed so its state is in Reth
// before check_payload runs on the child.
let parent_digest = block.parent();
Expand Down Expand Up @@ -420,6 +441,7 @@ impl<
let mut syncer = syncer.clone();
let mut finalizer_clone = finalizer.clone();
let epocher = self.epocher.clone();
let max_message_size_bytes = self.max_message_size_bytes;
move |context| async move {
// Subscribe to blocks (will wait for them if not available)
let parent_request = if parent.1 == genesis_hash.into() {
Expand Down Expand Up @@ -493,7 +515,16 @@ impl<
}

let now_millis = context.current().epoch_millis();
if handle_verify(round, &block, parent, signed_parent_view, &epocher, &aux_data, now_millis) {
if handle_verify(
round,
&block,
parent,
signed_parent_view,
&epocher,
&aux_data,
now_millis,
max_message_size_bytes,
) {
// Respond to consensus first. The vote is decided,
// so persisting and broadcasting the valid block via
// the syncer is auxiliary work that must not sit on
Expand Down Expand Up @@ -537,6 +568,20 @@ impl<
}
}

fn ensure_proposed_block_within_p2p_limit(&self, block: &Block, round: Round) -> Result<()> {
if let Some((block_size_bytes, max_block_size_bytes)) =
block_size_limit_violation(block, self.max_message_size_bytes)
{
return Err(anyhow!(
"proposed block violates P2P block size limit for round {round} at height {}: block size {block_size_bytes} bytes must be smaller than {max_block_size_bytes} bytes (max_message_size_bytes = {})",
block.height(),
self.max_message_size_bytes,
));
}

Ok(())
}

async fn handle_proposal(
&mut self,
parent: (View, Digest),
Expand Down Expand Up @@ -671,6 +716,7 @@ impl<
.expect("epoch should exist");
if parent_block.height() == last_in_epoch.get() {
debug!(round = ?round, digest = ?parent_block.digest(), "re-proposed parent block at epoch boundary");
self.ensure_proposed_block_within_p2p_limit(&parent_block, round)?;
return Ok(parent_block);
}

Expand Down Expand Up @@ -833,6 +879,7 @@ impl<
let proposal_duration = proposal_start.elapsed().as_millis() as f64;
histogram!("handle_proposal_duration_millis").record(proposal_duration);
}
self.ensure_proposed_block_within_p2p_limit(&block, round)?;
Ok(block)
}
}
Expand Down Expand Up @@ -877,6 +924,7 @@ fn execution_requests_ascending(requests: &[impl AsRef<[u8]>]) -> bool {
true
}

#[allow(clippy::too_many_arguments)]
fn handle_verify<ES: Epocher>(
round: Round,
block: &Block,
Expand All @@ -887,6 +935,7 @@ fn handle_verify<ES: Epocher>(
epocher: &ES,
aux_data: &BlockAuxData,
now_millis: u64,
max_message_size_bytes: u32,
) -> bool {
if round.epoch().get() != aux_data.epoch {
warn!(
Expand All @@ -896,6 +945,18 @@ fn handle_verify<ES: Epocher>(
);
return false;
}
if let Some((block_size_bytes, max_block_size_bytes)) =
block_size_limit_violation(block, max_message_size_bytes)
{
warn!(
height = block.height(),
block_size_bytes,
max_block_size_bytes,
max_message_size_bytes,
"verify: block violates P2P block size limit"
);
return false;
}
// You can only re-propose the same block iff it's the last height in the epoch.
let last_in_epoch = epocher
.last(Epoch::new(aux_data.epoch))
Expand Down Expand Up @@ -1103,6 +1164,56 @@ fn handle_verify<ES: Epocher>(
true
}

/// The largest encoded block we allow consensus to propose/verify, expressed as
/// half the P2P message budget.
///
/// The cap exists because finalized data is repaired through single-message
/// resolver responses (see `syncer`): a bare `block.encode()` for `Request::Block`,
/// and `(finalization, block).encode()` / `(notarization, block).encode()` for the
/// finalized/notarized requests. If any such response exceeds
/// `max_message_size_bytes`, a lagging or checkpoint-joining peer can never repair
/// that height — every serve fails the same size check. Bounding the block at
/// consensus time (propose *and* verify, so even a Byzantine proposer can't get an
/// oversized block finalized) keeps every response servable.
///
/// Why `/2` specifically: the response is `block + certificate + framing`, so the
/// half not used by the block must cover everything else. That overhead is
/// dominated by a small constant plus a term linear in the validator count `N`:
///
/// - `Proposal` metadata: `round` (epoch u64 + view u64 = 16) + `parent` view (8)
/// + `payload` digest (32) = **56 bytes**, fixed.
/// - certificate: one aggregated BLS signature (MinPk → a single G2 element,
/// **96 bytes**, constant — signatures aggregate, they do not grow with `N`)
/// plus the `Signers` set, which is a `BitMap<1>` = **⌈N/8⌉ bytes** (one bit per
/// validator) and a small length field.
/// - tuple / codec framing (the block's 4-byte length prefix, bitmap length
/// varint): a handful of bytes.
///
/// So the non-block overhead is `~165 + ⌈N/8⌉` bytes, and the full response stays
/// under `max_message_size_bytes` whenever `⌈N/8⌉ + ~165 <= max/2`, i.e. roughly
/// `N <= 4 * max_message_size_bytes`. For any realistic cap (≥ ~1 MiB → ~4M
/// validators; even a 64 KiB cap → ~256k) this holds with orders of magnitude of
/// margin. The only term that scales with `N` is the 1-bit-per-validator bitmap;
/// if a non-aggregating signature scheme were ever substituted (overhead becoming
/// `O(N * sig_len)`), this `/2` headroom would need to be revisited.
fn max_block_size_bytes(max_message_size_bytes: u32) -> usize {
usize::try_from(max_message_size_bytes / 2)
.expect("u32 will always fit into usize on 32/64-bit targets")
}

fn block_size_limit_violation(
block: &Block,
max_message_size_bytes: u32,
) -> Option<(usize, usize)> {
let block_size_bytes = block.encode_size();
let max_block_size_bytes = max_block_size_bytes(max_message_size_bytes);
if block_size_bytes < max_block_size_bytes {
return None;
}

Some((block_size_bytes, max_block_size_bytes))
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -1259,7 +1370,8 @@ mod tests {
parent_view,
&epocher(),
&aux_data,
u64::MAX / 4
u64::MAX / 4,
u32::MAX
),
"re-proposal of the epoch-terminal block must be accepted"
);
Expand Down Expand Up @@ -1304,7 +1416,8 @@ mod tests {
parent_view,
&epocher(),
&aux_data,
u64::MAX / 4
u64::MAX / 4,
u32::MAX
),
"non-reproposal child whose parent is the epoch-terminal block \
must be rejected"
Expand Down Expand Up @@ -1344,7 +1457,8 @@ mod tests {
parent_view,
&epocher(),
&aux_data,
u64::MAX / 4
u64::MAX / 4,
u32::MAX
),
"ordinary child inside the parent's epoch must be accepted"
);
Expand Down Expand Up @@ -1385,7 +1499,8 @@ mod tests {
mismatched_parent_view,
&epocher(),
&aux_data,
u64::MAX / 4
u64::MAX / 4,
u32::MAX
),
"child whose signed parent view disagrees with the parent block's \
decoded view must be rejected"
Expand Down Expand Up @@ -1425,7 +1540,8 @@ mod tests {
parent_view,
&epocher(),
&aux_data,
u64::MAX / 4
u64::MAX / 4,
u32::MAX
),
"child whose signed parent view matches the parent block must be accepted"
);
Expand Down Expand Up @@ -1464,7 +1580,8 @@ mod tests {
parent_view,
&epocher(),
&aux_data,
u64::MAX / 4
u64::MAX / 4,
u32::MAX
),
"child whose decoded view disagrees with the proposal round must be rejected"
);
Expand Down Expand Up @@ -1499,7 +1616,8 @@ mod tests {
later_parent_view,
&epocher(),
&aux_data,
u64::MAX / 4
u64::MAX / 4,
u32::MAX
),
"same-digest boundary re-proposal must be accepted even when the \
signed parent view differs from the block's decoded view"
Expand Down Expand Up @@ -1548,7 +1666,8 @@ mod tests {
0,
&epocher(),
&aux_data,
u64::MAX / 4
u64::MAX / 4,
u32::MAX
),
"a signed parent view of 0 on a genuine epoch opener must bypass the \
parent-view binding"
Expand Down Expand Up @@ -1591,7 +1710,8 @@ mod tests {
0,
&epocher(),
&aux_data,
u64::MAX / 4
u64::MAX / 4,
u32::MAX
),
"a signed parent view of 0 on a mid-epoch block must be rejected"
);
Expand Down Expand Up @@ -1676,7 +1796,8 @@ mod tests {
signed_parent_view,
&epocher(),
&aux_data,
u64::MAX / 4
u64::MAX / 4,
u32::MAX
),
"block with payload.block_number ({}) != header.height ({}) must be rejected",
payload_block_number,
Expand Down Expand Up @@ -1728,7 +1849,8 @@ mod tests {
signed_parent_view,
&epocher(),
&aux_data,
u64::MAX / 4
u64::MAX / 4,
u32::MAX
),
"block with payload.timestamp ({}) != header.timestamp ({}) must be rejected",
payload_timestamp,
Expand Down
3 changes: 3 additions & 0 deletions application/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ pub struct ApplicationConfig<C: EngineClient, ES: Epocher> {

pub genesis_hash: [u8; 32],

/// Maximum P2P message size from genesis.
pub max_message_size_bytes: u32,

/// Epocher for determining epoch boundaries.
pub epocher: ES,

Expand Down
2 changes: 1 addition & 1 deletion example_genesis.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ notarization_timeout_ms = 4000
nullify_timeout_ms = 4000
activity_timeout_views = 256
skip_timeout_views = 32
max_message_size_bytes = 104857600
max_message_size_bytes = 10485760
namespace = "_SUMMIT"
validator_minimum_stake = 32000000000
validator_maximum_stake = 32000000000
Expand Down
2 changes: 2 additions & 0 deletions node/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ pub struct EngineConfig<C: EngineClient, S: Signer, O: NetworkOracle<S::PublicKe
/// Digest of the immutable genesis configuration ([`Genesis::config_digest`]),
/// used to derive the chain-bound live P2P + consensus domain.
pub config_digest: [u8; 32],
pub max_message_size_bytes: u32,

/// Initial state given to the finalizer. All other processes should get initial state from the finalizer not the config
pub initial_state: ConsensusState,
Expand Down Expand Up @@ -117,6 +118,7 @@ impl<C: EngineClient, S: Signer, O: NetworkOracle<S::PublicKey>> EngineConfig<C,
namespace: genesis.namespace.clone(),
genesis_hash: genesis.genesis_hash(),
config_digest: genesis.config_digest(),
max_message_size_bytes: genesis.max_message_size_bytes as u32,
initial_state,
checkpoint_last_block,
checkpoint_finalized_header,
Expand Down
1 change: 1 addition & 0 deletions node/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ where
mailbox_size: cfg.mailbox_size,
partition_prefix: cfg.partition_prefix.clone(),
genesis_hash: cfg.genesis_hash,
max_message_size_bytes: cfg.max_message_size_bytes,
epocher: epocher.clone(),
cancellation_token: cancellation_token.clone(),
leader_timeout: cfg.leader_timeout,
Expand Down
1 change: 1 addition & 0 deletions node/src/test_harness/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -687,6 +687,7 @@ where
// so use it as the config digest to keep their derived chain domain
// consistent.
config_digest: genesis_hash,
max_message_size_bytes: 100 * 1024 * 1024,
namespace,
key_store,
participants,
Expand Down
4 changes: 4 additions & 0 deletions types/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,7 @@ prom = ["metrics"]
bench = ["serde_json"]
e2e = ["libc", "alloy-genesis", "url"]
bad-blocks = []

[[bench]]
name = "block_encode_size"
harness = false
Loading
Loading