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