Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
69 commits
Select commit Hold shift + click to select a range
c552943
feat(aztec-nr): add constrained delivery helper
vezenovm Jun 10, 2026
000162b
comments
vezenovm Jun 10, 2026
36681e6
fmt
vezenovm Jun 10, 2026
0271756
.
vezenovm Jun 10, 2026
98b556a
refactor(aztec-nr): rename calculate_secret_and_index to resolve_secr…
vezenovm Jun 10, 2026
5699c52
Merge branch 'merge-train/fairies-v5' into mv/f-669-constrained-deliv…
vezenovm Jun 10, 2026
389493b
Merge branch 'merge-train/fairies-v5' into mv/f-669-constrained-deliv…
vezenovm Jun 11, 2026
c334adc
some minor simplifications
vezenovm Jun 11, 2026
cd2fbea
shared unconstrained/constrained tag computaiton
vezenovm Jun 11, 2026
33a3b65
cleanup
vezenovm Jun 12, 2026
18a8df5
Merge branch 'merge-train/fairies-v5' into mv/f-669-constrained-deliv…
vezenovm Jun 12, 2026
b258ccd
chore: regenerate drifted contract snapshots
AztecBot Jun 12, 2026
153b84a
split into helpers
vezenovm Jun 12, 2026
a851c53
.
vezenovm Jun 12, 2026
170f8b9
Merge remote-tracking branch 'origin/mv/f-669-constrained-delivery-he…
vezenovm Jun 12, 2026
e371203
naming
vezenovm Jun 12, 2026
9d61ebb
.'
vezenovm Jun 12, 2026
df8704e
update tests
vezenovm Jun 12, 2026
09bae38
standard contract
vezenovm Jun 12, 2026
614e435
.'
vezenovm Jun 12, 2026
65ac26d
authorize get_app_siloed_secret and e2e test
vezenovm Jun 12, 2026
72009eb
remove old helper for public flow and lint
vezenovm Jun 12, 2026
dbf792b
.'
vezenovm Jun 12, 2026
6919a75
Merge branch 'merge-train/fairies-v5' into mv/f-669-constrained-deliv…
vezenovm Jun 15, 2026
58343a9
wallet pref todo
vezenovm Jun 15, 2026
dbc04f4
msg_sender default
vezenovm Jun 15, 2026
0eb6e78
Merge remote-tracking branch 'origin/mv/f-669-constrained-delivery-he…
vezenovm Jun 15, 2026
91b3840
fix(simulator): prevent circuit recorder from masking execution errors
vezenovm Jun 15, 2026
af18bc5
get_sender_for_tags
vezenovm Jun 16, 2026
fd84572
cleanup diff remove sender overrides, new tag module, comments cleanup
vezenovm Jun 16, 2026
48aa220
fix(simulator): guard recordCall against an absent recording
vezenovm Jun 16, 2026
bcf9c76
Merge branch 'mv/fix-circuit-recorder-finish-undefined' into mv/f-669…
vezenovm Jun 16, 2026
25b6540
comments
vezenovm Jun 16, 2026
2f8cf57
.
vezenovm Jun 16, 2026
446957f
Merge remote-tracking branch 'origin/merge-train/fairies-v5' into mv/…
vezenovm Jun 16, 2026
417482d
fix e2e test
vezenovm Jun 16, 2026
ba1be2d
cleamnup
vezenovm Jun 16, 2026
3d10cae
test pinning non-concurrent behavior of constrained delivery
vezenovm Jun 16, 2026
26aae03
e2e multiple constrained delivery sends in one tx
vezenovm Jun 16, 2026
0834425
pin additional batching tests to show e2e functionality
vezenovm Jun 16, 2026
00cc819
update comment
vezenovm Jun 16, 2026
bed55f4
fixed simulation stubs for accounts
vezenovm Jun 16, 2026
fbadea0
clean
vezenovm Jun 16, 2026
d7065a9
another test to use onchain_unconstrained
vezenovm Jun 16, 2026
a66cedd
update pending note hashes test
vezenovm Jun 16, 2026
54520c9
more tests and reorg
vezenovm Jun 16, 2026
4c13ccb
onchain_unconstrained for state_vars test
vezenovm Jun 16, 2026
7628eab
test(noir-contracts): switch StatefulTest note delivery to unconstrained
AztecBot Jun 16, 2026
fd07996
skip private state is zero w/o secret key test
vezenovm Jun 17, 2026
375800b
link to linear issue
vezenovm Jun 17, 2026
08c35b0
comments and remove redundant test
vezenovm Jun 17, 2026
269fb80
Merge branch 'merge-train/fairies-v5' into mv/f-669-constrained-deliv…
vezenovm Jun 17, 2026
99b0a9c
use default sender to simplify tests
vezenovm Jun 17, 2026
32cdf5d
fmt
vezenovm Jun 17, 2026
20ae9f6
.
vezenovm Jun 17, 2026
f6b9c71
Update noir-projects/noir-contracts/contracts/test/test_log_contract/…
vezenovm Jun 17, 2026
05ffc5a
comment updates
vezenovm Jun 17, 2026
45a9a9d
one more comment
vezenovm Jun 17, 2026
b6eb170
Update docs/docs-developers/docs/aztec-nr/framework-description/state…
vezenovm Jun 17, 2026
f73130b
pr review cleanup and comments
vezenovm Jun 17, 2026
41411a4
more snap and test removal
vezenovm Jun 17, 2026
73b3c40
constrained_delivery unit tests, re-org get_app_or.. to handshake mod…
vezenovm Jun 17, 2026
56d84b2
Merge remote-tracking branch 'origin/mv/f-669-constrained-delivery-he…
vezenovm Jun 17, 2026
833f58e
index >0 comment
vezenovm Jun 17, 2026
c98d885
fix unit test
vezenovm Jun 17, 2026
7d4e98e
refactor(aztec-nr): address constrained-delivery review comments (#24…
nchamo Jun 22, 2026
a22266d
Merge branch 'merge-train/fairies-v5' into mv/f-669-constrained-deliv…
vezenovm Jun 22, 2026
76c29c8
fix up calls to get_app_siloed_secret
vezenovm Jun 22, 2026
3d1f990
empty
vezenovm Jun 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ When working with private state variables, many operations return a `NoteMessage
#### Delivery Methods

Private notes need to be communicated to their recipients so they know the note exists and can use it. The [`NoteMessage`](pathname:///aztec-nr-api/#api_ref_version/noir_aztec/note/struct.NoteMessage) wrapper forces you to make an explicit choice about how this happens:
- [`MessageDelivery::onchain_constrained()`](pathname:///aztec-nr-api/#api_ref_version/noir_aztec/messages/delivery/global.MessageDelivery): Verified in the circuit (most secure, but highest cost) - Use when the sender cannot be trusted to deliver correctly (e.g., protocol fees, multisig config updates). **Warning:** Currently [not fully constrained](https://github.com/AztecProtocol/aztec-packages/issues/14565) - the log's tag is unconstrained.
- [`MessageDelivery::onchain_constrained()`](pathname:///aztec-nr-api/#api_ref_version/noir_aztec/messages/delivery/global.MessageDelivery): Verified in the circuit (most secure, but highest cost) - Use when the sender cannot be trusted to deliver correctly (e.g., protocol fees, multisig config updates).
- [`MessageDelivery::onchain_unconstrained()`](pathname:///aztec-nr-api/#api_ref_version/noir_aztec/messages/delivery/global.MessageDelivery): Message stored onchain but no guarantees on content - Use when the sender is incentivized to deliver correctly but may not have an offchain channel to the recipient.
- [`MessageDelivery::offchain()`](pathname:///aztec-nr-api/#api_ref_version/noir_aztec/messages/delivery/global.MessageDelivery): Lowest cost, no onchain data - Use when the sender and recipient can communicate and the sender is incentivized to deliver correctly.

Expand Down
2 changes: 1 addition & 1 deletion docs/examples/webapp-tutorial/contracts/src/main.nr
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ pub contract PodRacing {
.at(game_id)
.at(player)
.insert(GameRoundNote::new(track1, track2, track3, track4, track5, round, player))
.deliver(MessageDelivery::onchain_constrained());
.deliver(MessageDelivery::onchain_unconstrained());

self.enqueue(PodRacing::at(self.context.this_address()).validate_and_play_round(
player,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,7 @@ use super::tag_secret_derivation::TagSecretDerivation;
/// ## Construction
///
/// The fields are private and there is no public constructor: a `MessageDelivery` can only be produced by a
/// [`MessageDeliveryBuilder`] that enforces valid configurations, so invalid field combinations cannot be
/// represented to the consumer.
/// [`MessageDeliveryBuilder`].
pub struct MessageDelivery {
mode: DeliveryMode,
tag_secret_derivation: TagSecretDerivation,
Expand Down Expand Up @@ -144,10 +143,6 @@ impl MessageDelivery {

/// Delivers the message on-chain, guaranteeing the recipient will receive the correct content.
///
/// >**WARNING**: this delivery mode is [currently NOT fully
/// constrained](https://github.com/AztecProtocol/aztec-packages/issues/14565). The log's tag is unconstrained,
/// meaning a malicious sender could manipulate it to prevent the recipient from finding the message.
///
/// ## Use Cases
///
/// This delivery method is suitable for all use cases, since it always works as expected. It is however the most
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
//! Sender-side helpers for constrained message delivery.
//!
//! Constrained messages form per-`(sender, recipient, secret)` sequences, each send anchored to the handshake
//! registry at an incrementing index. Two consequences shape the whole flow: sends on one sequence are strictly
//! ordered across transactions (parallel sends collide or fail the predecessor check, while distinct recipients are
//! distinct sequences and parallelize), and batching several sends onto one sequence within a transaction requires
//! an already-committed handshake.
//!
//! See [`constrain_secret`] for how a send is anchored to the registry,

use crate::context::PrivateContext;
use crate::messages::delivery::OnchainDeliveryMode;
use crate::nullifier::utils::compute_nullifier_existence_request;

use crate::protocol::{
abis::function_selector::FunctionSelector, address::AztecAddress, constants::DOM_SEP__CONSTRAINED_MSG_NULLIFIER,
hash::poseidon2_hash_with_separator, traits::ToField,
};

// The helper cannot import the handshake registry interface because the registry contract depends on aztec-nr. The
// registry's test suite compares this against its macro-generated `HandshakeRegistry::at(...).method(...).selector`
Comment thread
vezenovm marked this conversation as resolved.
// value so signature drift fails in tests.
pub global VALIDATE_HANDSHAKE_SELECTOR: FunctionSelector =
comptime { FunctionSelector::from_signature("validate_handshake((Field),(Field),(u8),Field)") };

pub(crate) fn constrain_secret_and_emit_nullifier(
context: &mut PrivateContext,
registry: AztecAddress,
sender: AztecAddress,
recipient: AztecAddress,
secret: Field,
bootstrapped: bool,
index: u32,
) {
constrain_secret(
context,
registry,
sender,
recipient,
secret,
bootstrapped,
index,
);
context.push_nullifier_unsafe(compute_constrained_msg_nullifier(sender, recipient, secret, index));
}

/// Anchors an untrusted `(secret, index)` to the registry before its constrained tag is emitted.
///
/// - bootstrapped: the secret is the constrained `non_interactive_handshake` return (source of
/// truth), so only its `index == 0` start is asserted.
/// - reuse at index 0: `validate_handshake` binds the oracle-supplied secret to the stored handshake.
/// - reuse at index > 0: the prior sequence nullifier must exist, anchoring back to the index-0 check.
fn constrain_secret(
context: &mut PrivateContext,
registry: AztecAddress,
sender: AztecAddress,
recipient: AztecAddress,
secret: Field,
bootstrapped: bool,
index: u32,
) {
let caller = context.this_address();
let mode = OnchainDeliveryMode::onchain_constrained();
let mode_field = mode.to_field();

if bootstrapped {
assert(index == 0, "freshly bootstrapped secret must start at index 0");
} else if index == 0 {
let _ = context.call_private_function(
registry,
VALIDATE_HANDSHAKE_SELECTOR,
[sender.to_field(), recipient.to_field(), mode_field, secret],
);
} else {
let prev_nullifier = compute_constrained_msg_nullifier(sender, recipient, secret, index - 1);
context.assert_nullifier_exists(compute_nullifier_existence_request(prev_nullifier, caller));
}
}

/// Computes a constrained send's sequence nullifier.
///
/// Every constrained send emits this nullifier so the next send under the same `(sender, recipient, secret)` sequence
/// can prove its predecessor exists.
pub(crate) fn compute_constrained_msg_nullifier(
sender: AztecAddress,
recipient: AztecAddress,
secret: Field,
index: u32,
) -> Field {
poseidon2_hash_with_separator(
[sender.to_field(), recipient.to_field(), secret, index as Field],
DOM_SEP__CONSTRAINED_MSG_NULLIFIER,
)
}

mod test {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it me, or are we missing quite a lot of tests here?

I think we have a lot of behaviors and decision trees on this file that we are not testing. Could we?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most of the coverage is contained in constrained_delivery_test_contract/src/test.nr but yeah this could do with some unit tests.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added some unit tests

use crate::context::PrivateContext;
use crate::hash::hash_args;
use crate::messages::delivery::OnchainDeliveryMode;
use crate::protocol::{address::AztecAddress, hash::compute_siloed_nullifier, traits::{FromField, ToField}};
use crate::test::helpers::test_environment::TestEnvironment;
use super::{compute_constrained_msg_nullifier, constrain_secret_and_emit_nullifier, VALIDATE_HANDSHAKE_SELECTOR};
use std::test::OracleMock;

fn assert_current_nullifier_emitted(
context: &mut PrivateContext,
sender: AztecAddress,
recipient: AztecAddress,
secret: Field,
index: u32,
) {
assert_eq(context.nullifiers.len(), 1);
assert_eq(
context.nullifiers.get(0).inner.value,
compute_constrained_msg_nullifier(sender, recipient, secret, index),
);
}

#[test]
unconstrained fn constrained_helper_emits_current_nullifier() {
let env = TestEnvironment::new();
let registry = AztecAddress::from_field(1);
let sender = AztecAddress::from_field(2);
let recipient = AztecAddress::from_field(4);
let secret: Field = 1234;
let index: u32 = 0;

env.private_context(|context| {
constrain_secret_and_emit_nullifier(context, registry, sender, recipient, secret, true, index);

assert_current_nullifier_emitted(context, sender, recipient, secret, index);
assert_eq(context.private_call_requests.len(), 0);
assert_eq(context.nullifier_read_requests.len(), 0);
});
}

#[test(should_fail_with = "freshly bootstrapped secret must start at index 0")]
unconstrained fn bootstrapped_secret_must_start_at_index_zero() {
let env = TestEnvironment::new();
let registry = AztecAddress::from_field(1);
let sender = AztecAddress::from_field(2);
let recipient = AztecAddress::from_field(4);

env.private_context(|context| {
constrain_secret_and_emit_nullifier(context, registry, sender, recipient, 1234, true, 1);
});
}

#[test]
unconstrained fn reused_secret_at_index_zero_validates_registry_and_emits_nullifier() {
let env = TestEnvironment::new();
let registry = AztecAddress::from_field(1);
let sender = AztecAddress::from_field(2);
let recipient = AztecAddress::from_field(4);
let secret: Field = 1234;
let index: u32 = 0;

env.private_context(|context| {
// The real registry call is covered by integration tests; this unit test only needs a coherent child
// call result so `PrivateContext` records the request.
let child_call_end_counter = (context.side_effect_counter + 1) as Field;
let empty_returns_hash: Field = 0;
let _ = OracleMock::mock("aztec_prv_callPrivateFunction")
.returns([child_call_end_counter, empty_returns_hash])
.times(1);

constrain_secret_and_emit_nullifier(context, registry, sender, recipient, secret, false, index);

assert_current_nullifier_emitted(context, sender, recipient, secret, index);
assert_eq(context.nullifier_read_requests.len(), 0);
assert_eq(context.private_call_requests.len(), 1);

let request = context.private_call_requests.get(0);
assert_eq(request.call_context.msg_sender, context.this_address());
assert_eq(request.call_context.contract_address, registry);
assert_eq(request.call_context.function_selector, VALIDATE_HANDSHAKE_SELECTOR);
assert(!request.call_context.is_static_call);
assert_eq(
request.args_hash,
hash_args([
sender.to_field(),
recipient.to_field(),
OnchainDeliveryMode::onchain_constrained().to_field(),
secret,
]),
);
});
}

#[test]
unconstrained fn reused_secret_above_index_zero_reads_previous_nullifier_and_emits_current_nullifier() {
let env = TestEnvironment::new();
let registry = AztecAddress::from_field(1);
let sender = AztecAddress::from_field(2);
let recipient = AztecAddress::from_field(4);
let secret: Field = 1234;
let index: u32 = 3;

env.private_context(|context| {
let _ = OracleMock::mock("aztec_prv_isNullifierPending").returns(false).times(1);

constrain_secret_and_emit_nullifier(context, registry, sender, recipient, secret, false, index);

assert_current_nullifier_emitted(context, sender, recipient, secret, index);
assert_eq(context.private_call_requests.len(), 0);
assert_eq(context.nullifier_read_requests.len(), 1);

let read_request = context.nullifier_read_requests.get(0);
assert_eq(read_request.contract_address, AztecAddress::zero());
assert_eq(
read_request.inner.inner,
compute_siloed_nullifier(
context.this_address(),
compute_constrained_msg_nullifier(sender, recipient, secret, index - 1),
),
);
});
}
}
71 changes: 69 additions & 2 deletions noir-projects/aztec-nr/aztec/src/messages/delivery/handshake.nr
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use crate::protocol::point::EmbeddedCurvePoint;
use crate::protocol::traits::{Deserialize, Serialize};

use crate::{
context::PrivateContext,
ephemeral::EphemeralArray,
messages::processing::provided_secret::ProvidedSecret,
oracle::{call_utility_function::call_utility_function, shared_secret::get_shared_secrets},
Expand Down Expand Up @@ -36,6 +37,69 @@ global HANDSHAKE_EPH_PKS_SLOT: Field = sha256_to_field("AZTEC_NR::HANDSHAKE_EPH_

global HANDSHAKE_MODES_SLOT: Field = sha256_to_field("AZTEC_NR::HANDSHAKE_MODES_SLOT".as_bytes());

// The helper cannot import the handshake registry interface because the registry contract depends on aztec-nr. These
// selector constants pin the registry ABI surface this library calls. The registry's test suite compares them against
// its macro-generated `HandshakeRegistry::at(...).method(...).selector` values so signature drift fails in tests.
pub global GET_APP_SILOED_SECRET_SELECTOR: FunctionSelector =
comptime { FunctionSelector::from_signature("get_app_siloed_secret((Field),(Field),(u8))") };
pub global NON_INTERACTIVE_HANDSHAKE_SELECTOR: FunctionSelector =
comptime { FunctionSelector::from_signature("non_interactive_handshake((Field),(Field),(u8))") };
pub global GET_HANDSHAKES_SELECTOR: FunctionSelector =
comptime { FunctionSelector::from_signature("get_handshakes((Field),u32)") };

/// Resolves the app-siloed handshake secret, bootstrapping when none exists.
///
/// Resolves the secret for `(sender, recipient, mode)`, creating it via the registry's `non_interactive_handshake`
/// when no handshake exists yet.
///
/// Returns `(secret, bootstrapped)`, where `bootstrapped` is true when this call created the handshake.
///
/// ## Batching
///
/// The reuse-vs-bootstrap decision is a utility call that reads committed state. If no handshake exists yet,
/// multiple sends in the same transaction can't see each other's pending bootstrap, so each one creates its own
/// handshake (a fresh secret on a separate sequence) rather than reusing one. A brand-new recipient therefore needs
/// one landed transaction to establish the handshake before further sends can be batched onto it.
pub(crate) fn get_or_create_app_siloed_handshake_secret(
context: &mut PrivateContext,
registry: AztecAddress,
sender: AztecAddress,
recipient: AztecAddress,
mode: OnchainDeliveryMode,
) -> (Field, bool) {
let mode_field = mode.to_field();

// Safety: the response only selects which path runs. On `None` we bootstrap via `non_interactive_handshake`,
// whose constrained return value is the secret, so a forged empty response cannot fabricate one; it can only
// trigger an unnecessary re-handshake that replaces the registry note. The caller must constrain the returned
// `(secret, bootstrapped)` pair against the selected tagging index before emitting a handshake-derived tag.
let maybe_secret: Option<Field> = unsafe {
let returns = call_utility_function(
registry,
GET_APP_SILOED_SECRET_SELECTOR,
[sender.to_field(), recipient.to_field(), mode_field],
);
Deserialize::deserialize(returns)
};

maybe_secret.map(|secret| (secret, false)).unwrap_or_else(|| {
// Bootstrap: no handshake exists yet. The registry inserts a fresh note and returns the app-siloed
// secret to the caller. The constrained return is the source of truth for the secret, so no separate
// `validate_handshake` is needed by constrained delivery.
// TODO(F-660): dispatch to `perform_handshake(sender, recipient, handshake_type)` once interactive
// handshakes are supported.
let secret: Field = context
.call_private_function(
registry,
NON_INTERACTIVE_HANDSHAKE_SELECTOR,
[sender.to_field(), recipient.to_field(), mode_field],
)
.get_preimage();

(secret, true)
})
}

/// Fetches discovered handshakes from the HandshakeRegistry and derives app-siloed tagging secrets for each,
/// returning them so that [`get_pending_tagged_logs`](crate::oracle::message_processing::get_pending_tagged_logs)
/// searches for logs tagged with these secrets.
Expand Down Expand Up @@ -75,9 +139,12 @@ pub(crate) unconstrained fn get_handshake_secrets(

/// Calls the HandshakeRegistry's `get_handshakes` utility function and deserializes the response.
unconstrained fn fetch_handshake_page(recipient: AztecAddress, page_offset: u32) -> HandshakePage {
let selector = comptime { FunctionSelector::from_signature("get_handshakes((Field),u32)") };
let args: [Field; 2] = [recipient.to_field(), page_offset as Field];
let response = call_utility_function(STANDARD_HANDSHAKE_REGISTRY_ADDRESS, selector, args);
let response = call_utility_function(
STANDARD_HANDSHAKE_REGISTRY_ADDRESS,
GET_HANDSHAKES_SELECTOR,
args,
);
HandshakePage::deserialize(response)
}

Expand Down
Loading
Loading