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
22 changes: 17 additions & 5 deletions crates/networking/p2p/rlpx/eth/block_access_lists.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,30 +16,42 @@ use ethrex_rlp::{
pub const BLOCK_ACCESS_LIST_LIMIT: usize = 1024;

/// Wrapper for optional BAL in eth/71 protocol messages.
/// `None` (BAL unavailable) is encoded as an empty RLP list (0xc0).
///
/// Per EIP-8159 §"BlockAccessLists (0x13)": "The RLP empty string (`0x80`)
/// is returned for blocks where the BAL is unavailable." An empty list
/// (`0xc0`) is a valid BAL encoding (block with no state changes), so the
/// empty string is the only sentinel that can never alias a real BAL.
/// `Some(bal)` is encoded as the BAL's normal RLP list encoding.
///
/// INVARIANT: `BlockAccessList` always encodes as an RLP list (first byte is
/// `0xc0` or greater), so `0x80` is unambiguously the `None` sentinel; keep
/// this true if `BlockAccessList`'s encoding is ever refactored.
///
/// Public so the byte-level sentinel encoding can be asserted from the test
/// crate (`test/tests/p2p/rlpx/block_access_lists_tests.rs`); message-level
/// roundtrips run the bytes through snappy and can't see the raw `0x80`.
#[derive(Debug, Clone)]
struct OptionalBal(Option<BlockAccessList>);
pub struct OptionalBal(pub Option<BlockAccessList>);

impl RLPEncode for OptionalBal {
fn encode(&self, buf: &mut dyn BufMut) {
match &self.0 {
None => buf.put_u8(0xc0),
None => buf.put_u8(0x80),
Some(bal) => bal.encode(buf),
}
}

fn length(&self) -> usize {
match &self.0 {
None => 1, // empty list = 0xc0
None => 1, // empty string = 0x80 per EIP-8159
Some(bal) => bal.length(),
}
}
}

impl RLPDecode for OptionalBal {
fn decode_unfinished(rlp: &[u8]) -> Result<(Self, &[u8]), RLPDecodeError> {
if rlp.first() == Some(&0xc0) {
if rlp.first() == Some(&0x80) {
Comment thread
edg-l marked this conversation as resolved.
return Ok((OptionalBal(None), &rlp[1..]));
}
let (bal, rest) = BlockAccessList::decode_unfinished(rlp)?;
Expand Down
2 changes: 1 addition & 1 deletion crates/networking/p2p/rlpx/eth/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
pub(crate) mod block_access_lists;
pub mod block_access_lists;
pub mod blocks;
pub mod eth68;
mod eth69;
Expand Down
63 changes: 63 additions & 0 deletions test/tests/p2p/rlpx/block_access_lists_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
use ethrex_common::types::block_access_list::BlockAccessList;
use ethrex_p2p::rlpx::{
eth::block_access_lists::{BlockAccessLists, OptionalBal},
message::RLPxMessage,
};
use ethrex_rlp::encode::RLPEncode;

// ── BlockAccessLists (0x13, eth/71) ──
//
// EIP-8159 says a missing BAL is the RLP empty string (`0x80`), while a
// present-but-empty BAL (block with no state changes) is the RLP empty list
// (`0xc0`). The two must never alias, otherwise an upgraded node silently
// confuses "BAL unavailable" with "valid empty BAL" (interop break with geth).

#[test]
fn missing_bal_is_distinct_from_present_empty_bal() {
let missing = BlockAccessLists::new(1, vec![None]);
let empty = BlockAccessLists::new(1, vec![Some(BlockAccessList::from_accounts(vec![]))]);

let mut missing_buf = Vec::new();
missing.encode(&mut missing_buf).unwrap();
let mut empty_buf = Vec::new();
empty.encode(&mut empty_buf).unwrap();

// 0x80 sentinel must not collapse onto the 0xc0 empty-list encoding.
assert_ne!(missing_buf, empty_buf);
}

#[test]
fn missing_bal_roundtrips_as_none() {
let msg = BlockAccessLists::new(7, vec![None]);
let mut buf = Vec::new();
msg.encode(&mut buf).unwrap();

let decoded = BlockAccessLists::decode(&buf).unwrap();
assert_eq!(decoded.id, 7);
assert_eq!(decoded.block_access_lists.len(), 1);
assert!(decoded.block_access_lists[0].is_none());
}

#[test]
fn present_empty_bal_roundtrips_as_some() {
let msg = BlockAccessLists::new(7, vec![Some(BlockAccessList::from_accounts(vec![]))]);
let mut buf = Vec::new();
msg.encode(&mut buf).unwrap();

let decoded = BlockAccessLists::decode(&buf).unwrap();
assert_eq!(decoded.block_access_lists.len(), 1);
assert!(decoded.block_access_lists[0].is_some());
}

/// Locks the EIP-8159 §"BlockAccessLists (0x13)" sentinel: a missing BAL
/// encodes as exactly the RLP empty string (`0x80`), never the empty list
/// (`0xc0`, a valid empty BAL). geth uses the same sentinel (`rlp.EmptyString`
/// in `eth/protocols/eth/handlers.go`); any drift here is silent interop
/// breakage. Asserts the raw byte directly on the `OptionalBal` wrapper, which
/// the message-level tests can't see (their bytes go through snappy).
#[test]
fn optional_bal_none_encodes_as_0x80_sentinel() {
let mut bytes = Vec::new();
OptionalBal(None).encode(&mut bytes);
assert_eq!(bytes, vec![0x80]);
}
1 change: 1 addition & 0 deletions test/tests/p2p/rlpx/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
mod block_access_lists_tests;
mod blocks_tests;
mod handshake_tests;
mod p2p_tests;
Expand Down
Loading