From 28dda086b9b6d76238db0769964de32b6e40809e Mon Sep 17 00:00:00 2001 From: Edgar Date: Thu, 28 May 2026 17:51:19 +0200 Subject: [PATCH 1/3] fix(l1): use 0x80 sentinel for missing eth/71 BAL per EIP-8159 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The eth/71 BlockAccessLists handler emitted 0xc0 (RLP empty list) for missing entries, but EIP-8159 §"BlockAccessLists (0x13)" mandates the RLP empty string 0x80: an empty list is a valid BAL encoding (block with no state changes), so only the empty string can never alias a real BAL. Affects both encode and decode of OptionalBal. geth uses rlp.EmptyString at eth/protocols/eth/handlers.go:693; the 0xc0 value would have caused silent data confusion on interop (geth decoding our missing slots as valid empty BALs). Adds a byte-exact test asserting 0x80 is present and 0xc0 is absent in a single-None response payload. --- .../p2p/rlpx/eth/block_access_lists.rs | 40 +++++++++++++++++-- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/crates/networking/p2p/rlpx/eth/block_access_lists.rs b/crates/networking/p2p/rlpx/eth/block_access_lists.rs index 71693c3732..af5d4146b8 100644 --- a/crates/networking/p2p/rlpx/eth/block_access_lists.rs +++ b/crates/networking/p2p/rlpx/eth/block_access_lists.rs @@ -16,7 +16,11 @@ 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. #[derive(Debug, Clone)] struct OptionalBal(Option); @@ -24,14 +28,14 @@ struct OptionalBal(Option); 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(), } } @@ -39,7 +43,7 @@ impl RLPEncode for OptionalBal { impl RLPDecode for OptionalBal { fn decode_unfinished(rlp: &[u8]) -> Result<(Self, &[u8]), RLPDecodeError> { - if rlp.first() == Some(&0xc0) { + if rlp.first() == Some(&0x80) { return Ok((OptionalBal(None), &rlp[1..])); } let (bal, rest) = BlockAccessList::decode_unfinished(rlp)?; @@ -139,6 +143,7 @@ impl RLPxMessage for BlockAccessLists { #[cfg(test)] mod tests { use super::*; + use crate::rlpx::utils::snappy_decompress; use ethereum_types::Address; use ethrex_common::types::block_access_list::{AccountChanges, BalanceChange}; @@ -244,4 +249,31 @@ mod tests { let decoded = BlockAccessLists::decode(&buf).unwrap(); assert_eq!(decoded.block_access_lists.len(), BLOCK_ACCESS_LIST_LIMIT); } + + /// Locks EIP-8159 §"BlockAccessLists (0x13)" sentinel: missing BALs encode + /// as RLP empty string (`0x80`), NOT empty list (`0xc0`). Empty list is a + /// valid BAL encoding (block with no state changes), so the empty string + /// is the only byte that can't alias a real BAL. geth uses the same + /// sentinel (`rlp.EmptyString` in `eth/protocols/eth/handlers.go`); any + /// drift here is silent interop breakage. + #[test] + fn block_access_lists_none_uses_0x80_sentinel() { + let msg = BlockAccessLists::new(0, vec![None]); + let mut buf = Vec::new(); + msg.encode(&mut buf).unwrap(); + let decompressed = snappy_decompress(&buf).unwrap(); + assert!( + decompressed.contains(&0x80), + "decompressed payload must contain the 0x80 None sentinel" + ); + assert!( + !decompressed.contains(&0xc0), + "decompressed payload must not contain the 0xc0 empty-list sentinel" + ); + + // Roundtrip must preserve the None. + let decoded = BlockAccessLists::decode(&buf).unwrap(); + assert_eq!(decoded.block_access_lists.len(), 1); + assert!(decoded.block_access_lists[0].is_none()); + } } From c2be1dddad4c9ec970aeb00acef9ce26d8dccf86 Mon Sep 17 00:00:00 2001 From: Edgar Luque Date: Mon, 1 Jun 2026 07:59:29 +0200 Subject: [PATCH 2/3] test(l1): move BAL sentinel test to test crate, add invariant comment --- .../p2p/rlpx/eth/block_access_lists.rs | 38 ++++++--------- crates/networking/p2p/rlpx/eth/mod.rs | 2 +- .../p2p/rlpx/block_access_lists_tests.rs | 46 +++++++++++++++++++ test/tests/p2p/rlpx/mod.rs | 1 + 4 files changed, 62 insertions(+), 25 deletions(-) create mode 100644 test/tests/p2p/rlpx/block_access_lists_tests.rs diff --git a/crates/networking/p2p/rlpx/eth/block_access_lists.rs b/crates/networking/p2p/rlpx/eth/block_access_lists.rs index af5d4146b8..b6426af7d3 100644 --- a/crates/networking/p2p/rlpx/eth/block_access_lists.rs +++ b/crates/networking/p2p/rlpx/eth/block_access_lists.rs @@ -22,6 +22,10 @@ pub const BLOCK_ACCESS_LIST_LIMIT: usize = 1024; /// (`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 +/// >= 0xc0), so `0x80` is unambiguously the `None` sentinel; keep this true +/// if `BlockAccessList`'s encoding is ever refactored. #[derive(Debug, Clone)] struct OptionalBal(Option); @@ -143,7 +147,6 @@ impl RLPxMessage for BlockAccessLists { #[cfg(test)] mod tests { use super::*; - use crate::rlpx::utils::snappy_decompress; use ethereum_types::Address; use ethrex_common::types::block_access_list::{AccountChanges, BalanceChange}; @@ -250,30 +253,17 @@ mod tests { assert_eq!(decoded.block_access_lists.len(), BLOCK_ACCESS_LIST_LIMIT); } - /// Locks EIP-8159 §"BlockAccessLists (0x13)" sentinel: missing BALs encode - /// as RLP empty string (`0x80`), NOT empty list (`0xc0`). Empty list is a - /// valid BAL encoding (block with no state changes), so the empty string - /// is the only byte that can't alias a real BAL. geth uses the same + /// Locks the EIP-8159 §"BlockAccessLists (0x13)" sentinel at the unit + /// level: 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. + /// drift here is silent interop breakage. Message-level roundtrip coverage + /// lives in `test/tests/p2p/rlpx/block_access_lists_tests.rs` (the private + /// `OptionalBal` wrapper asserted here is unreachable from that crate). #[test] - fn block_access_lists_none_uses_0x80_sentinel() { - let msg = BlockAccessLists::new(0, vec![None]); - let mut buf = Vec::new(); - msg.encode(&mut buf).unwrap(); - let decompressed = snappy_decompress(&buf).unwrap(); - assert!( - decompressed.contains(&0x80), - "decompressed payload must contain the 0x80 None sentinel" - ); - assert!( - !decompressed.contains(&0xc0), - "decompressed payload must not contain the 0xc0 empty-list sentinel" - ); - - // Roundtrip must preserve the None. - let decoded = BlockAccessLists::decode(&buf).unwrap(); - assert_eq!(decoded.block_access_lists.len(), 1); - assert!(decoded.block_access_lists[0].is_none()); + fn optional_bal_none_encodes_as_0x80_sentinel() { + let mut bytes = Vec::new(); + OptionalBal(None).encode(&mut bytes); + assert_eq!(bytes, vec![0x80]); } } diff --git a/crates/networking/p2p/rlpx/eth/mod.rs b/crates/networking/p2p/rlpx/eth/mod.rs index 5643f7fffa..e7beac2fcc 100644 --- a/crates/networking/p2p/rlpx/eth/mod.rs +++ b/crates/networking/p2p/rlpx/eth/mod.rs @@ -1,4 +1,4 @@ -pub(crate) mod block_access_lists; +pub mod block_access_lists; pub mod blocks; pub mod eth68; mod eth69; diff --git a/test/tests/p2p/rlpx/block_access_lists_tests.rs b/test/tests/p2p/rlpx/block_access_lists_tests.rs new file mode 100644 index 0000000000..99cc907c10 --- /dev/null +++ b/test/tests/p2p/rlpx/block_access_lists_tests.rs @@ -0,0 +1,46 @@ +use ethrex_common::types::block_access_list::BlockAccessList; +use ethrex_p2p::rlpx::{eth::block_access_lists::BlockAccessLists, message::RLPxMessage}; + +// ── 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()); +} diff --git a/test/tests/p2p/rlpx/mod.rs b/test/tests/p2p/rlpx/mod.rs index bb630aa538..4924d801c5 100644 --- a/test/tests/p2p/rlpx/mod.rs +++ b/test/tests/p2p/rlpx/mod.rs @@ -1,3 +1,4 @@ +mod block_access_lists_tests; mod blocks_tests; mod handshake_tests; mod p2p_tests; From 28f6b1218a7d437fb05e5d4408f8f38c8543b14a Mon Sep 17 00:00:00 2001 From: Edgar Luque Date: Mon, 1 Jun 2026 09:10:19 +0200 Subject: [PATCH 3/3] fix(l1): reword BAL invariant doc to avoid rustdoc blockquote lint --- crates/networking/p2p/rlpx/eth/block_access_lists.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/networking/p2p/rlpx/eth/block_access_lists.rs b/crates/networking/p2p/rlpx/eth/block_access_lists.rs index b6426af7d3..ba1efa605e 100644 --- a/crates/networking/p2p/rlpx/eth/block_access_lists.rs +++ b/crates/networking/p2p/rlpx/eth/block_access_lists.rs @@ -23,9 +23,9 @@ pub const BLOCK_ACCESS_LIST_LIMIT: usize = 1024; /// 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 -/// >= 0xc0), so `0x80` is unambiguously the `None` sentinel; keep this true -/// if `BlockAccessList`'s encoding is ever refactored. +/// 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. #[derive(Debug, Clone)] struct OptionalBal(Option);