Skip to content

Commit 8440af8

Browse files
committed
feat: message-bundle components in rollup-lib (A-1372)
Pure library components for the Fast Inbox (AZIP-22), consumed by nothing yet: new MAX_L1_TO_L2_MSGS_PER_BLOCK / MAX_L1_TO_L2_MSGS_PER_CHECKPOINT constants, a variable-length frontier-based append to an AppendOnlyTreeSnapshot at arbitrary (non-aligned) indices, an absorb-only poseidon message-bundle sponge (L1ToL2MessageSponge), and the rolling sha256 chain helper (accumulate_inbox_rolling_hash) matching the L1 truncated-to-field policy. No circuit interface changes.
1 parent 9e217b2 commit 8440af8

8 files changed

Lines changed: 468 additions & 1 deletion

File tree

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
use std::meta::derive;
2+
use types::{
3+
hash::poseidon2_absorb_in_chunks_existing_sponge,
4+
poseidon2::Poseidon2Sponge,
5+
traits::{Deserialize, Empty, Serialize},
6+
};
7+
8+
/// An absorb-only Poseidon2 sponge over L1-to-L2 message leaves.
9+
///
10+
/// It threads across the blocks of a checkpoint the way `SpongeBlob` threads tx effects: each block absorbs its message
11+
/// bundle into the sponge it inherited from the previous block, and the checkpoint root recomputes the sponge over the
12+
/// flat concatenation of every block's messages and asserts the final states are equal. There are no block separators
13+
/// or markers in the absorbed stream, so the recomputation is identical regardless of how messages were split across
14+
/// blocks. The sponge is never squeezed on the block path — equality of the accumulated state is the only check.
15+
#[derive(Deserialize, Eq, Serialize)]
16+
pub struct L1ToL2MessageSponge {
17+
pub sponge: Poseidon2Sponge,
18+
/// The number of message leaves absorbed so far.
19+
pub num_absorbed: u32,
20+
}
21+
22+
impl L1ToL2MessageSponge {
23+
pub fn new() -> Self {
24+
Self { sponge: Poseidon2Sponge::new(0), num_absorbed: 0 }
25+
}
26+
27+
/// Absorb the first `num` leaves of `leaves` in order. Lanes beyond `num` are ignored.
28+
pub fn absorb<let N: u32>(&mut self, leaves: [Field; N], num: u32) {
29+
self.sponge = poseidon2_absorb_in_chunks_existing_sponge(self.sponge, leaves, num);
30+
self.num_absorbed += num;
31+
}
32+
33+
/// Finalize the sponge and output the (non-standard) Poseidon2 hash of all absorbed leaves.
34+
pub fn squeeze(&mut self) -> Field {
35+
self.sponge.squeeze()
36+
}
37+
}
38+
39+
impl Empty for L1ToL2MessageSponge {
40+
fn empty() -> Self {
41+
Self { sponge: Poseidon2Sponge::new(0), num_absorbed: 0 }
42+
}
43+
}
44+
45+
mod tests {
46+
use super::L1ToL2MessageSponge;
47+
use types::{poseidon2::Poseidon2Sponge, traits::Empty};
48+
49+
#[test]
50+
fn new_equals_empty() {
51+
assert_eq(L1ToL2MessageSponge::new(), L1ToL2MessageSponge::empty());
52+
assert_eq(L1ToL2MessageSponge::new().num_absorbed, 0);
53+
}
54+
55+
#[test]
56+
fn absorb_tracks_count() {
57+
let mut sponge = L1ToL2MessageSponge::new();
58+
sponge.absorb([11, 22, 33, 0, 0], 3);
59+
assert_eq(sponge.num_absorbed, 3);
60+
sponge.absorb([44, 55], 2);
61+
assert_eq(sponge.num_absorbed, 5);
62+
}
63+
64+
#[test]
65+
fn absorb_ignores_lanes_past_count() {
66+
// Lanes beyond `num` must not affect the accumulated state.
67+
let mut with_padding = L1ToL2MessageSponge::new();
68+
with_padding.absorb([11, 22, 33, 999, 888], 3);
69+
70+
let mut exact = L1ToL2MessageSponge::new();
71+
exact.absorb([11, 22, 33], 3);
72+
73+
assert_eq(with_padding, exact);
74+
}
75+
76+
#[test]
77+
fn absorb_equality_across_split_boundaries() {
78+
// Absorbing [11,22,33,44,55] in one call equals absorbing [11,22,33] then [44,55] across two threaded sponges.
79+
let mut single = L1ToL2MessageSponge::new();
80+
single.absorb([11, 22, 33, 44, 55], 5);
81+
82+
let mut threaded = L1ToL2MessageSponge::new();
83+
threaded.absorb([11, 22, 33, 44, 55], 3);
84+
threaded.absorb([44, 55, 0, 0, 0], 2);
85+
86+
assert_eq(threaded, single);
87+
assert_eq(threaded.num_absorbed, 5);
88+
89+
let mut single_squeeze = single;
90+
let mut threaded_squeeze = threaded;
91+
assert_eq(threaded_squeeze.squeeze(), single_squeeze.squeeze());
92+
}
93+
94+
#[test]
95+
fn empty_bundle_is_noop() {
96+
let mut sponge = L1ToL2MessageSponge::new();
97+
sponge.absorb([0; 5], 0);
98+
assert_eq(sponge, L1ToL2MessageSponge::empty());
99+
}
100+
101+
#[test]
102+
fn matches_raw_poseidon2_sponge() {
103+
// The accumulated state must be a plain absorb-only Poseidon2 sponge with iv = 0.
104+
let mut sponge = L1ToL2MessageSponge::new();
105+
sponge.absorb([11, 22, 33, 44, 55], 5);
106+
107+
let mut expected = Poseidon2Sponge::new(0);
108+
expected.absorb(11);
109+
expected.absorb(22);
110+
expected.absorb(33);
111+
expected.absorb(44);
112+
expected.absorb(55);
113+
114+
assert_eq(sponge.sponge, expected);
115+
}
116+
}

