diff --git a/noir-projects/noir-protocol-circuits/crates/rollup-lib/src/abis/l1_to_l2_message_sponge.nr b/noir-projects/noir-protocol-circuits/crates/rollup-lib/src/abis/l1_to_l2_message_sponge.nr new file mode 100644 index 000000000000..9f4af934cce7 --- /dev/null +++ b/noir-projects/noir-protocol-circuits/crates/rollup-lib/src/abis/l1_to_l2_message_sponge.nr @@ -0,0 +1,116 @@ +use std::meta::derive; +use types::{ + hash::poseidon2_absorb_in_chunks_existing_sponge, + poseidon2::Poseidon2Sponge, + traits::{Deserialize, Empty, Serialize}, +}; + +/// An absorb-only Poseidon2 sponge over L1-to-L2 message leaves. +/// +/// It threads across the blocks of a checkpoint the way `SpongeBlob` threads tx effects: each block absorbs its message +/// bundle into the sponge it inherited from the previous block, and the checkpoint root recomputes the sponge over the +/// flat concatenation of every block's messages and asserts the final states are equal. There are no block separators +/// or markers in the absorbed stream, so the recomputation is identical regardless of how messages were split across +/// blocks. The sponge is never squeezed on the block path — equality of the accumulated state is the only check. +#[derive(Deserialize, Eq, Serialize)] +pub struct L1ToL2MessageSponge { + pub sponge: Poseidon2Sponge, + /// The number of message leaves absorbed so far. + pub num_absorbed: u32, +} + +impl L1ToL2MessageSponge { + pub fn new() -> Self { + Self { sponge: Poseidon2Sponge::new(0), num_absorbed: 0 } + } + + /// Absorb the first `num` leaves of `leaves` in order. Lanes beyond `num` are ignored. + pub fn absorb(&mut self, leaves: [Field; N], num: u32) { + self.sponge = poseidon2_absorb_in_chunks_existing_sponge(self.sponge, leaves, num); + self.num_absorbed += num; + } + + /// Finalize the sponge and output the (non-standard) Poseidon2 hash of all absorbed leaves. + pub fn squeeze(&mut self) -> Field { + self.sponge.squeeze() + } +} + +impl Empty for L1ToL2MessageSponge { + fn empty() -> Self { + Self { sponge: Poseidon2Sponge::new(0), num_absorbed: 0 } + } +} + +mod tests { + use super::L1ToL2MessageSponge; + use types::{poseidon2::Poseidon2Sponge, traits::Empty}; + + #[test] + fn new_equals_empty() { + assert_eq(L1ToL2MessageSponge::new(), L1ToL2MessageSponge::empty()); + assert_eq(L1ToL2MessageSponge::new().num_absorbed, 0); + } + + #[test] + fn absorb_tracks_count() { + let mut sponge = L1ToL2MessageSponge::new(); + sponge.absorb([11, 22, 33, 0, 0], 3); + assert_eq(sponge.num_absorbed, 3); + sponge.absorb([44, 55], 2); + assert_eq(sponge.num_absorbed, 5); + } + + #[test] + fn absorb_ignores_lanes_past_count() { + // Lanes beyond `num` must not affect the accumulated state. + let mut with_padding = L1ToL2MessageSponge::new(); + with_padding.absorb([11, 22, 33, 999, 888], 3); + + let mut exact = L1ToL2MessageSponge::new(); + exact.absorb([11, 22, 33], 3); + + assert_eq(with_padding, exact); + } + + #[test] + fn absorb_equality_across_split_boundaries() { + // Absorbing [11,22,33,44,55] in one call equals absorbing [11,22,33] then [44,55] across two threaded sponges. + let mut single = L1ToL2MessageSponge::new(); + single.absorb([11, 22, 33, 44, 55], 5); + + let mut threaded = L1ToL2MessageSponge::new(); + threaded.absorb([11, 22, 33, 44, 55], 3); + threaded.absorb([44, 55, 0, 0, 0], 2); + + assert_eq(threaded, single); + assert_eq(threaded.num_absorbed, 5); + + let mut single_squeeze = single; + let mut threaded_squeeze = threaded; + assert_eq(threaded_squeeze.squeeze(), single_squeeze.squeeze()); + } + + #[test] + fn empty_bundle_is_noop() { + let mut sponge = L1ToL2MessageSponge::new(); + sponge.absorb([0; 5], 0); + assert_eq(sponge, L1ToL2MessageSponge::empty()); + } + + #[test] + fn matches_raw_poseidon2_sponge() { + // The accumulated state must be a plain absorb-only Poseidon2 sponge with iv = 0. + let mut sponge = L1ToL2MessageSponge::new(); + sponge.absorb([11, 22, 33, 44, 55], 5); + + let mut expected = Poseidon2Sponge::new(0); + expected.absorb(11); + expected.absorb(22); + expected.absorb(33); + expected.absorb(44); + expected.absorb(55); + + assert_eq(sponge.sponge, expected); + } +} diff --git a/noir-projects/noir-protocol-circuits/crates/rollup-lib/src/abis/mod.nr b/noir-projects/noir-protocol-circuits/crates/rollup-lib/src/abis/mod.nr index 1ee35685bc37..37d441089b2c 100644 --- a/noir-projects/noir-protocol-circuits/crates/rollup-lib/src/abis/mod.nr +++ b/noir-projects/noir-protocol-circuits/crates/rollup-lib/src/abis/mod.nr @@ -5,9 +5,11 @@ mod block_rollup_public_inputs; mod parity_public_inputs; mod checkpoint_rollup_public_inputs; mod root_rollup_public_inputs; +mod l1_to_l2_message_sponge; pub use block_rollup_public_inputs::BlockRollupPublicInputs; pub use checkpoint_rollup_public_inputs::CheckpointRollupPublicInputs; +pub use l1_to_l2_message_sponge::L1ToL2MessageSponge; pub use parity_public_inputs::ParityPublicInputs; pub use public_chonk_verifier_public_inputs::PublicChonkVerifierPublicInputs; pub use root_rollup_public_inputs::RootRollupPublicInputs; diff --git a/noir-projects/noir-protocol-circuits/crates/rollup-lib/src/inbox_rolling_hash.nr b/noir-projects/noir-protocol-circuits/crates/rollup-lib/src/inbox_rolling_hash.nr new file mode 100644 index 000000000000..c106e466e3d6 --- /dev/null +++ b/noir-projects/noir-protocol-circuits/crates/rollup-lib/src/inbox_rolling_hash.nr @@ -0,0 +1,94 @@ +use types::hash::accumulate_sha256; + +/// Extends the Inbox rolling sha256 chain by the first `num_leaves` of `leaves`, returning the new rolling hash. +/// +/// Each link is `h' = sha256ToField(h || leaf)` over the two 32-byte big-endian values, i.e. exactly one +/// `accumulate_sha256` step, matching the truncated-to-field sha256 the L1 `Inbox` accumulates and today's `inHash` +/// frontier tree uses at every node. The genesis rolling hash is zero. +/// +/// The function takes a `start` and returns an end with no assumption about chunk position, so it chains sequentially +/// across chunked circuits: a segment's start hash is the previous segment's end. Lanes beyond `num_leaves` are never +/// absorbed — the Inbox chain only ever sees real messages. +pub fn accumulate_inbox_rolling_hash( + start: Field, + leaves: [Field; N], + num_leaves: u32, +) -> Field { + assert(num_leaves <= N, "num_leaves is greater than the leaves array length"); + + let mut acc = start; + for i in 0..N { + if i < num_leaves { + acc = accumulate_sha256(acc, leaves[i]); + } + } + acc +} + +mod tests { + use super::accumulate_inbox_rolling_hash; + use types::{constants::NUM_MSGS_PER_BASE_PARITY, hash::accumulate_sha256}; + + #[test] + fn empty_bundle_passes_start_through() { + assert_eq(accumulate_inbox_rolling_hash(0, [0; 8], 0), 0); + // A non-zero start with no leaves is returned unchanged (segment threading base case). + assert_eq(accumulate_inbox_rolling_hash(0x2a, [11, 22, 33], 0), 0x2a); + } + + #[test] + fn single_leaf_matches_reference() { + // Independent sha256 reference (see PR description): sha256ToField(0x00..00 || 0x00..0b), last byte dropped. + let expected = 0x00815fb1e9d2076ae5761439b6144ad11da69eb6c41ab2aca39e770407ad8d12; + assert_eq(accumulate_inbox_rolling_hash(0, [11, 0, 0, 0], 1), expected); + assert_eq(accumulate_sha256(0, 11), expected); + } + + #[test] + fn three_leaves_matches_reference() { + let expected = 0x0014cae968461979aab6d33266a2310ed234d3f6cf4472737c57551db07bd0da; + assert_eq(accumulate_inbox_rolling_hash(0, [11, 22, 33, 0, 0], 3), expected); + } + + #[test] + fn base_parity_batch_matches_reference() { + // NUM_MSGS_PER_BASE_PARITY leaves valued 1..=256 from a zero start. + let mut leaves = [0; NUM_MSGS_PER_BASE_PARITY]; + for i in 0..NUM_MSGS_PER_BASE_PARITY { + leaves[i] = (i + 1) as Field; + } + let expected = 0x00ea95b96f17b75be03525b35a2a1918b42f03ad8c00a437cf641751825f3992; + assert_eq(accumulate_inbox_rolling_hash(0, leaves, NUM_MSGS_PER_BASE_PARITY), expected); + } + + #[test] + fn non_zero_start_matches_reference() { + let expected = 0x0054d96b8a074a5030a5838972d0a3c04ba47cf5956348c853e02e9566233f65; + assert_eq(accumulate_inbox_rolling_hash(0x2a, [7, 8], 2), expected); + } + + #[test] + fn chain_is_continuous_across_segments() { + // Chaining [7,8,9] from a start equals chaining [7] then [8,9] threaded by the intermediate hash. + let start = 0x2a; + let full = accumulate_inbox_rolling_hash(start, [7, 8, 9, 0, 0], 3); + + let mid = accumulate_inbox_rolling_hash(start, [7, 0], 1); + let split = accumulate_inbox_rolling_hash(mid, [8, 9], 2); + + assert_eq(full, split); + } + + #[test] + fn padding_lanes_are_not_absorbed() { + // A padded array with num_leaves set correctly matches the exact-length chain regardless of lane contents. + let padded = accumulate_inbox_rolling_hash(0, [11, 22, 999, 888], 2); + let exact = accumulate_inbox_rolling_hash(0, [11, 22], 2); + assert_eq(padded, exact); + } + + #[test(should_fail_with = "num_leaves is greater than the leaves array length")] + fn num_leaves_past_array_fails() { + let _ = accumulate_inbox_rolling_hash(0, [11, 22, 33], 4); + } +} diff --git a/noir-projects/noir-protocol-circuits/crates/rollup-lib/src/lib.nr b/noir-projects/noir-protocol-circuits/crates/rollup-lib/src/lib.nr index af4d39d00c99..f6c5a2d30577 100644 --- a/noir-projects/noir-protocol-circuits/crates/rollup-lib/src/lib.nr +++ b/noir-projects/noir-protocol-circuits/crates/rollup-lib/src/lib.nr @@ -1,6 +1,7 @@ pub(crate) mod abis; pub(crate) mod tests; +pub mod inbox_rolling_hash; pub mod tx_base; pub mod tx_merge; pub mod block_root; diff --git a/noir-projects/noir-protocol-circuits/crates/types/src/constants.nr b/noir-projects/noir-protocol-circuits/crates/types/src/constants.nr index 0da946df4b89..e47b2de30c1a 100644 --- a/noir-projects/noir-protocol-circuits/crates/types/src/constants.nr +++ b/noir-projects/noir-protocol-circuits/crates/types/src/constants.nr @@ -63,6 +63,12 @@ pub global NULLIFIER_SUBTREE_ROOT_SIBLING_PATH_LENGTH: u32 = NULLIFIER_TREE_HEIGHT - NULLIFIER_SUBTREE_HEIGHT; pub global L1_TO_L2_MSG_SUBTREE_ROOT_SIBLING_PATH_LENGTH: u32 = L1_TO_L2_MSG_TREE_HEIGHT - L1_TO_L2_MSG_SUBTREE_HEIGHT; +// Cap on L1-to-L2 messages bundled into a single L2 block. Transitional value equal to the per-checkpoint cap: the +// constant is deliberately oversized so the first block's bundle can carry a whole checkpoint's padded messages during +// the transition, and drops to 256 at the flip. +pub global MAX_L1_TO_L2_MSGS_PER_BLOCK: u32 = 1024; +// Cap on L1-to-L2 messages consumed by a single checkpoint. Semantically today's NUMBER_OF_L1_L2_MESSAGES_PER_ROLLUP. +pub global MAX_L1_TO_L2_MSGS_PER_CHECKPOINT: u32 = 1024; // Maximum number of subtrees a L2ToL1Msg unbalanced tree can have. Used when calculating the out hash of a tx. pub global MAX_L2_TO_L1_MSG_SUBTREES_PER_TX: u32 = 3; // ceil(log2(MAX_L2_TO_L1_MSGS_PER_TX)) diff --git a/noir-projects/noir-protocol-circuits/crates/types/src/merkle_tree/append_only_tree.nr b/noir-projects/noir-protocol-circuits/crates/types/src/merkle_tree/append_only_tree.nr index 4cd8804ddbe0..757d1941ea08 100644 --- a/noir-projects/noir-protocol-circuits/crates/types/src/merkle_tree/append_only_tree.nr +++ b/noir-projects/noir-protocol-circuits/crates/types/src/merkle_tree/append_only_tree.nr @@ -5,6 +5,7 @@ use crate::{ merkle_tree::{merkle_hash, sha_merkle_hash}, root::{compute_empty_tree_root_with_hasher, root_from_sibling_path_with_hasher}, }, + utils::arrays::assert_trailing_zeros, }; pub fn append_leaf_to_snapshot( @@ -104,6 +105,150 @@ pub fn insert_subtree_root_to_snapshot_with_hasher( + snapshot: AppendOnlyTreeSnapshot, + leaves: [Field; MaxLeaves], + num_leaves: u32, + frontier_hint: [Field; TreeHeight], +) -> AppendOnlyTreeSnapshot { + // We cast the leaf index to a u64, so heights above 64 would insert at the wrong index once the tree grows past + // 2^64 leaves. Checking at compile time turns a bad instantiation into a build error rather than a circuit no + // prover can satisfy. + std::static_assert( + TreeHeight <= 64, + "`append_leaves_to_snapshot` does not support trees with height greater than 64", + ); + assert(num_leaves <= MaxLeaves, "num_leaves is greater than the leaves array length"); + // Hint lanes past `num_leaves` must be zero so they cannot smuggle values into anything a caller later absorbs. + assert_trailing_zeros(leaves, num_leaves); + + // `zero_subtree_roots[level]` is the root of an all-zero subtree of height `level`; index 0 is the empty leaf, 0. + let mut zero_subtree_roots = [0; TreeHeight]; + for level in 1..TreeHeight { + zero_subtree_roots[level] = + merkle_hash(zero_subtree_roots[level - 1], zero_subtree_roots[level - 1]); + } + + // The cast below truncates, so an index above 2^64 would silently drop its high bits and append at the wrong + // position. Callers keep the index far below that by only ever advancing it by the number of leaves appended, but + // nothing in this function's signature enforces it, so pin it here. + snapshot.next_available_leaf_index.assert_max_bit_size::<64>(); + let start_index = snapshot.next_available_leaf_index as u64; + let end_index = start_index + num_leaves as u64; + // Guard against wrapping past the tree capacity, which would otherwise silently produce a wrong root. + let mut tree_is_filled_exactly = false; + if TreeHeight < 64 { + let capacity = 1 as u64 << TreeHeight as u64; + assert(end_index <= capacity, "append_leaves_to_snapshot exceeds the tree capacity"); + tree_is_filled_exactly = end_index == capacity; + } + + // Validate the hint pins to the snapshot: recompute the root of a tree holding `start_index` leaves from the hint's + // left siblings and the zero subtrees to the right. + assert( + recompute_root_from_frontier(frontier_hint, zero_subtree_roots, start_index) + == snapshot.root, + "Frontier hint does not match the snapshot root", + ); + + // Merge the batch into the frontier one level at a time. At each level, `working` holds the batch's nodes, + // starting at absolute position `s` with `c` real entries (lanes past `c` hold garbage that is never read): + // - If `s` is odd the batch starts as a right child, so its pending left sibling — the validated hint entry — is + // prepended via a mux shift (no hashes) to make the batch start even. + // - If the count is then odd, the dangling last node sits at an even position: it is the level's new pending left + // child, so it becomes the frontier entry and drops out of pairing. On an even count the old entry is left in + // place; at the new tree size it is unused garbage, just like unread hint lanes. + // - The remaining nodes are hashed pairwise into the next level. + let mut frontier = frontier_hint; + let mut working = [0; MaxLeaves + 2]; + for i in 0..MaxLeaves { + working[i] = leaves[i]; + } + let mut s = start_index; + let mut c = num_leaves; + for level in 0..TreeHeight { + let prepend = (s % 2 == 1) & (c != 0); + let mut shifted = [0; MaxLeaves + 2]; + shifted[0] = if prepend { frontier[level] } else { working[0] }; + // The loop bounds below only need to upper-bound the batch size at this level; the shift amounts are clamped + // below 32 because a u32 shift by 32+ does not const-fold, and `MaxLeaves >> 31` is already 0. + for j in 1..(MaxLeaves >> std::cmp::min(level, 31)) + 2 { + shifted[j] = if prepend { working[j - 1] } else { working[j] }; + } + let s2 = s - prepend as u64; + let c2 = c + prepend as u32; + + // `c2 - 1` is only meaningful when `c2` is odd (hence >= 1); the subtraction is kept unconditionally safe. + let dangling_index = c2 - (c2 != 0) as u32; + frontier[level] = if c2 % 2 == 1 { + shifted[dangling_index] + } else { + frontier[level] + }; + + for i in 0..(MaxLeaves >> std::cmp::min(level + 1, 31)) + 1 { + working[i] = merkle_hash(shifted[2 * i], shifted[2 * i + 1]); + } + s = s2 / 2; + c = c2 / 2; + } + + // The frontier is now the end-state frontier, so the final root falls out of the same recomputation used to + // validate the hint. The exception is an append that fills the tree exactly: a full tree has no representable + // frontier, but in that case the batch was merged all the way up and `working[0]` is the root itself. + let root = if num_leaves == 0 { + snapshot.root + } else if tree_is_filled_exactly { + working[0] + } else { + recompute_root_from_frontier(frontier, zero_subtree_roots, end_index) + }; + + AppendOnlyTreeSnapshot { + root, + next_available_leaf_index: snapshot.next_available_leaf_index + num_leaves as Field, + } +} + +/// Recomputes the root of an append-only tree that holds `index` leaves (positions `0..index` filled, the rest zero) +/// from its frontier of left siblings and the all-zero subtree roots to the right. +fn recompute_root_from_frontier( + frontier: [Field; TreeHeight], + zero_subtree_roots: [Field; TreeHeight], + index: u64, +) -> Field { + let mut node = 0; // the empty leaf sitting at `index` + let mut idx = index; + for level in 0..TreeHeight { + node = if idx % 2 == 0 { + merkle_hash(node, zero_subtree_roots[level]) + } else { + merkle_hash(frontier[level], node) + }; + idx = idx / 2; + } + node +} + mod tests { use crate::{ abis::append_only_tree_snapshot::AppendOnlyTreeSnapshot, @@ -304,3 +449,315 @@ mod tests { let _ = insert_subtree_root_to_snapshot::<64, 64>(empty_snapshot, sibling_path, 0); } } + +mod append_leaves_tests { + use crate::{ + abis::append_only_tree_snapshot::AppendOnlyTreeSnapshot, + merkle_tree::{ + compute_empty_tree_root, + merkle_tree::{merkle_hash, MerkleTree}, + test_utils::compute_zero_hashes, + }, + utils::arrays::subarray, + }; + use super::{append_leaves_to_snapshot, insert_subtree_root_to_snapshot}; + + fn padded(items: [Field; N]) -> [Field; M] { + let mut out = [0; M]; + for i in 0..N { + out[i] = items[i]; + } + out + } + + fn empty_snapshot() -> AppendOnlyTreeSnapshot { + AppendOnlyTreeSnapshot { + root: compute_empty_tree_root::(), + next_available_leaf_index: 0, + } + } + + #[test] + fn append_empty_bundle_is_noop() { + let snapshot = empty_snapshot::<5>(); + let result = append_leaves_to_snapshot::<5, 8>(snapshot, [0; 8], 0, [0; 5]); + assert_eq(result, snapshot); + } + + #[test] + fn append_single_leaf() { + let snapshot = empty_snapshot::<5>(); + let leaves: [Field; 8] = padded([42]); + let result = append_leaves_to_snapshot::<5, 8>(snapshot, leaves, 1, [0; 5]); + + let expected_root = MerkleTree::<32>::new(padded([42])).get_root(); + assert_eq(result.root, expected_root); + assert_eq(result.next_available_leaf_index, 1); + } + + #[test] + fn append_at_non_aligned_index() { + // Tree already holds 3 leaves, so the next index (3) is not aligned to any power-of-two boundary. + let before_root = MerkleTree::<32>::new(padded([11, 22, 33])).get_root(); + let snapshot = AppendOnlyTreeSnapshot { root: before_root, next_available_leaf_index: 3 }; + // Frontier at index 3: level 0 left sibling is leaf 2 (33), level 1 left sibling is hash(leaf0, leaf1). + // Higher levels are left children at index 3, so their hint entries are unused (and left zero). + let frontier = [33, merkle_hash(11, 22), 0, 0, 0]; + + let new_leaves: [Field; 8] = padded([44, 55]); + let result = append_leaves_to_snapshot::<5, 8>(snapshot, new_leaves, 2, frontier); + + let expected_root = MerkleTree::<32>::new(padded([11, 22, 33, 44, 55])).get_root(); + assert_eq(result.root, expected_root); + assert_eq(result.next_available_leaf_index, 5); + } + + #[test] + fn append_max_size_bundle() { + let snapshot = empty_snapshot::<5>(); + let leaves = [101, 102, 103, 104, 105, 106, 107, 108]; + let result = append_leaves_to_snapshot::<5, 8>(snapshot, leaves, 8, [0; 5]); + + let expected_root = MerkleTree::<32>::new(padded(leaves)).get_root(); + assert_eq(result.root, expected_root); + assert_eq(result.next_available_leaf_index, 8); + } + + #[test] + fn append_partial_bundle_skips_padding_lanes() { + let snapshot = empty_snapshot::<5>(); + let leaves: [Field; 8] = padded([11, 22, 33]); + let result = append_leaves_to_snapshot::<5, 8>(snapshot, leaves, 3, [0; 5]); + + let expected_root = MerkleTree::<32>::new(padded([11, 22, 33])).get_root(); + assert_eq(result.root, expected_root); + assert_eq(result.next_available_leaf_index, 3); + } + + #[test] + fn append_matches_subtree_insert_small() { + // Appending 8 compact leaves at aligned index 0 reproduces inserting a height-3 subtree of the same leaves. + let snapshot = empty_snapshot::<5>(); + let leaves = [101, 102, 103, 104, 105, 106, 107, 108]; + + let subtree_root = MerkleTree::<8>::new(leaves).get_root(); + let sibling_path: [Field; 2] = subarray(compute_zero_hashes::<5>(), 2); + let ref_snapshot = + insert_subtree_root_to_snapshot::<5, 3>(snapshot, sibling_path, subtree_root); + + let leaves8: [Field; 8] = leaves; + let app_snapshot = append_leaves_to_snapshot::<5, 8>(snapshot, leaves8, 8, [0; 5]); + + assert_eq(app_snapshot, ref_snapshot); + } + + #[test] + fn append_matches_subtree_insert_1024() { + // The transitional first-block scenario: appending a full 1024-leaf bundle at aligned index 0 into the + // production-height (36) tree must reproduce today's fixed height-10 subtree insertion bit-for-bit. + let mut leaves = [0; 1024]; + for i in 0..1024 { + leaves[i] = (i + 1) as Field; + } + + let snapshot = empty_snapshot::<36>(); + + let subtree_root = MerkleTree::<1024>::new(leaves).get_root(); + let sibling_path: [Field; 26] = subarray(compute_zero_hashes::<36>(), 9); + let ref_snapshot = + insert_subtree_root_to_snapshot::<36, 10>(snapshot, sibling_path, subtree_root); + + let app_snapshot = append_leaves_to_snapshot::<36, 1024>(snapshot, leaves, 1024, [0; 36]); + + // Both paths produce root 0x27b87d4d3b78d1fc49a6cb4d83e0cc81de7f5972cda7fe29a6a82aa2c255cb3d. + assert_eq(app_snapshot.root, ref_snapshot.root); + assert_eq(app_snapshot.next_available_leaf_index, 1024); + } + + #[test(should_fail_with = "Frontier hint does not match the snapshot root")] + fn append_wrong_frontier_hint_fails() { + let before_root = MerkleTree::<32>::new(padded([11, 22, 33])).get_root(); + let snapshot = AppendOnlyTreeSnapshot { root: before_root, next_available_leaf_index: 3 }; + // Corrupt a pinned frontier entry (level 0), which the root recomputation must reject. + let frontier = [34, merkle_hash(11, 22), 0, 0, 0]; + + let new_leaves: [Field; 8] = padded([44, 55]); + let _ = append_leaves_to_snapshot::<5, 8>(snapshot, new_leaves, 2, frontier); + } + + #[test] + fn append_ignores_unpinned_frontier_lanes() { + // At start index 3 only levels 0 and 1 are right children, so those are the only hint lanes the validation + // reads; the rest are unpinned and entirely prover-chosen. A tree database answering with a sibling path puts + // the zero-subtree roots there, while the tests here pass zeros. Neither may influence the result. + let before_root = MerkleTree::<32>::new(padded([11, 22, 33])).get_root(); + let snapshot = AppendOnlyTreeSnapshot { root: before_root, next_available_leaf_index: 3 }; + let new_leaves: [Field; 8] = padded([44, 55]); + let expected_root = MerkleTree::<32>::new(padded([11, 22, 33, 44, 55])).get_root(); + + // `compute_zero_hashes[i]` is the root of an all-zero subtree of height `i + 1`, i.e. the right sibling a + // sibling path carries at level `i + 1`. + let zero_hashes = compute_zero_hashes::<5>(); + let from_db = [33, merkle_hash(11, 22), zero_hashes[1], zero_hashes[2], zero_hashes[3]]; + let adversarial = [33, merkle_hash(11, 22), 0xdead, 0xbeef, 0xf00d]; + + let from_db_result = append_leaves_to_snapshot::<5, 8>(snapshot, new_leaves, 2, from_db); + assert_eq(from_db_result.root, expected_root); + assert_eq(from_db_result.next_available_leaf_index, 5); + + let junk_result = append_leaves_to_snapshot::<5, 8>(snapshot, new_leaves, 2, adversarial); + assert_eq(junk_result.root, expected_root); + assert_eq(junk_result.next_available_leaf_index, 5); + } + + #[test] + fn append_reuses_stale_pinned_frontier_entry() { + // Appending one leaf at index 5 leaves level 2 untouched by the merge: the batch has no node there. The final + // root recomputation still reads that entry, because the end index 6 is a right child at level 2. The reuse is + // sound only because the start index 5 is a right child at level 2 as well, so the entry is pinned. + let before_root = MerkleTree::<32>::new(padded([11, 22, 33, 44, 55])).get_root(); + let snapshot = AppendOnlyTreeSnapshot { root: before_root, next_available_leaf_index: 5 }; + let level_2_node = merkle_hash(merkle_hash(11, 22), merkle_hash(33, 44)); + let frontier = [55, 0, level_2_node, 0, 0]; + + let new_leaves: [Field; 8] = padded([66]); + let result = append_leaves_to_snapshot::<5, 8>(snapshot, new_leaves, 1, frontier); + + let expected_root = MerkleTree::<32>::new(padded([11, 22, 33, 44, 55, 66])).get_root(); + assert_eq(result.root, expected_root); + assert_eq(result.next_available_leaf_index, 6); + } + + #[test(should_fail_with = "Frontier hint does not match the snapshot root")] + fn append_wrong_stale_pinned_frontier_entry_fails() { + // The level-2 entry that `append_reuses_stale_pinned_frontier_entry` reuses is pinned, so corrupting it is + // rejected instead of silently producing a wrong root. + let before_root = MerkleTree::<32>::new(padded([11, 22, 33, 44, 55])).get_root(); + let snapshot = AppendOnlyTreeSnapshot { root: before_root, next_available_leaf_index: 5 }; + let level_2_node = merkle_hash(merkle_hash(11, 22), merkle_hash(33, 44)); + let frontier = [55, 0, level_2_node + 1, 0, 0]; + + let new_leaves: [Field; 8] = padded([66]); + let _ = append_leaves_to_snapshot::<5, 8>(snapshot, new_leaves, 1, frontier); + } + + #[test] + fn append_filling_tree_exactly() { + // Filling the last four leaves of a height-4 tree takes the exact-fill branch: the post-state has no + // representable frontier, so the root comes from the top of the merged batch instead of a recomputation. + // Levels 0 and 1 are left children at index 12, so their junk hint lanes must not reach the result. + let mut pre_leaves = [0; 16]; + for i in 0..12 { + pre_leaves[i] = (100 + i) as Field; + } + let pre_tree = MerkleTree::new(pre_leaves); + let snapshot = + AppendOnlyTreeSnapshot { root: pre_tree.get_root(), next_available_leaf_index: 12 }; + // Levels 2 and 3 are right children at index 12: the node over leaves 8..11 and the node over leaves 0..7. + let level_2_node = merkle_hash(merkle_hash(108, 109), merkle_hash(110, 111)); + let left_half = MerkleTree::<8>::new([100, 101, 102, 103, 104, 105, 106, 107]); + let frontier = [0xdead, 0xbeef, level_2_node, left_half.get_root()]; + + let batch: [Field; 6] = padded([201, 202, 203, 204]); + let result = append_leaves_to_snapshot::<4, 6>(snapshot, batch, 4, frontier); + + let mut all_leaves = pre_leaves; + for i in 0..4 { + all_leaves[12 + i] = (201 + i) as Field; + } + assert_eq(result.root, MerkleTree::new(all_leaves).get_root()); + assert_eq(result.next_available_leaf_index, 16); + } + + #[test(should_fail_with = "append_leaves_to_snapshot exceeds the tree capacity")] + fn append_past_tree_capacity_fails() { + // A height-4 tree holds 16 leaves, so appending 3 from index 14 overruns it. The capacity check runs before + // the hint validation, hence the all-zero frontier here. + let mut pre_leaves = [0; 16]; + for i in 0..14 { + pre_leaves[i] = (100 + i) as Field; + } + let pre_tree = MerkleTree::new(pre_leaves); + let snapshot = + AppendOnlyTreeSnapshot { root: pre_tree.get_root(), next_available_leaf_index: 14 }; + + let batch: [Field; 6] = padded([201, 202, 203]); + let _ = append_leaves_to_snapshot::<4, 6>(snapshot, batch, 3, [0; 4]); + } + + #[test(should_fail_with = "Found non-zero field after breakpoint")] + fn append_non_zero_padding_lane_fails() { + let snapshot = empty_snapshot::<5>(); + // num_leaves is 1, but a later lane carries a non-zero value. + let leaves = [42, 0, 99, 0, 0, 0, 0, 0]; + let _ = append_leaves_to_snapshot::<5, 8>(snapshot, leaves, 1, [0; 5]); + } + + /// Extracts the frontier at `index` from a height-4 reference tree: the node at each level's position + /// `(index >> level) - 1` when that position is odd, i.e. the pending left sibling. + unconstrained fn frontier_of_height_4_tree(tree: MerkleTree<16>, index: u32) -> [Field; 4] { + // Level offsets into `MerkleTree.nodes` for N = 16: level 1 starts at 0, level 2 at 8, level 3 at 12. + let offsets = [0, 8, 12]; + let mut frontier = [0; 4]; + if index % 2 == 1 { + frontier[0] = tree.leaves[index - 1]; + } + for level in 1..4 { + let p = index >> level; + if p % 2 == 1 { + frontier[level] = tree.nodes[offsets[level - 1] + p - 1]; + } + } + frontier + } + + #[test] + unconstrained fn append_exhaustive_small_tree_sweep() { + // Height-4 tree (capacity 16) with MaxLeaves = 6: every (start, num) with start + num <= capacity, compared + // against a reference tree built over the concatenated leaves. Exercises every parity combination of the + // prepend/dangling logic, including appends that fill the tree exactly. + for start in 0..16 { + for num in 0..7 { + if start + num <= 16 { + let mut pre_leaves = [0; 16]; + for i in 0..start { + pre_leaves[i] = (100 + i) as Field; + } + let pre_tree = MerkleTree::new(pre_leaves); + let snapshot = AppendOnlyTreeSnapshot { + root: pre_tree.get_root(), + next_available_leaf_index: start as Field, + }; + let frontier = frontier_of_height_4_tree(pre_tree, start); + + let mut batch = [0; 6]; + let mut all_leaves = pre_leaves; + for i in 0..num { + let value = (200 + start + i) as Field; + batch[i] = value; + all_leaves[start + i] = value; + } + + let result = append_leaves_to_snapshot::<4, 6>(snapshot, batch, num, frontier); + + let expected_tree = MerkleTree::new(all_leaves); + assert_eq(result.root, expected_tree.get_root()); + assert_eq(result.next_available_leaf_index, (start + num) as Field); + } + } + } + } + + #[test(should_fail_with = "Frontier hint does not match the snapshot root")] + fn append_to_completely_full_tree_fails() { + // A full tree has no representable frontier; even a num_leaves = 0 call fails hint validation (fail-closed). + let mut leaves = [0; 16]; + for i in 0..16 { + leaves[i] = (100 + i) as Field; + } + let tree = MerkleTree::new(leaves); + let snapshot = + AppendOnlyTreeSnapshot { root: tree.get_root(), next_available_leaf_index: 16 }; + let _ = append_leaves_to_snapshot::<4, 6>(snapshot, [0; 6], 0, [0; 4]); + } +} diff --git a/noir-projects/noir-protocol-circuits/crates/types/src/poseidon2.nr b/noir-projects/noir-protocol-circuits/crates/types/src/poseidon2.nr index b56b419c225d..7883a04a4f8b 100644 --- a/noir-projects/noir-protocol-circuits/crates/types/src/poseidon2.nr +++ b/noir-projects/noir-protocol-circuits/crates/types/src/poseidon2.nr @@ -23,7 +23,7 @@ impl Poseidon2Sponge { Poseidon2Sponge::hash_internal(input, message_size, message_size != N) } - pub(crate) fn new(iv: Field) -> Poseidon2Sponge { + pub fn new(iv: Field) -> Poseidon2Sponge { let mut result = Poseidon2Sponge { cache: [0; 3], state: [0; 4], cache_size: 0, squeeze_mode: false }; result.state[RATE] = iv;