From 44fb21fc62c722d1ca9a357a217cad73283d2f3d Mon Sep 17 00:00:00 2001 From: Maxim Vezenov Date: Wed, 27 May 2026 14:35:28 -0400 Subject: [PATCH 1/3] initial integration into private msg delivery flow and tests for constrained tags --- .../aztec-nr/aztec/src/event/event_message.nr | 1 + .../messages/delivery/constrained_delivery.nr | 57 +++++++++++----- .../aztec/src/messages/delivery/mod.nr | 62 +++++++++++++---- .../aztec/src/messages/message_delivery.nr | 1 + .../aztec-nr/aztec/src/note/note_message.nr | 1 + .../aztec-nr/uint-note/src/uint_note.nr | 1 + .../app/nft_contract/src/types/nft_note.nr | 1 + .../src/main.nr | 34 +++++++++- .../src/test.nr | 67 ++++++++++++++++--- .../test/custom_message_contract/src/main.nr | 2 + 10 files changed, 189 insertions(+), 38 deletions(-) diff --git a/noir-projects/aztec-nr/aztec/src/event/event_message.nr b/noir-projects/aztec-nr/aztec/src/event/event_message.nr index 4efa4a48f835..f368302801aa 100644 --- a/noir-projects/aztec-nr/aztec/src/event/event_message.nr +++ b/noir-projects/aztec-nr/aztec/src/event/event_message.nr @@ -61,6 +61,7 @@ where Option::none(), recipient, delivery_mode, + Option::none(), ); } } diff --git a/noir-projects/aztec-nr/aztec/src/messages/delivery/constrained_delivery.nr b/noir-projects/aztec-nr/aztec/src/messages/delivery/constrained_delivery.nr index 55a353899fd1..280ee6f34067 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/delivery/constrained_delivery.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/delivery/constrained_delivery.nr @@ -7,8 +7,8 @@ use crate::oracle::{call_utility_function::call_utility_function, notes::get_nex use crate::protocol::{ abis::function_selector::FunctionSelector, address::AztecAddress, - constants::DOM_SEP__CONSTRAINED_MSG_NULLIFIER, - hash::poseidon2_hash_with_separator, + constants::{DOM_SEP__CONSTRAINED_MSG_LOG_TAG, DOM_SEP__CONSTRAINED_MSG_NULLIFIER}, + hash::{compute_log_tag, poseidon2_hash, poseidon2_hash_with_separator}, traits::{Deserialize, ToField}, }; @@ -32,7 +32,8 @@ use crate::protocol::{ /// note. /// - `index > 0`: asserts the prior nullifier /// `poseidon2_hash_with_separator([sender, recipient, secret, index - 1], DOM_SEP__CONSTRAINED_MSG_NULLIFIER)` -/// exists via [`compute_nullifier_existence_request`](crate::nullifier::utils::compute_nullifier_existence_request). +/// exists via +/// [`compute_nullifier_existence_request`](crate::nullifier::utils::compute_nullifier_existence_request). /// The chain transitively constrains back to the index-0 `validate_handshake`. The caller is expected /// to emit the matching nullifier alongside each constrained send so subsequent calls can prove the /// chain. @@ -47,9 +48,7 @@ pub fn calculate_secret_and_index( // Safety: the returned `Option` is untrusted and is constrained below before being returned to the // caller, either by performing a new handshake or by constraining the prior handshake's existence. let maybe_secret: Option = unsafe { - let selector = comptime { - FunctionSelector::from_signature("get_app_siloed_secret((Field),(Field),(Field))") - }; + let selector = comptime { FunctionSelector::from_signature("get_app_siloed_secret((Field),(Field),(Field))") }; let returns = call_utility_function( registry, selector, @@ -86,23 +85,51 @@ pub fn calculate_secret_and_index( let index = unsafe { get_next_constrained_tagging_index(secret) }; if index == 0 { - let selector = comptime { - FunctionSelector::from_signature("validate_handshake((Field),(Field),Field)") - }; - let _ = context.call_private_function( - registry, - selector, - [sender.to_field(), recipient.to_field(), secret], - ); + let selector = comptime { FunctionSelector::from_signature("validate_handshake((Field),(Field),Field)") }; + let _ = + context.call_private_function(registry, selector, [sender.to_field(), recipient.to_field(), secret]); } else { let prev_nullifier = poseidon2_hash_with_separator( [sender.to_field(), recipient.to_field(), secret, (index - 1) as Field], DOM_SEP__CONSTRAINED_MSG_NULLIFIER, ); - // TODO(F-670): exercise the index > 0 path end-to-end once send_constrained_msg emits nullifiers. context.assert_nullifier_exists(compute_nullifier_existence_request(prev_nullifier, caller)); } (secret, index) } } + +/// Computes the constrained-delivery discovery log tag for an `(app_siloed_secret, index)` pair. +/// +/// Collapses the secret and index into a single raw tag and domain-separates it with +/// `DOM_SEP__CONSTRAINED_MSG_LOG_TAG`. This mirrors the recipient-side derivation in the PXE (`siloedTagFor`): the +/// recipient recomputes the same tag to discover the message, and the protocol silos the emitted log's first field by +/// the emitting contract address, completing the match. +pub fn compute_constrained_log_tag(app_siloed_secret: Field, index: u32) -> Field { + compute_log_tag(poseidon2_hash([app_siloed_secret, index as Field]), DOM_SEP__CONSTRAINED_MSG_LOG_TAG) +} + +/// Emits the per-send chain nullifier for a constrained message and returns its discovery log tag. +/// +/// Used by [`crate::messages::delivery::do_private_message_delivery`] on the constrained-delivery path. It wraps +/// [`calculate_secret_and_index`] to resolve the `(app_siloed_secret, index)` pair, then: +/// 1. Emits the chain nullifier. A subsequent send at `index + 1` proves this nullifier exists, transitively constraining +/// the chain back to the `index == 0` handshake validation in [`calculate_secret_and_index`]. +/// 2. Returns the log tag. The recipient recomputes the same tag from the handshake secret to discover the message. +pub(crate) fn emit_nullifier_and_compute_constrained_tag( + context: &mut PrivateContext, + registry: AztecAddress, + sender: AztecAddress, + recipient: AztecAddress, +) -> Field { + let (secret, index) = calculate_secret_and_index(context, registry, sender, recipient); + + let nullifier = poseidon2_hash_with_separator( + [sender.to_field(), recipient.to_field(), secret, index as Field], + DOM_SEP__CONSTRAINED_MSG_NULLIFIER, + ); + context.push_nullifier_unsafe(nullifier); + + compute_constrained_log_tag(secret, index) +} diff --git a/noir-projects/aztec-nr/aztec/src/messages/delivery/mod.nr b/noir-projects/aztec-nr/aztec/src/messages/delivery/mod.nr index 9e4ee8816b61..77cb6040b51d 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/delivery/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/delivery/mod.nr @@ -46,6 +46,18 @@ impl MessageDelivery { } } +/// Handshake inputs that opt a message delivery into constrained tagging. +/// +/// Pass `Some` to [`do_private_message_delivery`] alongside [`MessageDelivery::onchain_constrained`] to derive +/// the log tag from a handshake-registry secret and emit the per-send chain nullifier (see +/// [`crate::messages::delivery::constrained_delivery`]). Pass `None` for the wallet-driven unconstrained tag. +pub struct ConstrainedDelivery { + /// Handshake registry to resolve the app-siloed shared secret from. + pub registry: AztecAddress, + /// Account the message is sent on behalf of, bound into both the secret/index resolution and the chain nullifier. + pub sender: AztecAddress, +} + /// Performs private delivery of a message to `recipient` according to `delivery_mode`. /// /// The message is encoded into plaintext and then encrypted for `recipient`. This function takes a _function_ that @@ -53,12 +65,17 @@ impl MessageDelivery { /// message in scenarios where the plaintext will be encrypted with unconstrained encryption. /// /// `maybe_note_hash_counter` is only relevant for on-chain delivery modes (i.e. via protocol logs): if a newly created -/// note hash's side effect counter is passed, then the log will be squashed alongside the note should its nullifier be -/// emitted in the current transaction. This is typically only used for note messages: since the note will not actually -/// be created, there is no point in delivering the message. +/// note hash's side effect counter is passed and constrained tagging is not requested, then the log will be squashed +/// alongside the note should its nullifier be emitted in the current transaction. Constrained-tagged logs are not tied +/// to note squashing because recipient discovery scans those tags sequentially. /// /// `delivery_mode` must be a [`MessageDelivery`] value. /// +/// `maybe_constrained_delivery` opts into constrained tagging: when `Some` (only valid with +/// [`MessageDelivery::onchain_constrained`]) the log tag is derived from a handshake-registry secret and a chain +/// nullifier is emitted. When `None` the wallet-driven unconstrained tag is used. Its some-ness must be a +/// compile-time constant. +/// /// ## Privacy /// /// 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( maybe_note_hash_counter: Option, recipient: AztecAddress, delivery_mode: MessageDelivery, + maybe_constrained_delivery: Option, ) { // This function relies on `delivery_mode` being a constant in order to reduce circuit constraints when // unconstrained usage is requested. If `delivery_mode` were a runtime value the compiler would be unable to // perform dead-code elimination. assert_constant(delivery_mode); - // The following maps out the 3 dimensions across which we configure message delivery. + // The following maps out the dimensions across which we configure message delivery. let constrained_encryption = delivery_mode == MessageDelivery::onchain_constrained(); let deliver_as_offchain_message = delivery_mode == MessageDelivery::offchain(); - // TODO(#14565): Add constrained tagging - let _constrained_tagging = delivery_mode == MessageDelivery::onchain_constrained(); + + // Constrained tagging is opt-in via `maybe_constrained_delivery`. Its some-ness must be a compile-time constant. + assert_constant(maybe_constrained_delivery.is_some()); + let constrained_tagging = maybe_constrained_delivery.is_some(); + if constrained_tagging { + // Constrained tagging requires the constrained encryption that `ONCHAIN_CONSTRAINED` selects, otherwise the + // sender could pair a correct tag with forge-able (unconstrained) ciphertext. + assert(constrained_encryption, "constrained tagging requires ONCHAIN_CONSTRAINED delivery"); + } let contract_address = context.this_address(); @@ -91,11 +116,21 @@ pub fn do_private_message_delivery( if deliver_as_offchain_message { deliver_offchain_message(ciphertext, recipient); } else { - // TODO(#14565): constrained tagging is not yet implemented. Both modes currently use the unconstrained - // domain separator because the discovery tag always comes from an oracle. Once constrained tagging lands, - // this should branch on `constrained_tagging` to select the appropriate separator. - let discovery_tag = compute_discovery_tag(recipient); - let log_tag = compute_log_tag(discovery_tag, DOM_SEP__UNCONSTRAINED_MSG_LOG_TAG); + let log_tag = if constrained_tagging { + let delivery_info = maybe_constrained_delivery.unwrap_unchecked(); + constrained_delivery::emit_nullifier_and_compute_constrained_tag( + context, + delivery_info.registry, + delivery_info.sender, + recipient, + ) + } else { + // TODO(#14565): Thread constrained tagging through high-level note/event delivery APIs so that + // `ONCHAIN_CONSTRAINED` callers supply handshake info instead of taking this fallback. + // `ONCHAIN_UNCONSTRAINED` deliveries are expected to derive the wallet-driven tag here. + let discovery_tag = compute_discovery_tag(recipient); + compute_log_tag(discovery_tag, DOM_SEP__UNCONSTRAINED_MSG_LOG_TAG) + }; // We forbid this value not being constant to avoid predicating the context calls below, which might result in // the context's arrays having unknown compile time write indices and hence dramatically increasing constraints @@ -103,8 +138,11 @@ pub fn do_private_message_delivery( // we're emitting a note or non-note message. assert_constant(maybe_note_hash_counter.is_some()); + // TODO(F-664): Add e2e coverage for constrained note delivery through kernel squashing and recipient sync. + let squashable_note_log = maybe_note_hash_counter.is_some() & !constrained_tagging; + let log = BoundedVec::from_array(ciphertext); - if maybe_note_hash_counter.is_some() { + if squashable_note_log { // We associate the log with the note's side effect counter, so that if the note ends up being squashed in // the current transaction, the log will be removed as well. context.emit_raw_note_log_unsafe(log_tag, log, maybe_note_hash_counter.unwrap()); diff --git a/noir-projects/aztec-nr/aztec/src/messages/message_delivery.nr b/noir-projects/aztec-nr/aztec/src/messages/message_delivery.nr index 6d884d5f06d7..a6a1c31332d2 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/message_delivery.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/message_delivery.nr @@ -1,6 +1,7 @@ //! Compatibility wrapper for the legacy `messages::message_delivery` path. pub use crate::messages::delivery::{ + ConstrainedDelivery, MessageDelivery, do_private_message_delivery, }; diff --git a/noir-projects/aztec-nr/aztec/src/note/note_message.nr b/noir-projects/aztec-nr/aztec/src/note/note_message.nr index d660648225e3..9435473b81e7 100644 --- a/noir-projects/aztec-nr/aztec/src/note/note_message.nr +++ b/noir-projects/aztec-nr/aztec/src/note/note_message.nr @@ -84,6 +84,7 @@ where Option::some(self.new_note.note_hash_counter), recipient, delivery_mode, + Option::none(), ); } diff --git a/noir-projects/aztec-nr/uint-note/src/uint_note.nr b/noir-projects/aztec-nr/uint-note/src/uint_note.nr index c37e63d889ae..bcd6d86d1ae9 100644 --- a/noir-projects/aztec-nr/uint-note/src/uint_note.nr +++ b/noir-projects/aztec-nr/uint-note/src/uint_note.nr @@ -125,6 +125,7 @@ impl UintNote { Option::none(), recipient, MessageDelivery::onchain_unconstrained(), + Option::none(), ); let partial_note = PartialUintNote { commitment }; diff --git a/noir-projects/noir-contracts/contracts/app/nft_contract/src/types/nft_note.nr b/noir-projects/noir-contracts/contracts/app/nft_contract/src/types/nft_note.nr index 095d3a673a70..1cfb0721a550 100644 --- a/noir-projects/noir-contracts/contracts/app/nft_contract/src/types/nft_note.nr +++ b/noir-projects/noir-contracts/contracts/app/nft_contract/src/types/nft_note.nr @@ -121,6 +121,7 @@ impl NFTNote { Option::none(), recipient, MessageDelivery::onchain_unconstrained(), + Option::none(), ); let partial_note = PartialNFTNote { commitment }; diff --git a/noir-projects/noir-contracts/contracts/test/constrained_delivery_test_contract/src/main.nr b/noir-projects/noir-contracts/contracts/test/constrained_delivery_test_contract/src/main.nr index 3fae64f1cf3a..3d01e8a103ba 100644 --- a/noir-projects/noir-contracts/contracts/test/constrained_delivery_test_contract/src/main.nr +++ b/noir-projects/noir-contracts/contracts/test/constrained_delivery_test_contract/src/main.nr @@ -1,4 +1,4 @@ -//! Thin wrapper around the `calculate_secret_and_index` helper for TXE tests. +//! Thin wrappers around the constrained-delivery helpers for TXE tests. use aztec::macros::aztec; mod test; @@ -7,7 +7,12 @@ mod test; pub contract ConstrainedDeliveryTest { use aztec::{ macros::functions::external, - messages::delivery::constrained_delivery::calculate_secret_and_index, + messages::delivery::{ + constrained_delivery::{calculate_secret_and_index, compute_constrained_log_tag}, + ConstrainedDelivery, + do_private_message_delivery, + MessageDelivery, + }, oracle::notes::get_next_constrained_tagging_index, protocol::address::AztecAddress, }; @@ -34,4 +39,29 @@ pub contract ConstrainedDeliveryTest { let second_index = unsafe { get_next_constrained_tagging_index(secret) }; (secret, first_index, second_index) } + + /// Delivers a constrained message to `recipient`: resolves `(secret, index)`, emits the chain nullifier, and + /// emits the encrypted log under the constrained tag. Routes through `do_private_message_delivery` with + /// `MessageDelivery::onchain_constrained()` and the opt-in constrained-delivery info. + #[external("private")] + fn emit(registry: AztecAddress, sender: AztecAddress, recipient: AztecAddress) { + let payload: [Field; 3] = [1, 2, 3]; + do_private_message_delivery( + self.context, + || payload, + Option::none(), + recipient, + MessageDelivery::onchain_constrained(), + Option::some(ConstrainedDelivery { registry, sender }), + ); + } + + /// Test-only: emits a log under the constrained tag for `(secret, index)` WITHOUT the chain nullifier. This + /// advances the PXE's per-secret index (the recipient discovers the log by tag) while deliberately leaving the + /// nullifier chain broken, which drives the negative `index > 0` test. + #[external("private")] + fn emit_constrained_log_without_nullifier(secret: Field, index: u32) { + let log_tag = compute_constrained_log_tag(secret, index); + self.context.emit_private_log_unsafe(log_tag, BoundedVec::from_array([secret])); + } } diff --git a/noir-projects/noir-contracts/contracts/test/constrained_delivery_test_contract/src/test.nr b/noir-projects/noir-contracts/contracts/test/constrained_delivery_test_contract/src/test.nr index 7b93a7e5d289..d9e0ce1e1f3a 100644 --- a/noir-projects/noir-contracts/contracts/test/constrained_delivery_test_contract/src/test.nr +++ b/noir-projects/noir-contracts/contracts/test/constrained_delivery_test_contract/src/test.nr @@ -1,13 +1,9 @@ -//! Tests for `calculate_secret_and_index`. +//! Tests for the constrained-delivery sender helpers. //! -//! TODO(F-670): Coverage here is limited to the bootstrap branch and the `Some(secret)` branch with `index == 0` (which -//! constrains via `HandshakeRegistry::validate_handshake`). The `index > 0` branch of the helper is reachable -//! only after the per-secret index has been advanced in the PXE, which requires a tx to land a private log -//! tagged with `compute_log_tag(poseidon2_hash([secret, index]), DOM_SEP__CONSTRAINED_MSG_LOG_TAG)` AND emit -//! the chained constrained-message nullifier. Both are produced by the forthcoming `emit_constrained_message` -//! helper, so positive and negative `index > 0` tests live with that helper. Add -//! `advances_index_above_zero_when_prior_message_was_emitted` and -//! `fails_at_index_above_zero_without_prior_nullifier` alongside `emit_constrained_message`. +//! `calculate_and_return` exercises `calculate_secret_and_index` directly; `emit` exercises the full delivery path +//! (`do_private_message_delivery` with `ONCHAIN_CONSTRAINED`), which emits the chain nullifier and a +//! constrained-tagged log. The `index > 0` branch of `calculate_secret_and_index` is reachable only once a +//! constrained log has advanced the PXE's per-secret index, so the two `index > 0` tests land such a log first. use crate::ConstrainedDeliveryTest; use aztec::{protocol::address::AztecAddress, test::helpers::test_environment::{CallPrivateOptions, TestEnvironment}}; @@ -138,3 +134,56 @@ unconstrained fn distinct_pairs_have_independent_indexes() { assert_eq(index_b, 0); assert(secret_a != secret_b, "different recipients should yield distinct siloed secrets"); } + +// After a constrained `emit` lands a tagged log and the chain nullifier in a block, the per-secret index advances in +// the PXE. The next resolution takes the `index > 0` branch, which proves the prior (now settled) nullifier exists +// via `compute_nullifier_existence_request`, and returns index 1. +#[test] +unconstrained fn advances_index_above_zero_when_prior_message_was_emitted() { + let (env, registry_address, test_address, sender, recipient) = setup(); + let test_contract = ConstrainedDeliveryTest::at(test_address); + + // tx1: bootstraps the handshake (index 0), emits the chain nullifier and the constrained log. + env.call_private_opts( + sender, + helper_options(registry_address), + test_contract.emit(registry_address, sender, recipient), + ); + + // tx2: the emitted log advanced the per-secret index, so resolution returns index 1 and proves the now-settled + // predecessor nullifier. + let (_secret, index) = env.call_private_opts( + sender, + helper_options(registry_address), + test_contract.calculate_and_return(registry_address, sender, recipient), + ); + + assert_eq(index, 1); +} + +// Without a prior chain nullifier the `index > 0` branch cannot prove its predecessor. We advance the per-secret +// index by landing a constrained-tagged log at index 0 without the nullifier, then a resolution at an index above 0 +// fails because a preceding nullifier is neither pending nor settled. +#[test(should_fail_with = "reading an unknown nullifier")] +unconstrained fn fails_at_index_above_zero_without_prior_nullifier() { + let (env, registry_address, test_address, sender, recipient) = setup(); + let test_contract = ConstrainedDeliveryTest::at(test_address); + let registry = HandshakeRegistry::at(registry_address); + + // Establish a handshake so resolution takes the `Some(secret)` branch. This emits no constrained nullifier. + let _ = env.call_private(sender, registry.non_interactive_handshake(sender, recipient)); + let secret = env.execute_utility(registry.get_app_siloed_secret(sender, recipient, test_address)).expect( + f"handshake should be siloed for the test contract", + ); + + // Advance the per-secret index above 0 by landing a constrained-tagged log at index 0, deliberately skipping the + // chain nullifier. + env.call_private(sender, test_contract.emit_constrained_log_without_nullifier(secret, 0)); + + // Resolution now takes the `index > 0` branch and fails: the predecessor nullifier was never emitted. + let _ = env.call_private_opts( + sender, + helper_options(registry_address), + test_contract.calculate_and_return(registry_address, sender, recipient), + ); +} diff --git a/noir-projects/noir-contracts/contracts/test/custom_message_contract/src/main.nr b/noir-projects/noir-contracts/contracts/test/custom_message_contract/src/main.nr index e0d4ffed8682..8bc08ed7df6a 100644 --- a/noir-projects/noir-contracts/contracts/test/custom_message_contract/src/main.nr +++ b/noir-projects/noir-contracts/contracts/test/custom_message_contract/src/main.nr @@ -163,6 +163,7 @@ contract CustomMessage { Option::none(), recipient, MessageDelivery::onchain_unconstrained(), + Option::none(), ); // Part 1: randomness (message_id) and remaining event fields @@ -177,6 +178,7 @@ contract CustomMessage { Option::none(), recipient, MessageDelivery::onchain_unconstrained(), + Option::none(), ); } } From 2382f29f09f99e28d001fb71f19cbe67cfe5e01d Mon Sep 17 00:00:00 2001 From: Maxim Vezenov Date: Thu, 28 May 2026 17:11:49 -0400 Subject: [PATCH 2/3] switch to regular message delivery API and new txe tests for events --- .../aztec-nr/aztec/src/event/event_message.nr | 23 +++++++- .../aztec/src/messages/delivery/mod.nr | 6 +- .../aztec-nr/aztec/src/note/note_message.nr | 55 ++++++++++++++++++- .../Nargo.toml | 2 + .../src/main.nr | 50 +++++++++++------ .../src/test.nr | 54 +++++++++++++++--- 6 files changed, 160 insertions(+), 30 deletions(-) diff --git a/noir-projects/aztec-nr/aztec/src/event/event_message.nr b/noir-projects/aztec-nr/aztec/src/event/event_message.nr index f368302801aa..d4fbf94f3ab6 100644 --- a/noir-projects/aztec-nr/aztec/src/event/event_message.nr +++ b/noir-projects/aztec-nr/aztec/src/event/event_message.nr @@ -3,7 +3,7 @@ use crate::{ event::{event_emission::NewEvent, event_interface::EventInterface}, messages::{ logs::event::encode_private_event_message, - message_delivery::{do_private_message_delivery, MessageDelivery}, + message_delivery::{ConstrainedDelivery, do_private_message_delivery, MessageDelivery}, }, }; use crate::protocol::{address::AztecAddress, traits::Serialize}; @@ -64,4 +64,25 @@ where Option::none(), ); } + + /// Delivers the event message to a `recipient` using constrained onchain tagging. + /// + /// `registry` is the handshake registry used to resolve the shared secret and `sender` is the account the + /// message is sent on behalf of. + pub fn deliver_to_constrained(self, registry: AztecAddress, sender: AztecAddress, recipient: AztecAddress) { + // Technical note: we're about to call a closure that needs access to `new_event`, but we can't pass `self` to + // it because the closure might execute in unconstrained mode, and since `self` contains a mutable reference to + // `context` this would cause for a mutable reference to cross the constrained-unconstrained barrier, which is + // not allowed. As a workaround, we create a variable without the context and capture that instead. + let new_event = self.new_event; + + do_private_message_delivery( + self.context, + || encode_private_event_message(new_event.event, new_event.randomness), + Option::none(), + recipient, + MessageDelivery::onchain_constrained(), + Option::some(ConstrainedDelivery { registry, sender }), + ); + } } diff --git a/noir-projects/aztec-nr/aztec/src/messages/delivery/mod.nr b/noir-projects/aztec-nr/aztec/src/messages/delivery/mod.nr index 77cb6040b51d..18f4c4eebf5a 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/delivery/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/delivery/mod.nr @@ -125,9 +125,9 @@ pub fn do_private_message_delivery( recipient, ) } else { - // TODO(#14565): Thread constrained tagging through high-level note/event delivery APIs so that - // `ONCHAIN_CONSTRAINED` callers supply handshake info instead of taking this fallback. - // `ONCHAIN_UNCONSTRAINED` deliveries are expected to derive the wallet-driven tag here. + // High-level constrained delivery APIs supply handshake info instead of taking this fallback. + // `ONCHAIN_UNCONSTRAINED` deliveries and legacy `ONCHAIN_CONSTRAINED` calls derive the wallet-driven tag + // here. let discovery_tag = compute_discovery_tag(recipient); compute_log_tag(discovery_tag, DOM_SEP__UNCONSTRAINED_MSG_LOG_TAG) }; diff --git a/noir-projects/aztec-nr/aztec/src/note/note_message.nr b/noir-projects/aztec-nr/aztec/src/note/note_message.nr index 9435473b81e7..3afdf81b4549 100644 --- a/noir-projects/aztec-nr/aztec/src/note/note_message.nr +++ b/noir-projects/aztec-nr/aztec/src/note/note_message.nr @@ -2,7 +2,7 @@ use crate::{ context::PrivateContext, messages::{ logs::note::encode_private_note_message, - message_delivery::{do_private_message_delivery, MessageDelivery}, + message_delivery::{ConstrainedDelivery, do_private_message_delivery, MessageDelivery}, }, note::{lifecycle::NewNote, note_interface::NoteType}, }; @@ -52,6 +52,14 @@ where self.deliver_to(self.new_note.owner, delivery_mode); } + /// Delivers the note message to its owner using constrained on-chain tagging. + /// + /// `registry` is the handshake registry used to resolve the shared secret and `sender` is the account the + /// message is sent on behalf of. + pub fn deliver_constrained(self, registry: AztecAddress, sender: AztecAddress) { + self.deliver_to_constrained(registry, sender, self.new_note.owner); + } + /// Same as [`deliver`](NoteMessage::deliver), except the message gets delivered to an arbitrary `recipient` /// instead /// of the note owner. @@ -71,6 +79,7 @@ where // it because the closure might execute in unconstrained mode, and since `self` contains a mutable reference to // `context` this would cause for a mutable reference to cross the constrained-unconstrained barrier, which is // not allowed. As a workaround, we create a variable without the context and capture that instead. + let note_hash_counter = self.new_note.note_hash_counter; let new_note = self.new_note; do_private_message_delivery( @@ -81,13 +90,38 @@ where new_note.storage_slot, new_note.randomness, ), - Option::some(self.new_note.note_hash_counter), + Option::some(note_hash_counter), recipient, delivery_mode, Option::none(), ); } + /// Same as [`deliver_constrained`](NoteMessage::deliver_constrained), except the message gets delivered to an + /// arbitrary `recipient` instead of the note owner. + pub fn deliver_to_constrained(self, registry: AztecAddress, sender: AztecAddress, recipient: AztecAddress) { + // Technical note: we're about to call a closure that needs access to `new_note`, but we can't pass `self` to + // it because the closure might execute in unconstrained mode, and since `self` contains a mutable reference to + // `context` this would cause for a mutable reference to cross the constrained-unconstrained barrier, which is + // not allowed. As a workaround, we create a variable without the context and capture that instead. + let note_hash_counter = self.new_note.note_hash_counter; + let new_note = self.new_note; + + do_private_message_delivery( + self.context, + || encode_private_note_message( + new_note.note, + new_note.owner, + new_note.storage_slot, + new_note.randomness, + ), + Option::some(note_hash_counter), + recipient, + MessageDelivery::onchain_constrained(), + Option::some(ConstrainedDelivery { registry, sender }), + ); + } + /// Returns the note contained in the message. pub fn get_note(self) -> Note { self.new_note.note @@ -138,6 +172,14 @@ where } } + /// Same as [`NoteMessage::deliver_constrained`], except the message will only be delivered if it actually exists. + pub fn deliver_constrained(self, registry: AztecAddress, sender: AztecAddress) { + if self.maybe_new_note.is_some() { + let note_message = NoteMessage::new(self.maybe_new_note.unwrap_unchecked(), self.context); + note_message.deliver_constrained(registry, sender); + } + } + /// Same as [`NoteMessage::deliver_to`], except the message will only be delivered if it actually exists. /// /// Messages delivered using [`crate::messages::message_delivery::MessageDelivery::onchain_constrained()`] will @@ -148,6 +190,15 @@ where } } + /// Same as [`NoteMessage::deliver_to_constrained`], except the message will only be delivered if it actually + /// exists. + pub fn deliver_to_constrained(self, registry: AztecAddress, sender: AztecAddress, recipient: AztecAddress) { + if self.maybe_new_note.is_some() { + let note_message = NoteMessage::new(self.maybe_new_note.unwrap_unchecked(), self.context); + note_message.deliver_to_constrained(registry, sender, recipient); + } + } + /// Returns the note contained in the message. pub fn get_note(self) -> Option { self.maybe_new_note.map(|new_note| new_note.note) diff --git a/noir-projects/noir-contracts/contracts/test/constrained_delivery_test_contract/Nargo.toml b/noir-projects/noir-contracts/contracts/test/constrained_delivery_test_contract/Nargo.toml index adcb0e356bdf..e6d7c6223322 100644 --- a/noir-projects/noir-contracts/contracts/test/constrained_delivery_test_contract/Nargo.toml +++ b/noir-projects/noir-contracts/contracts/test/constrained_delivery_test_contract/Nargo.toml @@ -6,4 +6,6 @@ type = "contract" [dependencies] aztec = { path = "../../../../aztec-nr/aztec" } +balance_set = { path = "../../../../aztec-nr/balance-set" } +field_note = { path = "../../../../aztec-nr/field-note" } handshake_registry_contract = { path = "../../message_discovery/handshake_registry_contract" } diff --git a/noir-projects/noir-contracts/contracts/test/constrained_delivery_test_contract/src/main.nr b/noir-projects/noir-contracts/contracts/test/constrained_delivery_test_contract/src/main.nr index 3d01e8a103ba..99127a1373e8 100644 --- a/noir-projects/noir-contracts/contracts/test/constrained_delivery_test_contract/src/main.nr +++ b/noir-projects/noir-contracts/contracts/test/constrained_delivery_test_contract/src/main.nr @@ -6,16 +6,26 @@ mod test; #[aztec] pub contract ConstrainedDeliveryTest { use aztec::{ - macros::functions::external, - messages::delivery::{ - constrained_delivery::{calculate_secret_and_index, compute_constrained_log_tag}, - ConstrainedDelivery, - do_private_message_delivery, - MessageDelivery, - }, + event::event_emission::emit_event_in_private, + macros::{events::event, functions::external, storage::storage}, + messages::delivery::constrained_delivery::{calculate_secret_and_index, compute_constrained_log_tag}, oracle::notes::get_next_constrained_tagging_index, protocol::address::AztecAddress, + state_vars::{Owned, PrivateSet}, }; + use balance_set::BalanceSet; + use field_note::FieldNote; + + #[event] + struct ExampleEvent { + value: Field, + } + + #[storage] + struct Storage { + notes: Owned, Context>, + balances: Owned, Context>, + } /// Calls the helper and returns the resolved `(app_siloed_secret, index)` tuple. #[external("private")] @@ -40,19 +50,25 @@ pub contract ConstrainedDeliveryTest { (secret, first_index, second_index) } - /// Delivers a constrained message to `recipient`: resolves `(secret, index)`, emits the chain nullifier, and - /// emits the encrypted log under the constrained tag. Routes through `do_private_message_delivery` with - /// `MessageDelivery::onchain_constrained()` and the opt-in constrained-delivery info. + /// Delivers a constrained note message to `recipient` through the high-level note API. #[external("private")] fn emit(registry: AztecAddress, sender: AztecAddress, recipient: AztecAddress) { - let payload: [Field; 3] = [1, 2, 3]; - do_private_message_delivery( - self.context, - || payload, - Option::none(), + self.storage.notes.at(recipient).insert(FieldNote { value: 1 }).deliver_constrained(registry, sender); + } + + /// Delivers a constrained maybe-note message through the high-level maybe-note API. + #[external("private")] + fn emit_maybe_note(registry: AztecAddress, sender: AztecAddress, recipient: AztecAddress) { + self.storage.balances.at(recipient).add(1).deliver_to_constrained(registry, sender, recipient); + } + + /// Delivers a constrained event message through the high-level event API. + #[external("private")] + fn emit_event(registry: AztecAddress, sender: AztecAddress, recipient: AztecAddress) { + emit_event_in_private(self.context, ExampleEvent { value: 1 }).deliver_to_constrained( + registry, + sender, recipient, - MessageDelivery::onchain_constrained(), - Option::some(ConstrainedDelivery { registry, sender }), ); } diff --git a/noir-projects/noir-contracts/contracts/test/constrained_delivery_test_contract/src/test.nr b/noir-projects/noir-contracts/contracts/test/constrained_delivery_test_contract/src/test.nr index d9e0ce1e1f3a..e016d44dbe9c 100644 --- a/noir-projects/noir-contracts/contracts/test/constrained_delivery_test_contract/src/test.nr +++ b/noir-projects/noir-contracts/contracts/test/constrained_delivery_test_contract/src/test.nr @@ -1,9 +1,9 @@ //! Tests for the constrained-delivery sender helpers. //! -//! `calculate_and_return` exercises `calculate_secret_and_index` directly; `emit` exercises the full delivery path -//! (`do_private_message_delivery` with `ONCHAIN_CONSTRAINED`), which emits the chain nullifier and a -//! constrained-tagged log. The `index > 0` branch of `calculate_secret_and_index` is reachable only once a -//! constrained log has advanced the PXE's per-secret index, so the two `index > 0` tests land such a log first. +//! `calculate_and_return` exercises `calculate_secret_and_index` directly; `emit`, `emit_maybe_note`, and +//! `emit_event` exercise the high-level constrained delivery APIs, which emit the chain nullifier and a +//! constrained-tagged log. The `index > 0` branch of `calculate_secret_and_index` is reachable only once a constrained +//! log has advanced the PXE's per-secret index, so the `index > 0` tests land such a log first. use crate::ConstrainedDeliveryTest; use aztec::{protocol::address::AztecAddress, test::helpers::test_environment::{CallPrivateOptions, TestEnvironment}}; @@ -135,9 +135,9 @@ unconstrained fn distinct_pairs_have_independent_indexes() { assert(secret_a != secret_b, "different recipients should yield distinct siloed secrets"); } -// After a constrained `emit` lands a tagged log and the chain nullifier in a block, the per-secret index advances in -// the PXE. The next resolution takes the `index > 0` branch, which proves the prior (now settled) nullifier exists -// via `compute_nullifier_existence_request`, and returns index 1. +// After high-level constrained note delivery lands a tagged log and the chain nullifier in a block, the per-secret +// index advances in the PXE. The next resolution takes the `index > 0` branch, which proves the prior (now settled) +// nullifier exists via `compute_nullifier_existence_request`, and returns index 1. #[test] unconstrained fn advances_index_above_zero_when_prior_message_was_emitted() { let (env, registry_address, test_address, sender, recipient) = setup(); @@ -161,6 +161,46 @@ unconstrained fn advances_index_above_zero_when_prior_message_was_emitted() { assert_eq(index, 1); } +#[test] +unconstrained fn maybe_note_delivery_advances_index_above_zero() { + let (env, registry_address, test_address, sender, recipient) = setup(); + let test_contract = ConstrainedDeliveryTest::at(test_address); + + env.call_private_opts( + sender, + helper_options(registry_address), + test_contract.emit_maybe_note(registry_address, sender, recipient), + ); + + let (_secret, index) = env.call_private_opts( + sender, + helper_options(registry_address), + test_contract.calculate_and_return(registry_address, sender, recipient), + ); + + assert_eq(index, 1); +} + +#[test] +unconstrained fn event_delivery_advances_index_above_zero() { + let (env, registry_address, test_address, sender, recipient) = setup(); + let test_contract = ConstrainedDeliveryTest::at(test_address); + + env.call_private_opts( + sender, + helper_options(registry_address), + test_contract.emit_event(registry_address, sender, recipient), + ); + + let (_secret, index) = env.call_private_opts( + sender, + helper_options(registry_address), + test_contract.calculate_and_return(registry_address, sender, recipient), + ); + + assert_eq(index, 1); +} + // Without a prior chain nullifier the `index > 0` branch cannot prove its predecessor. We advance the per-secret // index by landing a constrained-tagged log at index 0 without the nullifier, then a resolution at an index above 0 // fails because a preceding nullifier is neither pending nor settled. From 407f4c575c7063fab88f69bfe07801b700afd436 Mon Sep 17 00:00:00 2001 From: Maxim Vezenov Date: Thu, 28 May 2026 18:09:36 -0400 Subject: [PATCH 3/3] e2e test --- .../src/e2e_constrained_delivery.test.ts | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 yarn-project/end-to-end/src/e2e_constrained_delivery.test.ts diff --git a/yarn-project/end-to-end/src/e2e_constrained_delivery.test.ts b/yarn-project/end-to-end/src/e2e_constrained_delivery.test.ts new file mode 100644 index 000000000000..932a3f086928 --- /dev/null +++ b/yarn-project/end-to-end/src/e2e_constrained_delivery.test.ts @@ -0,0 +1,93 @@ +import type { AztecAddress } from '@aztec/aztec.js/addresses'; +import type { Wallet } from '@aztec/aztec.js/wallet'; +import { HandshakeRegistryContract } from '@aztec/noir-contracts.js/HandshakeRegistry'; +import { ConstrainedDeliveryTestContract } from '@aztec/noir-test-contracts.js/ConstrainedDeliveryTest'; +import type { UtilityCallAuthorizationRequest } from '@aztec/pxe/server'; + +import { jest } from '@jest/globals'; + +import { AUTOMINE_E2E_OPTS } from './fixtures/fixtures.js'; +import { setup } from './fixtures/utils.js'; + +const TIMEOUT = 300_000; + +describe('Constrained delivery', () => { + let handshakeRegistry: HandshakeRegistryContract | undefined; + let constrainedDeliveryTest: ConstrainedDeliveryTestContract | undefined; + let wallet: Wallet; + let sender: AztecAddress; + let recipient: AztecAddress; + let teardown: () => Promise = async () => {}; + jest.setTimeout(TIMEOUT); + + beforeAll(async () => { + ({ + teardown, + wallet, + accounts: [sender, recipient], + } = await setup(2, { + ...AUTOMINE_E2E_OPTS, + pxeCreationOptions: { + hooks: { + authorizeUtilityCall: (req: UtilityCallAuthorizationRequest) => + Promise.resolve({ + authorized: + handshakeRegistry !== undefined && + constrainedDeliveryTest !== undefined && + req.target.equals(handshakeRegistry.address) && + req.caller.equals(constrainedDeliveryTest.address) && + req.functionName === 'get_app_siloed_secret' && + req.callerContext === 'private', + }), + }, + }, + })); + + ({ contract: handshakeRegistry } = await HandshakeRegistryContract.deploy(wallet).send({ from: sender })); + ({ contract: constrainedDeliveryTest } = await ConstrainedDeliveryTestContract.deploy(wallet).send({ + from: sender, + })); + }); + + afterAll(() => teardown()); + + it('advances constrained note delivery indices across included transactions', async () => { + const registry = getHandshakeRegistry(); + const testContract = getConstrainedDeliveryTest(); + + await testContract.methods.emit(registry.address, sender, recipient).send({ from: sender }); + + const { result: firstResolution } = await testContract.methods + .calculate_and_return(registry.address, sender, recipient) + .simulate({ from: sender }); + expectResolvedIndex(firstResolution, 1n); + + await testContract.methods.emit(registry.address, sender, recipient).send({ from: sender }); + + const { result: secondResolution } = await testContract.methods + .calculate_and_return(registry.address, sender, recipient) + .simulate({ from: sender }); + expectResolvedIndex(secondResolution, 2n); + }); + + function getHandshakeRegistry() { + if (handshakeRegistry === undefined) { + throw new Error('HandshakeRegistry was not deployed'); + } + return handshakeRegistry; + } + + function getConstrainedDeliveryTest() { + if (constrainedDeliveryTest === undefined) { + throw new Error('ConstrainedDeliveryTest was not deployed'); + } + return constrainedDeliveryTest; + } +}); + +function expectResolvedIndex(resolution: unknown, expectedIndex: bigint) { + if (!Array.isArray(resolution)) { + throw new Error(`Expected constrained delivery resolution tuple, got ${String(resolution)}`); + } + expect(resolution[1]).toEqual(expectedIndex); +}