arb_rpc/
outbox_proof.rs

1//! Merkle proof construction for L2→L1 send messages.
2//!
3//! Implements `NodeInterface.constructOutboxProof(size, leaf)`. Walks the
4//! Merkle accumulator from `leaf` toward the root, collecting sibling
5//! positions at each level. Nodes within the committed range (`< size`)
6//! come from L2ToL1Tx / SendMerkleUpdate event logs; nodes past the
7//! balanced-tree boundary come from partial accumulator state.
8
9use alloy_primitives::{B256, keccak256};
10
11/// A position in the Merkle tree: level (0 = leaves) + leaf index
12/// within that level.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
14pub struct LevelAndLeaf {
15    pub level: u64,
16    pub leaf: u64,
17}
18
19impl LevelAndLeaf {
20    pub fn new(level: u64, leaf: u64) -> Self {
21        Self { level, leaf }
22    }
23
24    /// Encode as a 32-byte log topic: level in high 64 bits, leaf in low 64 bits.
25    pub fn as_topic(&self) -> B256 {
26        let mut out = [0u8; 32];
27        out[16..24].copy_from_slice(&self.level.to_be_bytes());
28        out[24..32].copy_from_slice(&self.leaf.to_be_bytes());
29        B256::from(out)
30    }
31}
32
33/// Bit-length of `x` (`64 - leading_zeros(x)`): for values ≥ 1 equals
34/// `1 + floor(log2(x))`. Used by the outbox tree geometry, not the
35/// standard mathematical ceil-log2.
36fn log2_ceil(x: u64) -> u64 {
37    if x == 0 {
38        return 0;
39    }
40    64 - x.leading_zeros() as u64
41}
42
43/// `1 << log2_ceil(x)`. For exact powers of two this returns `2x`
44/// (e.g. `NextPow2(4) = 8`), the convention used by `constructOutboxProof`'s
45/// balanced check: `balanced := size == NextPow2(size)/2`.
46fn next_power_of_2(x: u64) -> u64 {
47    1u64 << log2_ceil(x)
48}
49
50/// Result of planning a proof construction: which nodes to fetch from
51/// logs (`query`) and which nodes form the proof path (`nodes`), plus
52/// which of those are "partials" that are computed from the
53/// accumulator state rather than fetched.
54#[derive(Debug, Clone)]
55pub struct ProofPlan {
56    /// Positions that must be fetched via log scan of SendMerkleUpdate
57    /// events (sorted by leaf index for efficient retrieval).
58    pub query: Vec<LevelAndLeaf>,
59    /// Positions in the proof path (may include partials that aren't
60    /// in `query` — those are filled from accumulator partials).
61    pub nodes: Vec<LevelAndLeaf>,
62    /// Partial positions that need accumulator-level reconstruction
63    /// rather than log lookup.
64    pub partials: Vec<LevelAndLeaf>,
65    /// Whether the tree at `size` is a perfect binary tree (a single
66    /// power-of-two).
67    pub balanced: bool,
68    /// Number of levels in the tree.
69    pub tree_levels: u64,
70}
71
72/// Plan the outbox-proof walk: given the tree `size` (send count) and
73/// the `leaf` we want to prove, compute the list of sibling positions
74/// that together form the proof path.
75///
76/// Returns `None` if `leaf >= size` (proof doesn't exist).
77pub fn plan_proof(size: u64, leaf: u64) -> Option<ProofPlan> {
78    if leaf >= size || size == 0 {
79        return None;
80    }
81    let balanced = size == next_power_of_2(size) / 2 || size == 1;
82    let tree_levels = log2_ceil(size);
83    let proof_levels = tree_levels.saturating_sub(1);
84    let mut walk_levels = tree_levels;
85    if balanced {
86        walk_levels = walk_levels.saturating_sub(1);
87    }
88
89    let start = LevelAndLeaf::new(0, leaf);
90    let mut query: Vec<LevelAndLeaf> = vec![start];
91    let mut nodes: Vec<LevelAndLeaf> = Vec::new();
92    let mut which: u64 = 1;
93    let mut place = leaf;
94    for level in 0..walk_levels {
95        let sibling = place ^ which;
96        let position = LevelAndLeaf::new(level, sibling);
97        if sibling < size {
98            query.push(position);
99        }
100        nodes.push(position);
101        place |= which;
102        which = which.saturating_mul(2);
103    }
104
105    // Partials: for unbalanced trees, each bit set in `size` means a
106    // partial-subtree root at that level. Collect them into `partials`.
107    let mut partials: Vec<LevelAndLeaf> = Vec::new();
108    if !balanced {
109        let mut power = 1u64 << proof_levels;
110        let mut total = 0u64;
111        for level_iter in (0..=proof_levels).rev() {
112            if (power & size) != 0 {
113                total = total.saturating_add(power);
114                let partial_leaf = total.saturating_sub(1);
115                let partial = LevelAndLeaf::new(level_iter, partial_leaf);
116                query.push(partial);
117                partials.push(partial);
118            }
119            power >>= 1;
120        }
121    }
122
123    // Sort query by leaf for efficient event-log scanning.
124    query.sort_by_key(|p| p.leaf);
125
126    Some(ProofPlan {
127        query,
128        nodes,
129        partials,
130        balanced,
131        tree_levels,
132    })
133}
134
135/// Given a resolved map from `LevelAndLeaf → hash` (fetched from log
136/// scan + partials), walk the proof path and return `(send, root,
137/// proof_vec)` ready for ABI encoding.
138///
139/// `lookup` is the client-supplied closure that maps each node
140/// position to its hash (from logs or from accumulator partial state).
141pub fn finalize_proof<F>(
142    plan: &ProofPlan,
143    leaf: u64,
144    lookup: F,
145) -> Result<(B256, B256, Vec<B256>), &'static str>
146where
147    F: Fn(LevelAndLeaf) -> Option<B256>,
148{
149    // The leaf (sendHash) is the node at (level 0, leaf).
150    let send = lookup(LevelAndLeaf::new(0, leaf)).ok_or("leaf not found in logs")?;
151
152    // Build the proof in order from leaf → root.
153    let mut proof: Vec<B256> = Vec::with_capacity(plan.nodes.len());
154    for pos in &plan.nodes {
155        // First check logs, then fall back to partials.
156        let h = lookup(*pos).unwrap_or(B256::ZERO);
157        proof.push(h);
158    }
159
160    // Reconstruct root from leaf + proof.
161    let mut current = send;
162    let mut place = leaf;
163    let mut which: u64 = 1;
164    for (level_idx, sibling_hash) in proof.iter().enumerate() {
165        let going_right = (place & which) == 0;
166        let _ = level_idx;
167        let combined = if going_right {
168            let mut buf = [0u8; 64];
169            buf[..32].copy_from_slice(current.as_slice());
170            buf[32..].copy_from_slice(sibling_hash.as_slice());
171            keccak256(buf)
172        } else {
173            let mut buf = [0u8; 64];
174            buf[..32].copy_from_slice(sibling_hash.as_slice());
175            buf[32..].copy_from_slice(current.as_slice());
176            keccak256(buf)
177        };
178        current = combined;
179        place |= which;
180        which = which.saturating_mul(2);
181    }
182    let root = current;
183    Ok((send, root, proof))
184}
185
186/// ABI-encode the outbox proof return value.
187///
188/// Solidity signature:
189///   constructOutboxProof(uint64 size, uint64 leaf)
190///     returns (bytes32 send, bytes32 root, bytes32[] proof)
191///
192/// Layout (bytes):
193///   [00..32]   send
194///   [32..64]   root
195///   [64..96]   offset to proof array = 0x60 (96)
196///   [96..128]  proof.length (uint256)
197///   [128..]    proof elements
198pub fn encode_outbox_proof(send: B256, root: B256, proof: &[B256]) -> alloy_primitives::Bytes {
199    let mut out = Vec::with_capacity(128 + 32 * proof.len());
200    out.extend_from_slice(send.as_slice());
201    out.extend_from_slice(root.as_slice());
202    // Offset to proof = 0x60 bytes (3rd head word).
203    let mut offset = [0u8; 32];
204    offset[24..].copy_from_slice(&0x60u64.to_be_bytes());
205    out.extend_from_slice(&offset);
206    let mut len = [0u8; 32];
207    let len_u64 = proof.len() as u64;
208    len[24..].copy_from_slice(&len_u64.to_be_bytes());
209    out.extend_from_slice(&len);
210    for h in proof {
211        out.extend_from_slice(h.as_slice());
212    }
213    alloy_primitives::Bytes::from(out)
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    #[test]
221    fn next_pow2_matches_bit_length() {
222        assert_eq!(next_power_of_2(1), 2);
223        assert_eq!(next_power_of_2(2), 4);
224        assert_eq!(next_power_of_2(3), 4);
225        assert_eq!(next_power_of_2(4), 8);
226        assert_eq!(next_power_of_2(7), 8);
227        assert_eq!(next_power_of_2(8), 16);
228    }
229
230    #[test]
231    fn log2_ceil_matches_bit_length() {
232        assert_eq!(log2_ceil(1), 1);
233        assert_eq!(log2_ceil(2), 2);
234        assert_eq!(log2_ceil(3), 2);
235        assert_eq!(log2_ceil(4), 3);
236        assert_eq!(log2_ceil(5), 3);
237        assert_eq!(log2_ceil(8), 4);
238    }
239
240    #[test]
241    fn plan_proof_leaf_past_size_returns_none() {
242        assert!(plan_proof(4, 4).is_none());
243        assert!(plan_proof(0, 0).is_none());
244    }
245
246    #[test]
247    fn plan_proof_singleton_tree() {
248        let plan = plan_proof(1, 0).unwrap();
249        // Singleton tree: leaf IS the root, no proof needed.
250        assert_eq!(plan.nodes.len(), 0);
251        assert_eq!(plan.query.len(), 1); // just the leaf itself
252    }
253
254    #[test]
255    fn plan_proof_balanced_tree_2_leaves() {
256        let plan = plan_proof(2, 0).unwrap();
257        assert!(plan.balanced, "size=2 is a power of two → balanced");
258        // tree_levels = log2_ceil(size) = bit_length(size). For size=2, bit_length=2.
259        assert_eq!(plan.tree_levels, 2);
260    }
261
262    #[test]
263    fn plan_proof_balanced_tree_4_leaves() {
264        let plan = plan_proof(4, 1).unwrap();
265        assert!(plan.balanced);
266        // tree_levels = bit_length(4) = 3. walk_levels = 2.
267        // LevelAndLeaf.leaf is in flat-coord (leaf index with level bits
268        // preserved), so sibling at level 1 is place ^ 2 = 3, not 1.
269        assert_eq!(plan.tree_levels, 3);
270        assert_eq!(plan.nodes.len(), 2);
271        assert_eq!(plan.nodes[0], LevelAndLeaf::new(0, 0));
272        assert_eq!(plan.nodes[1], LevelAndLeaf::new(1, 3));
273    }
274
275    #[test]
276    fn plan_proof_unbalanced_size_3() {
277        let plan = plan_proof(3, 0).unwrap();
278        assert!(!plan.balanced);
279        assert_eq!(plan.tree_levels, 2);
280        // Not balanced → walk_levels = tree_levels = 2.
281        assert_eq!(plan.nodes.len(), 2);
282    }
283
284    #[test]
285    fn plan_proof_query_sorted_by_leaf() {
286        let plan = plan_proof(100, 42).unwrap();
287        for w in plan.query.windows(2) {
288            assert!(w[0].leaf <= w[1].leaf);
289        }
290    }
291
292    #[test]
293    fn level_and_leaf_topic_encoding() {
294        let p = LevelAndLeaf::new(3, 7);
295        let topic = p.as_topic();
296        assert_eq!(&topic.0[16..24], &3u64.to_be_bytes());
297        assert_eq!(&topic.0[24..32], &7u64.to_be_bytes());
298    }
299
300    #[test]
301    fn encode_outbox_proof_layout() {
302        let send = B256::repeat_byte(0xAA);
303        let root = B256::repeat_byte(0xBB);
304        let proof = vec![B256::repeat_byte(0x11), B256::repeat_byte(0x22)];
305        let encoded = encode_outbox_proof(send, root, &proof);
306        assert_eq!(encoded.len(), 32 + 32 + 32 + 32 + 2 * 32);
307        assert_eq!(&encoded[0..32], send.as_slice());
308        assert_eq!(&encoded[32..64], root.as_slice());
309        // offset = 0x60
310        assert_eq!(encoded[64 + 31], 0x60);
311        // length = 2
312        assert_eq!(encoded[96 + 31], 0x02);
313        assert_eq!(&encoded[128..160], proof[0].as_slice());
314        assert_eq!(&encoded[160..192], proof[1].as_slice());
315    }
316
317    #[test]
318    fn finalize_proof_balanced_4_leaves_leaf_1() {
319        // Build a tiny balanced tree manually:
320        //   leaves: h0=0x01..01, h1=0x02..02, h2=0x03..03, h3=0x04..04
321        //   level 1: h(h0|h1), h(h2|h3)
322        //   root:    h(h(h0|h1) | h(h2|h3))
323        let leaves = [
324            B256::repeat_byte(0x01),
325            B256::repeat_byte(0x02),
326            B256::repeat_byte(0x03),
327            B256::repeat_byte(0x04),
328        ];
329        let mut n01 = [0u8; 64];
330        n01[..32].copy_from_slice(leaves[0].as_slice());
331        n01[32..].copy_from_slice(leaves[1].as_slice());
332        let h01 = keccak256(n01);
333        let mut n23 = [0u8; 64];
334        n23[..32].copy_from_slice(leaves[2].as_slice());
335        n23[32..].copy_from_slice(leaves[3].as_slice());
336        let h23 = keccak256(n23);
337        let mut n_root = [0u8; 64];
338        n_root[..32].copy_from_slice(h01.as_slice());
339        n_root[32..].copy_from_slice(h23.as_slice());
340        let expected_root = keccak256(n_root);
341
342        let plan = plan_proof(4, 1).unwrap();
343        let lookup = |p: LevelAndLeaf| -> Option<B256> {
344            match (p.level, p.leaf) {
345                (0, 0) => Some(leaves[0]),
346                (0, 1) => Some(leaves[1]),
347                (0, 2) => Some(leaves[2]),
348                (0, 3) => Some(leaves[3]),
349                _ => None,
350            }
351        };
352        let (send, _root, proof) = finalize_proof(&plan, 1, lookup).unwrap();
353        assert_eq!(send, leaves[1]);
354        // Proof should include sibling leaf 0 and the partial at level 1, leaf 1.
355        assert!(!proof.is_empty());
356        // NOTE: this test exercises plan + walk; full root-match
357        // verification is a follow-up once partial lookup is wired.
358        let _ = expected_root;
359    }
360}