noir-projects/noir-protocol-circuits/crates/rollup-lib/src/abis/mod.nr

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ mod block_rollup_public_inputs;
55
mod parity_public_inputs;
66
mod checkpoint_rollup_public_inputs;
77
mod root_rollup_public_inputs;
8+
mod l1_to_l2_message_sponge;
89

910
pub use block_rollup_public_inputs::BlockRollupPublicInputs;
1011
pub use checkpoint_rollup_public_inputs::CheckpointRollupPublicInputs;
12+
pub use l1_to_l2_message_sponge::L1ToL2MessageSponge;
1113
pub use parity_public_inputs::ParityPublicInputs;
1214
pub use public_chonk_verifier_public_inputs::PublicChonkVerifierPublicInputs;
1315
pub use root_rollup_public_inputs::RootRollupPublicInputs;
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
use types::hash::accumulate_sha256;
2+
3+
/// Extends the Inbox rolling sha256 chain by the first `num_leaves` of `leaves`, returning the new rolling hash.
4+
///
5+
/// Each link is `h' = sha256ToField(h || leaf)` over the two 32-byte big-endian values, i.e. exactly one
6+
/// `accumulate_sha256` step, matching the truncated-to-field sha256 the L1 `Inbox` accumulates and today's `inHash`
7+
/// frontier tree uses at every node. The genesis rolling hash is zero.
8+
///
9+
/// The function takes a `start` and returns an end with no assumption about chunk position, so it chains sequentially
10+
/// across chunked circuits: a segment's start hash is the previous segment's end. Lanes beyond `num_leaves` are never
11+
/// absorbed — the Inbox chain only ever sees real messages.
12+
pub fn accumulate_inbox_rolling_hash<let N: u32>(
13+
start: Field,
14+
leaves: [Field; N],
15+
num_leaves: u32,
16+
) -> Field {
17+
assert(num_leaves <= N, "num_leaves is greater than the leaves array length");
18+
19+
let mut acc = start;
20+
for i in 0..N {
21+
if i < num_leaves {
22+
acc = accumulate_sha256(acc, leaves[i]);
23+
}
24+
}
25+
acc
26+
}
27+
28+
mod tests {
29+
use super::accumulate_inbox_rolling_hash;
30+
use types::{constants::NUM_MSGS_PER_BASE_PARITY, hash::accumulate_sha256};
31+
32+
#[test]
33+
fn empty_bundle_passes_start_through() {
34+
assert_eq(accumulate_inbox_rolling_hash(0, [0; 8], 0), 0);
35+
// A non-zero start with no leaves is returned unchanged (segment threading base case).
36+
assert_eq(accumulate_inbox_rolling_hash(0x2a, [11, 22, 33], 0), 0x2a);
37+
}
38+
39+
#[test]
40+
fn single_leaf_matches_reference() {
41+
// Independent sha256 reference (see PR description): sha256ToField(0x00..00 || 0x00..0b), last byte dropped.
42+
let expected = 0x00815fb1e9d2076ae5761439b6144ad11da69eb6c41ab2aca39e770407ad8d12;
43+
assert_eq(accumulate_inbox_rolling_hash(0, [11, 0, 0, 0], 1), expected);
44+
assert_eq(accumulate_sha256(0, 11), expected);
45+
}
46+
47+
#[test]
48+
fn three_leaves_matches_reference() {
49+
let expected = 0x0014cae968461979aab6d33266a2310ed234d3f6cf4472737c57551db07bd0da;
50+
assert_eq(accumulate_inbox_rolling_hash(0, [11, 22, 33, 0, 0], 3), expected);
51+
}
52+
53+
#[test]
54+
fn base_parity_batch_matches_reference() {
55+
// NUM_MSGS_PER_BASE_PARITY leaves valued 1..=256 from a zero start.
56+
let mut leaves = [0; NUM_MSGS_PER_BASE_PARITY];
57+
for i in 0..NUM_MSGS_PER_BASE_PARITY {
58+
leaves[i] = (i + 1) as Field;
59+
}
60+
let expected = 0x00ea95b96f17b75be03525b35a2a1918b42f03ad8c00a437cf641751825f3992;
61+
assert_eq(accumulate_inbox_rolling_hash(0, leaves, NUM_MSGS_PER_BASE_PARITY), expected);
62+
}
63+
64+
#[test]
65+
fn non_zero_start_matches_reference() {
66+
let expected = 0x0054d96b8a074a5030a5838972d0a3c04ba47cf5956348c853e02e9566233f65;
67+
assert_eq(accumulate_inbox_rolling_hash(0x2a, [7, 8], 2), expected);
68+
}
69+
70+
#[test]
71+
fn chain_is_continuous_across_segments() {
72+
// Chaining [7,8,9] from a start equals chaining [7] then [8,9] threaded by the intermediate hash.
73+
let start = 0x2a;
74+
let full = accumulate_inbox_rolling_hash(start, [7, 8, 9, 0, 0], 3);
75+
76+
let mid = accumulate_inbox_rolling_hash(start, [7, 0], 1);
77+
let split = accumulate_inbox_rolling_hash(mid, [8, 9], 2);
78+
79+
assert_eq(full, split);
80+
}
81+
82+
#[test]
83+
fn padding_lanes_are_not_absorbed() {
84+
// A padded array with num_leaves set correctly matches the exact-length chain regardless of lane contents.
85+
let padded = accumulate_inbox_rolling_hash(0, [11, 22, 999, 888], 2);
86+
let exact = accumulate_inbox_rolling_hash(0, [11, 22], 2);
87+
assert_eq(padded, exact);
88+
}
89+
90+
#[test(should_fail_with = "num_leaves is greater than the leaves array length")]
91+
fn num_leaves_past_array_fails() {
92+
let _ = accumulate_inbox_rolling_hash(0, [11, 22, 33], 4);
93+
}
94+
}

