Skip to content

Commit 9a86501

Browse files
committed
Merge branch 'main' into devnet4
2 parents 5208ea0 + 1345286 commit 9a86501

3 files changed

Lines changed: 182 additions & 7 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/blockchain/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ ethlambda-test-fixtures.workspace = true
3333
serde = { workspace = true }
3434
serde_json = { workspace = true }
3535
hex = { workspace = true }
36+
libssz.workspace = true
37+
libssz-types.workspace = true
3638
datatest-stable = "0.3.3"
3739

3840
[[test]]

crates/blockchain/src/store.rs

Lines changed: 178 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,15 @@ use crate::{INTERVALS_PER_SLOT, MILLISECONDS_PER_INTERVAL, MILLISECONDS_PER_SLOT
2626

2727
const JUSTIFICATION_LOOKBACK_SLOTS: u64 = 3;
2828

29+
/// Maximum bytes of attestation proof data that build_block will accumulate.
30+
///
31+
/// Derived from the 10 MiB MAX_PAYLOAD_SIZE gossip limit with a 1 MiB margin
32+
/// for the block header, proposer signature, attestation metadata, bitlists,
33+
/// and SSZ encoding overhead.
34+
///
35+
/// See: https://github.com/lambdaclass/ethlambda/issues/259
36+
const MAX_ATTESTATION_PROOF_BYTES: usize = 9 * 1024 * 1024;
37+
2938
/// Accept new aggregated payloads, promoting them to known for fork choice.
3039
fn accept_new_attestations(store: &mut Store, log_tree: bool) {
3140
store.promote_new_aggregated_payloads();
@@ -910,18 +919,21 @@ fn aggregation_bits_from_validator_indices(bits: &[u64]) -> AggregationBits {
910919
///
911920
/// For a single attestation data entry, picks proofs that cover the most
912921
/// uncovered validators. Each selected proof produces one AggregatedAttestation.
922+
/// Returns the total proof_data bytes consumed.
913923
fn extend_proofs_greedily(
914924
proofs: &[AggregatedSignatureProof],
915925
selected_proofs: &mut Vec<AggregatedSignatureProof>,
916926
attestations: &mut Vec<AggregatedAttestation>,
917927
att_data: &AttestationData,
918-
) {
919-
if proofs.is_empty() {
920-
return;
928+
remaining_bytes: usize,
929+
) -> usize {
930+
if proofs.is_empty() || remaining_bytes == 0 {
931+
return 0;
921932
}
922933

923934
let mut covered: HashSet<u64> = HashSet::new();
924935
let mut remaining_indices: HashSet<usize> = (0..proofs.len()).collect();
936+
let mut bytes_consumed = 0;
925937

926938
while !remaining_indices.is_empty() {
927939
// Pick proof covering the most uncovered validators (count only, no allocation)
@@ -943,13 +955,18 @@ fn extend_proofs_greedily(
943955
break;
944956
}
945957

958+
let proof = &proofs[best_idx];
959+
let proof_bytes = proof.proof_data.len();
960+
if bytes_consumed + proof_bytes > remaining_bytes {
961+
break;
962+
}
963+
946964
// Collect coverage only for the winning proof
947-
let new_covered: Vec<u64> = proofs[best_idx]
965+
let new_covered: Vec<u64> = proof
948966
.participant_indices()
949967
.filter(|vid| !covered.contains(vid))
950968
.collect();
951969

952-
let proof = &proofs[best_idx];
953970
attestations.push(AggregatedAttestation {
954971
aggregation_bits: proof.participants.clone(),
955972
data: att_data.clone(),
@@ -961,7 +978,10 @@ fn extend_proofs_greedily(
961978

962979
covered.extend(new_covered);
963980
remaining_indices.remove(&best_idx);
981+
bytes_consumed += proof_bytes;
964982
}
983+
984+
bytes_consumed
965985
}
966986

967987
/// Build a valid block on top of this state.
@@ -982,6 +1002,7 @@ fn build_block(
9821002
) -> Result<(Block, Vec<AggregatedSignatureProof>), StoreError> {
9831003
let mut aggregated_attestations: Vec<AggregatedAttestation> = Vec::new();
9841004
let mut aggregated_signatures: Vec<AggregatedSignatureProof> = Vec::new();
1005+
let mut accumulated_proof_bytes: usize = 0;
9851006

9861007
if !aggregated_payloads.is_empty() {
9871008
// Genesis edge case: when building on genesis (slot 0),
@@ -1006,6 +1027,9 @@ fn build_block(
10061027
let mut found_new = false;
10071028

10081029
for &(data_root, (att_data, proofs)) in &sorted_entries {
1030+
if accumulated_proof_bytes >= MAX_ATTESTATION_PROOF_BYTES {
1031+
break;
1032+
}
10091033
if processed_data_roots.contains(data_root) {
10101034
continue;
10111035
}
@@ -1019,15 +1043,18 @@ fn build_block(
10191043
processed_data_roots.insert(*data_root);
10201044
found_new = true;
10211045

1022-
extend_proofs_greedily(
1046+
let remaining_bytes = MAX_ATTESTATION_PROOF_BYTES - accumulated_proof_bytes;
1047+
let consumed = extend_proofs_greedily(
10231048
proofs,
10241049
&mut aggregated_signatures,
10251050
&mut aggregated_attestations,
10261051
att_data,
1052+
remaining_bytes,
10271053
);
1054+
accumulated_proof_bytes += consumed;
10281055
}
10291056

1030-
if !found_new {
1057+
if !found_new || accumulated_proof_bytes >= MAX_ATTESTATION_PROOF_BYTES {
10311058
break;
10321059
}
10331060

@@ -1304,6 +1331,150 @@ mod tests {
13041331
);
13051332
}
13061333

1334+
/// Regression test for https://github.com/lambdaclass/ethlambda/issues/259
1335+
///
1336+
/// Simulates a stall scenario by populating the payload pool with 50
1337+
/// distinct attestation entries, each carrying a ~253 KB proof (realistic
1338+
/// XMSS aggregated proof size). Without the byte budget cap this would
1339+
/// produce a 12.4 MiB block, exceeding the 10 MiB gossip limit.
1340+
/// Verifies that build_block respects the cap and stays under the limit.
1341+
#[test]
1342+
fn build_block_respects_max_payload_size_during_stall() {
1343+
use libssz::SszEncode;
1344+
use libssz_types::SszList;
1345+
1346+
const MAX_PAYLOAD_SIZE: usize = 10 * 1024 * 1024; // 10 MiB (spec limit)
1347+
const PROOF_SIZE: usize = 253 * 1024; // ~253 KB realistic XMSS proof
1348+
const NUM_VALIDATORS: usize = 50;
1349+
const NUM_PAYLOAD_ENTRIES: usize = 50;
1350+
1351+
// Create genesis state with NUM_VALIDATORS validators.
1352+
let validators: Vec<_> = (0..NUM_VALIDATORS)
1353+
.map(|i| ethlambda_types::state::Validator {
1354+
pubkey: [i as u8; 52],
1355+
index: i as u64,
1356+
})
1357+
.collect();
1358+
let head_state = State::from_genesis(1000, validators);
1359+
1360+
// process_slots fills in the genesis header's state_root before
1361+
// process_block_header computes the parent hash. Simulate that here.
1362+
let mut header_for_root = head_state.latest_block_header.clone();
1363+
header_for_root.state_root = head_state.hash_tree_root();
1364+
let parent_root = header_for_root.hash_tree_root();
1365+
1366+
// Proposer for slot 1 with NUM_VALIDATORS validators: 1 % 50 = 1
1367+
let proposer_index = 1u64;
1368+
let slot = 1u64;
1369+
1370+
// The genesis edge case in build_block sets current_justified to:
1371+
// Checkpoint { root: parent_root, slot: 0 }
1372+
let source = Checkpoint {
1373+
root: parent_root,
1374+
slot: 0,
1375+
};
1376+
1377+
let mut known_block_roots = HashSet::new();
1378+
known_block_roots.insert(parent_root);
1379+
1380+
// Simulate a stall: populate the payload pool with many distinct entries.
1381+
// Each has a unique target (different slot) and a large proof payload.
1382+
let mut aggregated_payloads: HashMap<
1383+
H256,
1384+
(AttestationData, Vec<AggregatedSignatureProof>),
1385+
> = HashMap::new();
1386+
1387+
for i in 0..NUM_PAYLOAD_ENTRIES {
1388+
let target_slot = (i + 1) as u64;
1389+
let att_data = AttestationData {
1390+
slot: target_slot,
1391+
head: Checkpoint {
1392+
root: parent_root,
1393+
slot: 0,
1394+
},
1395+
target: Checkpoint {
1396+
root: H256([target_slot as u8; 32]),
1397+
slot: target_slot,
1398+
},
1399+
source,
1400+
};
1401+
1402+
// Use the real hash_tree_root as the data_root key
1403+
let data_root = att_data.hash_tree_root();
1404+
1405+
// Create a single large proof per entry (one validator per proof)
1406+
let validator_id = i % NUM_VALIDATORS;
1407+
let mut bits = AggregationBits::with_length(NUM_VALIDATORS).unwrap();
1408+
bits.set(validator_id, true).unwrap();
1409+
1410+
let proof_bytes: Vec<u8> = vec![0xAB; PROOF_SIZE];
1411+
let proof_data = SszList::try_from(proof_bytes).expect("proof fits in ByteListMiB");
1412+
let proof = AggregatedSignatureProof::new(bits, proof_data);
1413+
1414+
aggregated_payloads.insert(data_root, (att_data, vec![proof]));
1415+
}
1416+
1417+
// Build the block; this should succeed (the bug: no size guard)
1418+
let (block, signatures) = build_block(
1419+
&head_state,
1420+
slot,
1421+
proposer_index,
1422+
parent_root,
1423+
&known_block_roots,
1424+
&aggregated_payloads,
1425+
)
1426+
.expect("build_block should succeed");
1427+
1428+
// The byte budget should have been enforced: fewer than 50 entries included
1429+
let attestation_count = block.body.attestations.len();
1430+
assert!(attestation_count > 0, "block should contain attestations");
1431+
assert!(
1432+
attestation_count < NUM_PAYLOAD_ENTRIES,
1433+
"byte budget should have capped attestations below the pool size"
1434+
);
1435+
1436+
// Construct the full signed block as it would be sent over gossip
1437+
let attestation_sigs: Vec<AggregatedSignatureProof> = signatures;
1438+
let signed_block = SignedBlockWithAttestation {
1439+
block: BlockWithAttestation {
1440+
block,
1441+
proposer_attestation: Attestation {
1442+
validator_id: proposer_index,
1443+
data: AttestationData {
1444+
slot,
1445+
head: Checkpoint {
1446+
root: parent_root,
1447+
slot: 0,
1448+
},
1449+
target: Checkpoint {
1450+
root: parent_root,
1451+
slot: 0,
1452+
},
1453+
source,
1454+
},
1455+
},
1456+
},
1457+
signature: BlockSignatures {
1458+
attestation_signatures: AttestationSignatures::try_from(attestation_sigs).unwrap(),
1459+
proposer_signature: XmssSignature::try_from(vec![0u8; SIGNATURE_SIZE]).unwrap(),
1460+
},
1461+
};
1462+
1463+
// SSZ-encode: this is exactly what publish_block does before compression
1464+
let ssz_bytes = signed_block.to_ssz();
1465+
1466+
// build_block must not produce blocks that exceed the gossip wire limit.
1467+
assert!(
1468+
ssz_bytes.len() <= MAX_PAYLOAD_SIZE,
1469+
"block with {} attestations is {} bytes SSZ, \
1470+
which exceeds MAX_PAYLOAD_SIZE ({} bytes). \
1471+
build_block must enforce a size cap (issue #259).",
1472+
signed_block.block.block.body.attestations.len(),
1473+
ssz_bytes.len(),
1474+
MAX_PAYLOAD_SIZE,
1475+
);
1476+
}
1477+
13071478
/// Attestation source must come from the head state's justified checkpoint,
13081479
/// not the store-wide global max.
13091480
///

0 commit comments

Comments
 (0)