Skip to content

Commit 44fb21f

Browse files
committed
initial integration into private msg delivery flow and tests for constrained tags
1 parent 3e89f16 commit 44fb21f

10 files changed

Lines changed: 189 additions & 38 deletions

File tree

noir-projects/aztec-nr/aztec/src/event/event_message.nr

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ where
6161
Option::none(),
6262
recipient,
6363
delivery_mode,
64+
Option::none(),
6465
);
6566
}
6667
}

noir-projects/aztec-nr/aztec/src/messages/delivery/constrained_delivery.nr

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ use crate::oracle::{call_utility_function::call_utility_function, notes::get_nex
77
use crate::protocol::{
88
abis::function_selector::FunctionSelector,
99
address::AztecAddress,
10-
constants::DOM_SEP__CONSTRAINED_MSG_NULLIFIER,
11-
hash::poseidon2_hash_with_separator,
10+
constants::{DOM_SEP__CONSTRAINED_MSG_LOG_TAG, DOM_SEP__CONSTRAINED_MSG_NULLIFIER},
11+
hash::{compute_log_tag, poseidon2_hash, poseidon2_hash_with_separator},
1212
traits::{Deserialize, ToField},
1313
};
1414

@@ -32,7 +32,8 @@ use crate::protocol::{
3232
/// note.
3333
/// - `index > 0`: asserts the prior nullifier
3434
/// `poseidon2_hash_with_separator([sender, recipient, secret, index - 1], DOM_SEP__CONSTRAINED_MSG_NULLIFIER)`
35-
/// exists via [`compute_nullifier_existence_request`](crate::nullifier::utils::compute_nullifier_existence_request).
35+
/// exists via
36+
/// [`compute_nullifier_existence_request`](crate::nullifier::utils::compute_nullifier_existence_request).
3637
/// The chain transitively constrains back to the index-0 `validate_handshake`. The caller is expected
3738
/// to emit the matching nullifier alongside each constrained send so subsequent calls can prove the
3839
/// chain.
@@ -47,9 +48,7 @@ pub fn calculate_secret_and_index(
4748
// Safety: the returned `Option<Field>` is untrusted and is constrained below before being returned to the
4849
// caller, either by performing a new handshake or by constraining the prior handshake's existence.
4950
let maybe_secret: Option<Field> = unsafe {
50-
let selector = comptime {
51-
FunctionSelector::from_signature("get_app_siloed_secret((Field),(Field),(Field))")
52-
};
51+
let selector = comptime { FunctionSelector::from_signature("get_app_siloed_secret((Field),(Field),(Field))") };
5352
let returns = call_utility_function(
5453
registry,
5554
selector,
@@ -86,23 +85,51 @@ pub fn calculate_secret_and_index(
8685
let index = unsafe { get_next_constrained_tagging_index(secret) };
8786

8887
if index == 0 {
89-
let selector = comptime {
90-
FunctionSelector::from_signature("validate_handshake((Field),(Field),Field)")
91-
};
92-
let _ = context.call_private_function(
93-
registry,
94-
selector,
95-
[sender.to_field(), recipient.to_field(), secret],
96-
);
88+
let selector = comptime { FunctionSelector::from_signature("validate_handshake((Field),(Field),Field)") };
89+
let _ =
90+
context.call_private_function(registry, selector, [sender.to_field(), recipient.to_field(), secret]);
9791
} else {
9892
let prev_nullifier = poseidon2_hash_with_separator(
9993
[sender.to_field(), recipient.to_field(), secret, (index - 1) as Field],
10094
DOM_SEP__CONSTRAINED_MSG_NULLIFIER,
10195
);
102-
// TODO(F-670): exercise the index > 0 path end-to-end once send_constrained_msg emits nullifiers.
10396
context.assert_nullifier_exists(compute_nullifier_existence_request(prev_nullifier, caller));
10497
}
10598

10699
(secret, index)
107100
}
108101
}
102+
103+
/// Computes the constrained-delivery discovery log tag for an `(app_siloed_secret, index)` pair.
104+
///
105+
/// Collapses the secret and index into a single raw tag and domain-separates it with
106+
/// `DOM_SEP__CONSTRAINED_MSG_LOG_TAG`. This mirrors the recipient-side derivation in the PXE (`siloedTagFor`): the
107+
/// recipient recomputes the same tag to discover the message, and the protocol silos the emitted log's first field by
108+
/// the emitting contract address, completing the match.
109+
pub fn compute_constrained_log_tag(app_siloed_secret: Field, index: u32) -> Field {
110+
compute_log_tag(poseidon2_hash([app_siloed_secret, index as Field]), DOM_SEP__CONSTRAINED_MSG_LOG_TAG)
111+
}
112+
113+
/// Emits the per-send chain nullifier for a constrained message and returns its discovery log tag.
114+
///
115+
/// Used by [`crate::messages::delivery::do_private_message_delivery`] on the constrained-delivery path. It wraps
116+
/// [`calculate_secret_and_index`] to resolve the `(app_siloed_secret, index)` pair, then:
117+
/// 1. Emits the chain nullifier. A subsequent send at `index + 1` proves this nullifier exists, transitively constraining
118+
/// the chain back to the `index == 0` handshake validation in [`calculate_secret_and_index`].
119+
/// 2. Returns the log tag. The recipient recomputes the same tag from the handshake secret to discover the message.
120+
pub(crate) fn emit_nullifier_and_compute_constrained_tag(
121+
context: &mut PrivateContext,
122+
registry: AztecAddress,
123+
sender: AztecAddress,
124+
recipient: AztecAddress,
125+
) -> Field {
126+
let (secret, index) = calculate_secret_and_index(context, registry, sender, recipient);
127+
128+
let nullifier = poseidon2_hash_with_separator(
129+
[sender.to_field(), recipient.to_field(), secret, index as Field],
130+
DOM_SEP__CONSTRAINED_MSG_NULLIFIER,
131+
);
132+
context.push_nullifier_unsafe(nullifier);
133+
134+
compute_constrained_log_tag(secret, index)
135+
}

noir-projects/aztec-nr/aztec/src/messages/delivery/mod.nr

Lines changed: 50 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -46,19 +46,36 @@ impl MessageDelivery {
4646
}
4747
}
4848

49+
/// Handshake inputs that opt a message delivery into constrained tagging.
50+
///
51+
/// Pass `Some` to [`do_private_message_delivery`] alongside [`MessageDelivery::onchain_constrained`] to derive
52+
/// the log tag from a handshake-registry secret and emit the per-send chain nullifier (see
53+
/// [`crate::messages::delivery::constrained_delivery`]). Pass `None` for the wallet-driven unconstrained tag.
54+
pub struct ConstrainedDelivery {
55+
/// Handshake registry to resolve the app-siloed shared secret from.
56+
pub registry: AztecAddress,
57+
/// Account the message is sent on behalf of, bound into both the secret/index resolution and the chain nullifier.
58+
pub sender: AztecAddress,
59+
}
60+
4961
/// Performs private delivery of a message to `recipient` according to `delivery_mode`.
5062
///
5163
/// The message is encoded into plaintext and then encrypted for `recipient`. This function takes a _function_ that
5264
/// returns the plaintext instead of taking the plaintext directly in order to not waste constraints encoding the
5365
/// message in scenarios where the plaintext will be encrypted with unconstrained encryption.
5466
///
5567
/// `maybe_note_hash_counter` is only relevant for on-chain delivery modes (i.e. via protocol logs): if a newly created
56-
/// note hash's side effect counter is passed, then the log will be squashed alongside the note should its nullifier be
57-
/// emitted in the current transaction. This is typically only used for note messages: since the note will not actually
58-
/// be created, there is no point in delivering the message.
68+
/// note hash's side effect counter is passed and constrained tagging is not requested, then the log will be squashed
69+
/// alongside the note should its nullifier be emitted in the current transaction. Constrained-tagged logs are not tied
70+
/// to note squashing because recipient discovery scans those tags sequentially.
5971
///
6072
/// `delivery_mode` must be a [`MessageDelivery`] value.
6173
///
74+
/// `maybe_constrained_delivery` opts into constrained tagging: when `Some` (only valid with
75+
/// [`MessageDelivery::onchain_constrained`]) the log tag is derived from a handshake-registry secret and a chain
76+
/// nullifier is emitted. When `None` the wallet-driven unconstrained tag is used. Its some-ness must be a
77+
/// compile-time constant.
78+
///
6279
/// ## Privacy
6380
///
6481
/// The emitted log always has the same length regardless of `MESSAGE_PLAINTEXT_LEN`, because all message ciphertexts
@@ -69,17 +86,25 @@ pub fn do_private_message_delivery<Env, let MESSAGE_PLAINTEXT_LEN: u32>(
6986
maybe_note_hash_counter: Option<u32>,
7087
recipient: AztecAddress,
7188
delivery_mode: MessageDelivery,
89+
maybe_constrained_delivery: Option<ConstrainedDelivery>,
7290
) {
7391
// This function relies on `delivery_mode` being a constant in order to reduce circuit constraints when
7492
// unconstrained usage is requested. If `delivery_mode` were a runtime value the compiler would be unable to
7593
// perform dead-code elimination.
7694
assert_constant(delivery_mode);
7795

78-
// The following maps out the 3 dimensions across which we configure message delivery.
96+
// The following maps out the dimensions across which we configure message delivery.
7997
let constrained_encryption = delivery_mode == MessageDelivery::onchain_constrained();
8098
let deliver_as_offchain_message = delivery_mode == MessageDelivery::offchain();
81-
// TODO(#14565): Add constrained tagging
82-
let _constrained_tagging = delivery_mode == MessageDelivery::onchain_constrained();
99+
100+
// Constrained tagging is opt-in via `maybe_constrained_delivery`. Its some-ness must be a compile-time constant.
101+
assert_constant(maybe_constrained_delivery.is_some());
102+
let constrained_tagging = maybe_constrained_delivery.is_some();
103+
if constrained_tagging {
104+
// Constrained tagging requires the constrained encryption that `ONCHAIN_CONSTRAINED` selects, otherwise the
105+
// sender could pair a correct tag with forge-able (unconstrained) ciphertext.
106+
assert(constrained_encryption, "constrained tagging requires ONCHAIN_CONSTRAINED delivery");
107+
}
83108

84109
let contract_address = context.this_address();
85110

@@ -91,20 +116,33 @@ pub fn do_private_message_delivery<Env, let MESSAGE_PLAINTEXT_LEN: u32>(
91116
if deliver_as_offchain_message {
92117
deliver_offchain_message(ciphertext, recipient);
93118
} else {
94-
// TODO(#14565): constrained tagging is not yet implemented. Both modes currently use the unconstrained
95-
// domain separator because the discovery tag always comes from an oracle. Once constrained tagging lands,
96-
// this should branch on `constrained_tagging` to select the appropriate separator.
97-
let discovery_tag = compute_discovery_tag(recipient);
98-
let log_tag = compute_log_tag(discovery_tag, DOM_SEP__UNCONSTRAINED_MSG_LOG_TAG);
119+
let log_tag = if constrained_tagging {
120+
let delivery_info = maybe_constrained_delivery.unwrap_unchecked();
121+
constrained_delivery::emit_nullifier_and_compute_constrained_tag(
122+
context,
123+
delivery_info.registry,
124+
delivery_info.sender,
125+
recipient,
126+
)
127+
} else {
128+
// TODO(#14565): Thread constrained tagging through high-level note/event delivery APIs so that
129+
// `ONCHAIN_CONSTRAINED` callers supply handshake info instead of taking this fallback.
130+
// `ONCHAIN_UNCONSTRAINED` deliveries are expected to derive the wallet-driven tag here.
131+
let discovery_tag = compute_discovery_tag(recipient);
132+
compute_log_tag(discovery_tag, DOM_SEP__UNCONSTRAINED_MSG_LOG_TAG)
133+
};
99134

100135
// We forbid this value not being constant to avoid predicating the context calls below, which might result in
101136
// the context's arrays having unknown compile time write indices and hence dramatically increasing constraints
102137
// when accessing them. In practice this restriction is not a problem as we always know at compile time whether
103138
// we're emitting a note or non-note message.
104139
assert_constant(maybe_note_hash_counter.is_some());
105140

141+
// TODO(F-664): Add e2e coverage for constrained note delivery through kernel squashing and recipient sync.
142+
let squashable_note_log = maybe_note_hash_counter.is_some() & !constrained_tagging;
143+
106144
let log = BoundedVec::from_array(ciphertext);
107-
if maybe_note_hash_counter.is_some() {
145+
if squashable_note_log {
108146
// We associate the log with the note's side effect counter, so that if the note ends up being squashed in
109147
// the current transaction, the log will be removed as well.
110148
context.emit_raw_note_log_unsafe(log_tag, log, maybe_note_hash_counter.unwrap());
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
//! Compatibility wrapper for the legacy `messages::message_delivery` path.
22

33
pub use crate::messages::delivery::{
4+
ConstrainedDelivery,
45
MessageDelivery,
56
do_private_message_delivery,
67
};

noir-projects/aztec-nr/aztec/src/note/note_message.nr

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ where
8484
Option::some(self.new_note.note_hash_counter),
8585
recipient,
8686
delivery_mode,
87+
Option::none(),
8788
);
8889
}
8990

noir-projects/aztec-nr/uint-note/src/uint_note.nr

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ impl UintNote {
125125
Option::none(),
126126
recipient,
127127
MessageDelivery::onchain_unconstrained(),
128+
Option::none(),
128129
);
129130

130131
let partial_note = PartialUintNote { commitment };

noir-projects/noir-contracts/contracts/app/nft_contract/src/types/nft_note.nr

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ impl NFTNote {
121121
Option::none(),
122122
recipient,
123123
MessageDelivery::onchain_unconstrained(),
124+
Option::none(),
124125
);
125126

126127
let partial_note = PartialNFTNote { commitment };

noir-projects/noir-contracts/contracts/test/constrained_delivery_test_contract/src/main.nr

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
//! Thin wrapper around the `calculate_secret_and_index` helper for TXE tests.
1+
//! Thin wrappers around the constrained-delivery helpers for TXE tests.
22
use aztec::macros::aztec;
33

44
mod test;
@@ -7,7 +7,12 @@ mod test;
77
pub contract ConstrainedDeliveryTest {
88
use aztec::{
99
macros::functions::external,
10-
messages::delivery::constrained_delivery::calculate_secret_and_index,
10+
messages::delivery::{
11+
constrained_delivery::{calculate_secret_and_index, compute_constrained_log_tag},
12+
ConstrainedDelivery,
13+
do_private_message_delivery,
14+
MessageDelivery,
15+
},
1116
oracle::notes::get_next_constrained_tagging_index,
1217
protocol::address::AztecAddress,
1318
};
@@ -34,4 +39,29 @@ pub contract ConstrainedDeliveryTest {
3439
let second_index = unsafe { get_next_constrained_tagging_index(secret) };
3540
(secret, first_index, second_index)
3641
}
42+
43+
/// Delivers a constrained message to `recipient`: resolves `(secret, index)`, emits the chain nullifier, and
44+
/// emits the encrypted log under the constrained tag. Routes through `do_private_message_delivery` with
45+
/// `MessageDelivery::onchain_constrained()` and the opt-in constrained-delivery info.
46+
#[external("private")]
47+
fn emit(registry: AztecAddress, sender: AztecAddress, recipient: AztecAddress) {
48+
let payload: [Field; 3] = [1, 2, 3];
49+
do_private_message_delivery(
50+
self.context,
51+
|| payload,
52+
Option::none(),
53+
recipient,
54+
MessageDelivery::onchain_constrained(),
55+
Option::some(ConstrainedDelivery { registry, sender }),
56+
);
57+
}
58+
59+
/// Test-only: emits a log under the constrained tag for `(secret, index)` WITHOUT the chain nullifier. This
60+
/// advances the PXE's per-secret index (the recipient discovers the log by tag) while deliberately leaving the
61+
/// nullifier chain broken, which drives the negative `index > 0` test.
62+
#[external("private")]
63+
fn emit_constrained_log_without_nullifier(secret: Field, index: u32) {
64+
let log_tag = compute_constrained_log_tag(secret, index);
65+
self.context.emit_private_log_unsafe(log_tag, BoundedVec::from_array([secret]));
66+
}
3767
}

noir-projects/noir-contracts/contracts/test/constrained_delivery_test_contract/src/test.nr

Lines changed: 58 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,9 @@
1-
//! Tests for `calculate_secret_and_index`.
1+
//! Tests for the constrained-delivery sender helpers.
22
//!
3-
//! TODO(F-670): Coverage here is limited to the bootstrap branch and the `Some(secret)` branch with `index == 0` (which
4-
//! constrains via `HandshakeRegistry::validate_handshake`). The `index > 0` branch of the helper is reachable
5-
//! only after the per-secret index has been advanced in the PXE, which requires a tx to land a private log
6-
//! tagged with `compute_log_tag(poseidon2_hash([secret, index]), DOM_SEP__CONSTRAINED_MSG_LOG_TAG)` AND emit
7-
//! the chained constrained-message nullifier. Both are produced by the forthcoming `emit_constrained_message`
8-
//! helper, so positive and negative `index > 0` tests live with that helper. Add
9-
//! `advances_index_above_zero_when_prior_message_was_emitted` and
10-
//! `fails_at_index_above_zero_without_prior_nullifier` alongside `emit_constrained_message`.
3+
//! `calculate_and_return` exercises `calculate_secret_and_index` directly; `emit` exercises the full delivery path
4+
//! (`do_private_message_delivery` with `ONCHAIN_CONSTRAINED`), which emits the chain nullifier and a
5+
//! constrained-tagged log. The `index > 0` branch of `calculate_secret_and_index` is reachable only once a
6+
//! constrained log has advanced the PXE's per-secret index, so the two `index > 0` tests land such a log first.
117
use crate::ConstrainedDeliveryTest;
128

139
use aztec::{protocol::address::AztecAddress, test::helpers::test_environment::{CallPrivateOptions, TestEnvironment}};
@@ -138,3 +134,56 @@ unconstrained fn distinct_pairs_have_independent_indexes() {
138134
assert_eq(index_b, 0);
139135
assert(secret_a != secret_b, "different recipients should yield distinct siloed secrets");
140136
}
137+
138+
// After a constrained `emit` lands a tagged log and the chain nullifier in a block, the per-secret index advances in
139+
// the PXE. The next resolution takes the `index > 0` branch, which proves the prior (now settled) nullifier exists
140+
// via `compute_nullifier_existence_request`, and returns index 1.
141+
#[test]
142+
unconstrained fn advances_index_above_zero_when_prior_message_was_emitted() {
143+
let (env, registry_address, test_address, sender, recipient) = setup();
144+
let test_contract = ConstrainedDeliveryTest::at(test_address);
145+
146+
// tx1: bootstraps the handshake (index 0), emits the chain nullifier and the constrained log.
147+
env.call_private_opts(
148+
sender,
149+
helper_options(registry_address),
150+
test_contract.emit(registry_address, sender, recipient),
151+
);
152+
153+
// tx2: the emitted log advanced the per-secret index, so resolution returns index 1 and proves the now-settled
154+
// predecessor nullifier.
155+
let (_secret, index) = env.call_private_opts(
156+
sender,
157+
helper_options(registry_address),
158+
test_contract.calculate_and_return(registry_address, sender, recipient),
159+
);
160+
161+
assert_eq(index, 1);
162+
}
163+
164+
// Without a prior chain nullifier the `index > 0` branch cannot prove its predecessor. We advance the per-secret
165+
// index by landing a constrained-tagged log at index 0 without the nullifier, then a resolution at an index above 0
166+
// fails because a preceding nullifier is neither pending nor settled.
167+
#[test(should_fail_with = "reading an unknown nullifier")]
168+
unconstrained fn fails_at_index_above_zero_without_prior_nullifier() {
169+
let (env, registry_address, test_address, sender, recipient) = setup();
170+
let test_contract = ConstrainedDeliveryTest::at(test_address);
171+
let registry = HandshakeRegistry::at(registry_address);
172+
173+
// Establish a handshake so resolution takes the `Some(secret)` branch. This emits no constrained nullifier.
174+
let _ = env.call_private(sender, registry.non_interactive_handshake(sender, recipient));
175+
let secret = env.execute_utility(registry.get_app_siloed_secret(sender, recipient, test_address)).expect(
176+
f"handshake should be siloed for the test contract",
177+
);
178+
179+
// Advance the per-secret index above 0 by landing a constrained-tagged log at index 0, deliberately skipping the
180+
// chain nullifier.
181+
env.call_private(sender, test_contract.emit_constrained_log_without_nullifier(secret, 0));
182+
183+
// Resolution now takes the `index > 0` branch and fails: the predecessor nullifier was never emitted.
184+
let _ = env.call_private_opts(
185+
sender,
186+
helper_options(registry_address),
187+
test_contract.calculate_and_return(registry_address, sender, recipient),
188+
);
189+
}

noir-projects/noir-contracts/contracts/test/custom_message_contract/src/main.nr

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ contract CustomMessage {
163163
Option::none(),
164164
recipient,
165165
MessageDelivery::onchain_unconstrained(),
166+
Option::none(),
166167
);
167168

168169
// Part 1: randomness (message_id) and remaining event fields
@@ -177,6 +178,7 @@ contract CustomMessage {
177178
Option::none(),
178179
recipient,
179180
MessageDelivery::onchain_unconstrained(),
181+
Option::none(),
180182
);
181183
}
182184
}

0 commit comments

Comments
 (0)