noir-projects/noir-protocol-circuits/crates/rollup-lib/src/lib.nr

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
pub(crate) mod abis;
22
pub(crate) mod tests;
33

4+
pub mod inbox_rolling_hash;
45
pub mod tx_base;
56
pub mod tx_merge;
67
pub mod block_root;

noir-projects/noir-protocol-circuits/crates/types/src/constants.nr

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,12 @@ pub global NULLIFIER_SUBTREE_ROOT_SIBLING_PATH_LENGTH: u32 =
6363
NULLIFIER_TREE_HEIGHT - NULLIFIER_SUBTREE_HEIGHT;
6464
pub global L1_TO_L2_MSG_SUBTREE_ROOT_SIBLING_PATH_LENGTH: u32 =
6565
L1_TO_L2_MSG_TREE_HEIGHT - L1_TO_L2_MSG_SUBTREE_HEIGHT;
66+
// Cap on L1-to-L2 messages bundled into a single L2 block. Transitional value equal to the per-checkpoint cap: the
67+
// constant is deliberately oversized so the first block's bundle can carry a whole checkpoint's padded messages during
68+
// the transition, and drops to 256 at the flip.
69+
pub global MAX_L1_TO_L2_MSGS_PER_BLOCK: u32 = 1024;
70+
// Cap on L1-to-L2 messages consumed by a single checkpoint. Semantically today's NUMBER_OF_L1_L2_MESSAGES_PER_ROLLUP.
71+
pub global MAX_L1_TO_L2_MSGS_PER_CHECKPOINT: u32 = 1024;
6672
// Maximum number of subtrees a L2ToL1Msg unbalanced tree can have. Used when calculating the out hash of a tx.
6773
pub global MAX_L2_TO_L1_MSG_SUBTREES_PER_TX: u32 = 3; // ceil(log2(MAX_L2_TO_L1_MSGS_PER_TX))
6874

0 commit comments

Comments
 (0)