Skip to main content

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}