@@ -26,6 +26,15 @@ use crate::{INTERVALS_PER_SLOT, MILLISECONDS_PER_INTERVAL, MILLISECONDS_PER_SLOT
2626
2727const 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.
3039fn 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.
913923fn 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