arb_rpc/
outbox_proof.rs

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