bal_codec/lib.rs
1//! EIP-7928 Block-Level Access List codec.
2//!
3//! This crate is the *only* place the 7928 wire format is known. Everything
4//! above it (archive, layout, cli) consumes the typed structures and must not
5//! touch RLP.
6//!
7//! Wire schema (from the EIP, Review status, subject to change):
8//!
9//! ```text
10//! StorageChange = [BlockAccessIndex(u32), StorageValue(u256)]
11//! BalanceChange = [BlockAccessIndex(u32), Balance(u256)]
12//! NonceChange = [BlockAccessIndex(u32), Nonce(u64)]
13//! CodeChange = [BlockAccessIndex(u32), Bytecode(bytes)]
14//! SlotChanges = [StorageKey(u256), [StorageChange...]]
15//! AccountChanges = [Address, [SlotChanges...], [StorageKey...],
16//! [BalanceChange...], [NonceChange...], [CodeChange...]]
17//! BlockAccessList = [AccountChanges...]
18//! block_access_list_hash = keccak256(rlp(BlockAccessList))
19//! ```
20//!
21//! Ordering is part of validity: accounts by address, slots by key, changes by
22//! index, all strictly ascending. A BAL that violates ordering or uniqueness
23//! is rejected by [`BlockAccessList::decode`]; it is never softly accepted.
24//!
25//! With the `json` feature, [`BlockAccessList::from_rpc_json`] accepts the
26//! execution-apis JSON form served by `eth_getBlockAccessList`.
27
28#[cfg(feature = "json")]
29mod json;
30mod schema;
31mod validate;
32
33pub use schema::{
34 AccountChanges, BalanceChange, BlockAccessIndex, BlockAccessList, CodeChange, NonceChange,
35 SlotChanges, StorageChange,
36};
37
38use alloy_primitives::{keccak256, Address, B256, U256};
39use alloy_rlp::{Decodable, Encodable};
40
41/// Hash of the empty BAL: `keccak256(rlp([]))` = `keccak256(0xc0)`.
42pub const EMPTY_BAL_HASH: B256 = B256::new([
43 0x1d, 0xcc, 0x4d, 0xe8, 0xde, 0xc7, 0x5d, 0x7a, 0xab, 0x85, 0xb5, 0x67, 0xb6, 0xcc, 0xd4, 0x1a,
44 0xd3, 0x12, 0x45, 0x1b, 0x94, 0x8a, 0x74, 0x13, 0xf0, 0xa1, 0x42, 0xfd, 0x40, 0xd4, 0x93, 0x47,
45]);
46
47/// Decoding, validation and verification failures.
48#[derive(Debug, thiserror::Error)]
49pub enum CodecError {
50 /// Malformed RLP.
51 #[error("rlp: {0}")]
52 Rlp(#[from] alloy_rlp::Error),
53 /// Bytes left over after the outer list.
54 #[error("trailing bytes after BAL: {0} bytes")]
55 Trailing(usize),
56 /// A list is not strictly ascending.
57 #[error("ordering violated: {0}")]
58 Ordering(&'static str),
59 /// A key appears twice in a list.
60 #[error("duplicate {what} at position {pos}")]
61 Duplicate {
62 /// Which list.
63 what: &'static str,
64 /// Index of the repeated element.
65 pos: usize,
66 },
67 /// The EIP forbids a key in both `storage_changes` and `storage_reads`.
68 #[error("storage key appears in both storage_changes and storage_reads for {0}")]
69 KeyInChangesAndReads(Address),
70 /// A slot entry with no changes.
71 #[error("empty change list for slot {slot} of {address}")]
72 EmptySlotChanges {
73 /// Account.
74 address: Address,
75 /// Slot key.
76 slot: B256,
77 },
78 /// `keccak(rlp(bal))` differs from the header.
79 #[error("BAL hash mismatch: computed {computed}, expected {expected}")]
80 HashMismatch {
81 /// What this codec computed.
82 computed: B256,
83 /// What the header says.
84 expected: B256,
85 },
86 /// The JSON form did not match the expected shape (`json` feature).
87 #[error("json: {0}")]
88 Json(String),
89}
90
91impl BlockAccessList {
92 /// Decode from RLP and validate ordering/uniqueness. The whole input must
93 /// be consumed; trailing bytes are an error.
94 pub fn decode(mut bytes: &[u8]) -> Result<Self, CodecError> {
95 let bal = <Self as Decodable>::decode(&mut bytes)?;
96 if !bytes.is_empty() {
97 return Err(CodecError::Trailing(bytes.len()));
98 }
99 validate::validate(&bal)?;
100 Ok(bal)
101 }
102
103 /// Canonical RLP encoding.
104 pub fn encode_rlp(&self) -> Vec<u8> {
105 let mut out = Vec::with_capacity(self.length());
106 Encodable::encode(self, &mut out);
107 out
108 }
109
110 /// `keccak256(rlp(self))` — the value that lives in
111 /// `header.block_access_list_hash`.
112 pub fn hash(&self) -> B256 {
113 keccak256(self.encode_rlp())
114 }
115
116 /// Compare against the hash taken from the block header.
117 pub fn verify(&self, expected_bal_hash: B256) -> Result<(), CodecError> {
118 let computed = self.hash();
119 if computed == expected_bal_hash {
120 Ok(())
121 } else {
122 Err(CodecError::HashMismatch {
123 computed,
124 expected: expected_bal_hash,
125 })
126 }
127 }
128
129 /// Binary search by address (accounts are sorted by construction).
130 pub fn account(&self, addr: &Address) -> Option<&AccountChanges> {
131 self.accounts
132 .binary_search_by(|a| a.address.cmp(addr))
133 .ok()
134 .map(|i| &self.accounts[i])
135 }
136
137 /// `true` when no account was touched in the block.
138 pub fn is_empty(&self) -> bool {
139 self.accounts.is_empty()
140 }
141}
142
143impl AccountChanges {
144 /// Binary search by slot key.
145 pub fn slot(&self, key: &B256) -> Option<&SlotChanges> {
146 let k = U256::from_be_bytes(key.0);
147 self.storage_changes
148 .binary_search_by(|s| s.slot.cmp(&k))
149 .ok()
150 .map(|i| &self.storage_changes[i])
151 }
152
153 /// `true` iff the account has at least one storage write. Presence in the
154 /// BAL alone (reads, balance touches) does NOT count.
155 pub fn has_storage_changes(&self) -> bool {
156 !self.storage_changes.is_empty()
157 }
158}
159
160impl SlotChanges {
161 /// Slot key as a 32-byte word.
162 pub fn slot_b256(&self) -> B256 {
163 B256::from(self.slot.to_be_bytes::<32>())
164 }
165
166 /// Last write in block order — the end-of-block value.
167 ///
168 /// Validation guarantees `changes` is non-empty; a `SlotChanges` built by
169 /// hand with no changes yields a zero change rather than a panic.
170 pub fn final_change(&self) -> &StorageChange {
171 static EMPTY: StorageChange = StorageChange {
172 block_access_index: 0,
173 value: U256::ZERO,
174 };
175 self.changes.last().unwrap_or(&EMPTY)
176 }
177}
178
179impl StorageChange {
180 /// Post-value as a 32-byte word.
181 pub fn value_b256(&self) -> B256 {
182 B256::from(self.value.to_be_bytes::<32>())
183 }
184}