Skip to content

Commit a144601

Browse files
authored
refactor: address rollup circuit audit comments (#20077)
- Fix incorrect error message - Rename `copy_items_into_array` to `splice_at_count` and simplify it Other changes not from the audit - Use `splice_at_count` for merging non-revertible/revertible log arrays - Remove the confusing and more expensive `array_merge`
2 parents e697131 + fff2ef9 commit a144601

File tree

12 files changed

+318
-515
lines changed

12 files changed

+318
-515
lines changed

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

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use crate::abis::CheckpointRollupPublicInputs;
2-
use types::{constants::MAX_CHECKPOINTS_PER_EPOCH, utils::arrays::copy_items_into_array};
2+
use types::{constants::MAX_CHECKPOINTS_PER_EPOCH, utils::arrays::splice_at_count};
33

44
pub fn merge_checkpoint_rollups(
55
left: CheckpointRollupPublicInputs,
@@ -8,11 +8,8 @@ pub fn merge_checkpoint_rollups(
88
let num_left_checkpoints = left.num_checkpoints() as u32;
99
let num_right_checkpoints = right.num_checkpoints() as u32;
1010

11-
// Make sure that the total number of checkpoints does not exceed the maximum allowed in an epoch, to prevent the
12-
// merged arrays (`checkpoint_header_hashes`, `out_hashes`, `fees`) from being truncated within the below calls to
13-
// `copy_items_into_array`.
11+
// Make sure that the total number of checkpoints does not exceed the maximum allowed in an epoch.
1412
let num_checkpoints = num_left_checkpoints + num_right_checkpoints;
15-
1613
assert(
1714
num_checkpoints <= MAX_CHECKPOINTS_PER_EPOCH,
1815
"total number of checkpoints exceeds max allowed in an epoch",
@@ -24,27 +21,32 @@ pub fn merge_checkpoint_rollups(
2421
// (which is the only property that doesn't have empty values), for the number of fees.
2522
// Hence, we don't need to assert left.num_fees == num_left_checkpoints and right.num_fees == num_right_checkpoints.
2623

27-
// We use `copy_items_into_array` here because we know exactly how many items should be taken from each side when
28-
// merging checkpoints.
29-
// It is especially critical for `fees` to copy the exact amount of items from both checkpoints:
30-
// The L1 contracts will process fees by index, assuming each entry corresponds to the checkpoint at the same
31-
// position. However, a checkpoint may have zero fees and recipient as it is not prohibited by the circuit.
32-
// Therefore, preserving empty values is important.
33-
// Using this helper (instead of helper like `array_merge`) ensures those empty entries are copied over.
24+
// `splice_at_count` uses the number of **left** checkpoints to decide exactly how many items to include from each
25+
// side. This approach is more efficient than merging all non-empty items, and, importantly, it ensures the correct
26+
// alignment of array items. It would be incorrect to simply merge non-empty items.
27+
// For example, while header hashes are always present, the `fees` array can have empty (zeroed) entries that must
28+
// be preserved at their original indices. The L1 contract expects the fee at each index to correspond to the
29+
// checkpoint at the same index, even if that fee (or recipient) is zero. Therefore, we must always preserve all fee
30+
// items during the merge. Merging by `num_checkpoints` guarantees that array indices in all merged arrays remain
31+
// properly aligned.
3432

35-
let checkpoint_header_hashes = copy_items_into_array(
33+
let checkpoint_header_hashes = splice_at_count(
3634
left.checkpoint_header_hashes,
3735
num_left_checkpoints,
3836
right.checkpoint_header_hashes,
39-
num_right_checkpoints,
37+
);
38+
// Sanity check that the length of the array is defined correctly.
39+
std::static_assert(
40+
checkpoint_header_hashes.len() == MAX_CHECKPOINTS_PER_EPOCH,
41+
"The length of the checkpoint header hashes array does not match the constant MAX_CHECKPOINTS_PER_EPOCH",
4042
);
4143

4244
// We can't merge fees for the same recipient since the l1 contract iterates per checkpoint.
43-
let fees = copy_items_into_array(
44-
left.fees,
45-
num_left_checkpoints,
46-
right.fees,
47-
num_right_checkpoints,
45+
let fees = splice_at_count(left.fees, num_left_checkpoints, right.fees);
46+
// Sanity check that the length of the array is defined correctly.
47+
std::static_assert(
48+
fees.len() == MAX_CHECKPOINTS_PER_EPOCH,
49+
"The length of the fees array does not match the constant MAX_CHECKPOINTS_PER_EPOCH",
4850
);
4951

5052
CheckpointRollupPublicInputs {
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
use types::{
2+
abis::{log_hash::LogHash, private_log::PrivateLog},
3+
side_effect::Scoped,
4+
utils::arrays::array_length_until,
5+
};
6+
7+
// The logic in this file relies on the property that log arrays are "dense-trimmed": all logs with non-zero length
8+
// appear first, followed by logs with zero length (used as padding).
9+
// This structure is enforced by the Tail-to-Public kernel circuit and remains intact even after array concatenation in
10+
// the public_tx_effect_builder. As a result, we can safely use the index of the first zero-length log to determine the
11+
// logical length of the logs array.
12+
//
13+
// @dev: Unlike other smaller arrays (note hashes, nullifiers, and l2-to-l1 messages) that use `array_length`, it is
14+
// more efficient for log arrays to use `array_length_until` and iterate through all lengths (u32).
15+
// `array_length_until` is also more efficient than an optimized version of `array_length` that would call the
16+
// unconstrained `find_first_index` and then validate the result by checking just the lengths of two adjacent elements
17+
// (rather than verifying whether the entire log is empty).
18+
19+
/// Returns the number of private logs with length > 0.
20+
pub fn get_private_log_array_length<let N: u32>(array: [PrivateLog; N]) -> u32 {
21+
array_length_until(array, |log| log.length == 0)
22+
}
23+
24+
/// Returns the number of contract class log hashes with length > 0.
25+
pub fn get_contract_class_log_hash_array_length<let N: u32>(array: [Scoped<LogHash>; N]) -> u32 {
26+
array_length_until(array, |log| log.inner.length == 0)
27+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
pub mod fees;
2+
pub mod log_array_length;
23
pub mod private_tail_validator;
34
pub mod private_tx_effect_builder;
45
pub mod public_tx_effect_builder;

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ pub fn validate_tx_constant_data(
6565
},
6666
block_last_archive_root,
6767
),
68-
"Membership check failed: previous block hash not found in archive tree",
68+
"Membership check failed: anchor block header hash not found in archive tree",
6969
);
7070

7171
assert_eq(tx_chain_id, block_chain_id, "Mismatched chain_id between kernel and rollup");

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

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
use crate::tx_base::components::fees::compute_transaction_fee;
2+
use super::log_array_length::{
3+
get_contract_class_log_hash_array_length, get_private_log_array_length,
4+
};
25
use types::{
36
abis::{
47
block_constant_data::BlockConstantData, contract_class_log::ContractClassLog,
@@ -13,7 +16,7 @@ use types::{
1316
data::public_data_tree_leaf_preimage::PublicDataTreeLeafPreimage,
1417
hash::compute_l2_to_l1_message_hash,
1518
traits::{Empty, Hash},
16-
utils::arrays::{array_length, array_length_until},
19+
utils::arrays::array_length,
1720
};
1821

1922
pub fn build_tx_effect(
@@ -80,23 +83,23 @@ pub fn build_tx_effect(
8083
contract_class_logs,
8184
};
8285

83-
// We can use `array_length_until` to check only the length, because the kernel circuits guarantee that logs with
84-
// non-zero length are left-packed. In other words, there cannot be a log with length 0 before a log with non-zero
85-
// length.
86-
let private_logs_array_length =
87-
array_length_until(accumulated_data.private_logs, |log| log.length == 0);
88-
let contract_class_logs_array_length = array_length_until(
89-
accumulated_data.contract_class_logs_hashes,
90-
|log| log.inner.length == 0,
91-
);
86+
// The Tail kernel circuit guarantees that all accumulated data arrays are "dense-trimmed": all actual data emitted
87+
// by the tx are non-empty and appear first, followed by empty padding elements. This means it is safe to use
88+
// `array_length` to get the true logical length of these arrays.
89+
// For log arrays, we use specialized functions (get_private_log_array_length,
90+
// get_contract_class_log_hash_array_length) that efficiently determine the length by finding the first zero-length
91+
// log. Because the kernel enforces that only actual logs have nonzero length and all padding logs have zero length,
92+
// these functions safely provide the correct count, independent of the log contents themselves.
9293

9394
let tx_effect_array_lengths = TxEffectArrayLengths {
9495
note_hashes: array_length(accumulated_data.note_hashes),
9596
nullifiers: array_length(accumulated_data.nullifiers),
9697
l2_to_l1_msgs: array_length(l2_to_l1_msgs),
9798
public_data_writes: 1, // No public functions were called. This is the fee_payer's FeeJuice balance decrementation.
98-
private_logs: private_logs_array_length,
99-
contract_class_logs: contract_class_logs_array_length,
99+
private_logs: get_private_log_array_length(accumulated_data.private_logs),
100+
contract_class_logs: get_contract_class_log_hash_array_length(
101+
accumulated_data.contract_class_logs_hashes,
102+
),
100103
};
101104

102105
(tx_effect, tx_effect_array_lengths)

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

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
use crate::tx_base::components::private_tail_validator;
2+
use super::log_array_length::{
3+
get_contract_class_log_hash_array_length, get_private_log_array_length,
4+
};
25
use types::{
36
abis::{
47
avm_circuit_public_inputs::AvmCircuitPublicInputs, contract_class_log::ContractClassLog,
@@ -8,7 +11,7 @@ use types::{
811
constants::{CONTRACT_CLASS_LOG_SIZE_IN_FIELDS, MAX_CONTRACT_CLASS_LOGS_PER_TX},
912
hash::compute_l2_to_l1_message_hash,
1013
traits::Hash,
11-
utils::arrays::{array_length_until, array_merge},
14+
utils::arrays::splice_at_count,
1215
};
1316

1417
pub fn build_tx_effect(
@@ -49,13 +52,25 @@ pub fn build_tx_effect(
4952
// are conditionally discarded by the avm circuit. Private logs and contract class logs are
5053
// different, in that they're only emitted by private functions and so the avm doesn't care about them
5154
// or "see" them.
55+
//
56+
// Note: splice_at_count is safe to use here because both input arrays were originally split from a single
57+
// dense-trimmed array [T; MAX_PER_TX] in the Tail-to-Public kernel circuit.
58+
// (For general arrays, dense-trimmed means all non-empty entries appear before any empty entries; for logs, all
59+
// logs with nonzero length appear before any zero-length logs.)
60+
// The Tail-to-Public kernel ensures the 2 split arrays are also dense-trimmed.
61+
// This guarantees that merging them will never produce more than MAX_PER_TX items and no actual values will be lost
62+
// or truncated. And after concatenation, the resulting array remains dense-trimmed.
5263

5364
// Conditionally discard the revertible private logs.
5465
let private_logs = if reverted {
5566
private_to_public.non_revertible_accumulated_data.private_logs
5667
} else {
57-
array_merge(
68+
let num_non_revertible_private_logs = get_private_log_array_length(
5869
private_to_public.non_revertible_accumulated_data.private_logs,
70+
);
71+
splice_at_count(
72+
private_to_public.non_revertible_accumulated_data.private_logs,
73+
num_non_revertible_private_logs,
5974
private_to_public.revertible_accumulated_data.private_logs,
6075
)
6176
};
@@ -64,8 +79,12 @@ pub fn build_tx_effect(
6479
let contract_class_log_hashes = if reverted {
6580
private_to_public.non_revertible_accumulated_data.contract_class_logs_hashes
6681
} else {
67-
array_merge(
82+
let num_non_revertible_contract_class_log_hashes = get_contract_class_log_hash_array_length(
83+
private_to_public.non_revertible_accumulated_data.contract_class_logs_hashes,
84+
);
85+
splice_at_count(
6886
private_to_public.non_revertible_accumulated_data.contract_class_logs_hashes,
87+
num_non_revertible_contract_class_log_hashes,
6988
private_to_public.revertible_accumulated_data.contract_class_logs_hashes,
7089
)
7190
};
@@ -102,12 +121,9 @@ pub fn build_tx_effect(
102121
contract_class_logs,
103122
};
104123

105-
// We can use `array_length_until` to check only the length, because the kernel circuits guarantee that logs with
106-
// non-zero length are left-packed. In other words, there cannot be a log with length 0 before a log with non-zero
107-
// length.
108-
let private_logs_array_length = array_length_until(private_logs, |log| log.length == 0);
124+
let private_logs_array_length = get_private_log_array_length(private_logs);
109125
let contract_class_logs_array_length =
110-
array_length_until(contract_class_log_hashes, |log| log.inner.length == 0);
126+
get_contract_class_log_hash_array_length(contract_class_log_hashes);
111127

112128
let tx_effect_array_lengths = TxEffectArrayLengths {
113129
note_hashes: avm.accumulated_data_array_lengths.note_hashes,

noir-projects/noir-protocol-circuits/crates/rollup-lib/src/tx_base/tests/private_tx_base/validate_private_tail_tests.nr

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use super::TestBuilder;
22
use types::{constants::AVM_MAX_PROCESSABLE_L2_GAS, tests::fixture_builder::FixtureBuilder};
33

4-
#[test(should_fail_with = "Membership check failed: previous block hash not found in archive tree")]
4+
#[test(should_fail_with = "Membership check failed: anchor block header hash not found in archive tree")]
55
unconstrained fn anchor_block_header_not_in_archive() {
66
let mut builder = TestBuilder::new();
77

noir-projects/noir-protocol-circuits/crates/rollup-lib/src/tx_base/tests/public_tx_base/end_blob_sponge_tests.nr

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use types::{
99
},
1010
tests::{FixtureBuilder, utils::pad_end},
1111
traits::{Hash, ToField},
12-
utils::arrays::array_merge,
12+
utils::arrays::subarray,
1313
};
1414

1515
#[test]
@@ -100,10 +100,15 @@ unconstrained fn full_tx_effects() {
100100
}
101101
offset += MAX_TOTAL_PUBLIC_DATA_UPDATE_REQUESTS_PER_TX * 2;
102102

103-
let private_logs = array_merge(
103+
// Concatenate the 2 non-revertible and (MAX_PRIVATE_LOGS_PER_TX - 2) revertible private logs.
104+
let private_logs = subarray::<_, _, 2>(
104105
builder.private_tail.non_revertible_accumulated_data.private_logs,
105-
builder.private_tail.revertible_accumulated_data.private_logs,
106-
);
106+
0,
107+
)
108+
.concat(subarray::<_, _, MAX_PRIVATE_LOGS_PER_TX - 2>(
109+
builder.private_tail.revertible_accumulated_data.private_logs,
110+
0,
111+
));
107112
for i in 0..MAX_PRIVATE_LOGS_PER_TX {
108113
expected_blob_fields[offset] = PRIVATE_LOG_SIZE_IN_FIELDS as Field;
109114
offset += 1;

noir-projects/noir-protocol-circuits/crates/rollup-lib/src/tx_base/tests/public_tx_base/validate_private_tail_to_public_tests.nr

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use super::TestBuilder;
22
use types::{constants::AVM_MAX_PROCESSABLE_L2_GAS, tests::fixture_builder::FixtureBuilder};
33

4-
#[test(should_fail_with = "Membership check failed: previous block hash not found in archive tree")]
4+
#[test(should_fail_with = "Membership check failed: anchor block header hash not found in archive tree")]
55
unconstrained fn anchor_block_header_not_in_archive() {
66
let mut builder = TestBuilder::new();
77

0 commit comments

Comments
 (0)