diff --git a/docs/docs-developers/docs/aztec-nr/framework-description/note_delivery.md b/docs/docs-developers/docs/aztec-nr/framework-description/note_delivery.md index f4f94fbce7ee..5131e4534e66 100644 --- a/docs/docs-developers/docs/aztec-nr/framework-description/note_delivery.md +++ b/docs/docs-developers/docs/aztec-nr/framework-description/note_delivery.md @@ -167,17 +167,19 @@ Onchain delivery tags every message so the recipient can find it efficiently (se 2. Otherwise, when an onchain handshake has been registered for the pair, the secret derived from it is reused directly. 3. Otherwise, the wallet decides how to proceed, since it knows which secrets it holds and how it wants to reach the recipient. -The wallet's answer is a **tagging secret strategy**: it expresses *which* secret to use, and if necessary, PXE performs a [Diffie-Hellman key exchange](https://www.geeksforgeeks.org/computer-networks/diffie-hellman-key-exchange-and-perfect-forward-secrecy/) and/or app-siloing before handing the ready-to-use secret to the contract. Wallets therefore never reimplement that derivation. There are three strategies today: +The wallet's answer is a **tagging secret strategy**: it expresses *which* secret to use, and if necessary, PXE performs a [Diffie-Hellman key exchange](https://www.geeksforgeeks.org/computer-networks/diffie-hellman-key-exchange-and-perfect-forward-secrecy/) and/or app-siloing before handing the ready-to-use secret to the contract. Wallets therefore never reimplement that derivation. There are four strategies today: - **Non-interactive handshake**: the secret comes from a handshake published onchain that the recipient can derive. A non-recipient can at most learn that a sender did a handshake with the recipient, not the message itself, but the recipient discovers it without any prior coordination. Works for both constrained and unconstrained delivery. +- **Interactive handshake**: the secret comes from a handshake the recipient authorizes with a signature, which the registry requests through the custom request oracle and verifies in-circuit. Nothing announcing the handshake is published onchain (the recipient learns the secret while signing), but the recipient must be reachable to answer the request, or the send fails. Works for both constrained and unconstrained delivery. - **Address-derived secret**: the PXE derives the secret from the sender's and recipient's address keys via Diffie-Hellman. The wallet supplies no material, only the choice. It leaves no onchain trace, but the recipient only finds the message if they registered the sender in their PXE. Unconstrained delivery only. - **Arbitrary secret**: a raw secret point the two parties already share offchain, having coordinated out of band to agree on it. The wallet supplies the point and the PXE app-silos it. It leaves no onchain trace, but no onchain handshake backs the secret. Unconstrained delivery only. -| | Non-interactive handshake | Address-derived secret | Arbitrary secret | -|---|---|---|---| -| Onchain footprint when establishing | A handshake revealing information about the recipient | None | None | -| Who provides the material | The onchain registry | Nobody (PXE computes it) | The wallet (a raw point) | -| Constrained delivery | Supported | Not sound: not backed by an onchain handshake | Not sound: not backed by an onchain handshake | +| | Non-interactive handshake | Interactive handshake | Address-derived secret | Arbitrary secret | +|---|---|---|---|---| +| Onchain footprint when establishing | A handshake revealing information about the recipient | None | None | None | +| Who provides the material | The onchain registry | The onchain registry, with the recipient's signed authorization | Nobody (PXE computes it) | The wallet (a raw point) | +| Recipient coordination | None | The recipient must answer the signature request | The recipient must have registered the sender | Agreed out of band | +| Constrained delivery | Supported | Supported | Not sound: not backed by an onchain handshake | Not sound: not backed by an onchain handshake | ### Defaults @@ -186,6 +188,8 @@ When no `resolveTaggingSecretStrategy` hook is configured, the PXE applies a def - **Unconstrained delivery**: a non-interactive handshake when the recipient is external, so the recipient discovers the message without having registered the sender in advance. When the recipient is one of the wallet's own accounts (a self-send), an address-derived secret is used instead: the wallet holds both sides' keys, so no handshake is needed and nothing is revealed onchain. - **Constrained delivery**: a non-interactive handshake (constrained delivery must be backed by a handshake). +An interactive handshake is never the default, since it fails when the recipient cannot be reached: a wallet opts in through the hook when it knows how to reach them. + ### Configuring the strategy Wallets provide the strategy through the `resolveTaggingSecretStrategy` [execution hook](../../foundational-topics/pxe/execution_hooks.md) when creating their PXE. The hook receives the message context (executing contract, sender, recipient and delivery mode), so a wallet can answer per message instead of with a fixed value. That page also covers how to configure a strategy in Noir tests. @@ -198,7 +202,7 @@ A contract can fix the derivation at the point of delivery with the builder's `v MessageDelivery::onchain_unconstrained().via_address_derived_secret() ``` -Unconstrained delivery exposes `via_non_interactive_handshake()` and `via_address_derived_secret()`. Constrained delivery exposes only `via_non_interactive_handshake()`, since an address-derived secret cannot back constrained delivery. +Unconstrained delivery exposes `via_non_interactive_handshake()`, `via_interactive_handshake()` and `via_address_derived_secret()`. Constrained delivery exposes only `via_non_interactive_handshake()` and `via_interactive_handshake()`, since an address-derived secret cannot back constrained delivery. ## Note Discovery and the Sender diff --git a/docs/docs-developers/docs/foundational-topics/advanced/storage/note_discovery.md b/docs/docs-developers/docs/foundational-topics/advanced/storage/note_discovery.md index d726f9ee10b3..93244b41108d 100644 --- a/docs/docs-developers/docs/foundational-topics/advanced/storage/note_discovery.md +++ b/docs/docs-developers/docs/foundational-topics/advanced/storage/note_discovery.md @@ -27,7 +27,7 @@ Every tag is derived the same way: `poseidon2(secret, index)`. What varies is how the sender and recipient come to share `secret`. This is the [tagging secret strategy](../../../aztec-nr/framework-description/note_delivery.md#tagging-secret-strategy), chosen by the wallet by default, though a contract can [override it at delivery](../../../aztec-nr/framework-description/note_delivery.md#overriding-the-strategy-from-the-contract). -There are three strategies, described below. They differ only in how `secret` is established. Once `secret` exists, the tag is derived from it the same way for all three. +There are four strategies, described below. They differ only in how `secret` is established. Once `secret` exists, the tag is derived from it the same way for all four. ##### Arbitrary secret @@ -43,6 +43,12 @@ To establish a handshake, the sender publishes an ephemeral public key onchain, This lets the recipient discover messages from a sender they never registered, at the cost of publishing onchain that a handshake was made with them. It is the default for reaching a new external recipient, and unlike an address-derived or arbitrary secret it can back [constrained delivery](../../../aztec-nr/framework-description/note_delivery.md#tagging-secret-strategy). +##### Interactive handshake + +An interactive handshake derives the same kind of shared secret from an ephemeral key, but the ephemeral key is never published: instead of announcing it onchain, the recipient must be reachable at send time to sign it. The registry requests that signature through the custom request oracle and verifies it in-circuit, so the send fails if the recipient does not answer. The recipient learns the ephemeral key while signing and registers the resulting secret with their PXE so it can scan for the tags. As with a non-interactive handshake, the secret is app-siloed to the contract but left bare, with no directional fold. + +This reveals nothing onchain about the recipient, and like a non-interactive handshake it can back [constrained delivery](../../../aztec-nr/framework-description/note_delivery.md#tagging-secret-strategy). Because it requires the recipient's cooperation at send time, it is never the default: a wallet or contract opts in explicitly. + ##### Deriving the tag from the secret Whichever strategy produced it, the resulting app-siloed tagging secret is turned into a tag the same way: @@ -110,7 +116,7 @@ There are three broad families of solutions to this problem: **b) Tagging with known sender** - You know who will send you messages and search for those specifically. This is very fast and allows you to remove senders who spam you. However, it cannot be constrained, i.e., it cannot guarantee that the recipient will find the message. It also requires registering each sender's address in advance with `wallet.registerSender(address)`, so you must learn that address first. -**c) Tagging with a handshake** - The sender and recipient execute a handshake to agree on a tagging secret, after which regular tagging works, so the recipient can discover messages without having registered the sender in advance. A handshake can be interactive (the two coordinate offchain) or non-interactive (published onchain, which needs no prior coordination but reveals a sender has done a handshake with the recipient). By default the wallet determines the type of handshake to use, though a contract can override the choice at delivery (see [tagging secret strategy](../../../aztec-nr/framework-description/note_delivery.md#tagging-secret-strategy)). +**c) Tagging with a handshake** - The sender and recipient establish a handshake to agree on a tagging secret, after which regular tagging works: the recipient can discover messages without having registered the sender in advance. A non-interactive handshake is published onchain, so it needs nothing from the recipient, but it reveals that someone did a handshake with them. An interactive handshake reveals nothing onchain; in exchange, the recipient must sign it at send time. By default the wallet determines the type of handshake to use, though a contract can override the choice at delivery (see [tagging secret strategy](../../../aztec-nr/framework-description/note_delivery.md#tagging-secret-strategy)). See the [Note Delivery](../../../aztec-nr/framework-description/note_delivery.md) documentation for more details on how the sender is used when delivering notes. diff --git a/docs/docs-developers/docs/foundational-topics/pxe/execution_hooks.md b/docs/docs-developers/docs/foundational-topics/pxe/execution_hooks.md index dbdc302c3d4c..7d9fbfa86d59 100644 --- a/docs/docs-developers/docs/foundational-topics/pxe/execution_hooks.md +++ b/docs/docs-developers/docs/foundational-topics/pxe/execution_hooks.md @@ -96,6 +96,8 @@ Pass a `resolveTaggingSecretStrategy` hook when [creating the PXE](#configuring- When the hook is absent, the PXE applies a default: both delivery modes use a [non-interactive handshake](../../aztec-nr/framework-description/note_delivery.md#tagging-secret-strategy) so the recipient can discover the message without prior coordination. +Returning `{ type: 'interactive-handshake' }` makes the handshake registry request the recipient's signed authorization through the [`resolveCustomRequest`](#resolvecustomrequest) hook (see the [example](#example-interactive-handshakes) below), so a wallet should only choose it when that request can be served. + ## `resolveCustomRequest` A general-purpose hook for *custom*, caller-defined requests. A contract reaches for it when it needs something it cannot get on its own: not from its local notes, not from the protocol's existing oracles. The contract gives the request a `kind` and an opaque `payload`, and the wallet returns an opaque response. Because the request is caller-defined, the wallet decides per `kind` how to answer, whether by reading state it holds, contacting another party, or fetching offchain data. diff --git a/noir-projects/aztec-nr/aztec/src/context/utility_context.nr b/noir-projects/aztec-nr/aztec/src/context/utility_context.nr index 0c7f710a9422..4cb5597b876c 100644 --- a/noir-projects/aztec-nr/aztec/src/context/utility_context.nr +++ b/noir-projects/aztec-nr/aztec/src/context/utility_context.nr @@ -1,4 +1,4 @@ -use crate::oracle::{execution::get_utility_context, storage::storage_read}; +use crate::oracle::{execution::{get_utility_context, UtilityContextData}, storage::storage_read}; use crate::protocol::{ abis::block_header::BlockHeader, address::AztecAddress, constants::NULL_MSG_SENDER_CONTRACT_ADDRESS, traits::Packable, @@ -11,6 +11,12 @@ pub struct UtilityContext { msg_sender: AztecAddress, } +impl From for UtilityContext { + fn from(data: UtilityContextData) -> Self { + Self { block_header: data.block_header, contract_address: data.contract_address, msg_sender: data.msg_sender } + } +} + impl UtilityContext { pub unconstrained fn new() -> Self { get_utility_context() diff --git a/noir-projects/aztec-nr/aztec/src/macros/oracle_testing.nr b/noir-projects/aztec-nr/aztec/src/macros/oracle_testing.nr index 1c1c26c78130..f344ee0dc75c 100644 --- a/noir-projects/aztec-nr/aztec/src/macros/oracle_testing.nr +++ b/noir-projects/aztec-nr/aztec/src/macros/oracle_testing.nr @@ -6,21 +6,23 @@ //! //! `#[generate_oracle_tests]`, applied to a module, generates that test from each oracle's signature. The generated //! test: -//! 1. builds a canonical value for every parameter and the return type (via the [`OracleTestValue`] trait), +//! 1. builds a canonical value for every parameter and the return type, //! 2. calls the real oracle, which serializes the arguments and sends them to a dedicated TS resolver, //! 3. the resolver deserializes the arguments, checks them against the same canonical values (which it rebuilds //! independently from the oracle's registered type), and serializes a canonical return value back, //! 4. Noir deserializes that return value and asserts it equals the expected one. //! -//! So if the two serializers agree the test passes, and if they drift it fails. The TS half of the convention lives in -//! `yarn-project/txe/src/oracle/test-resolver/default_fixtures.ts` and must stay in lockstep with this file: its -//! `TEST_VALUE_IMPLS` table mirrors the [`OracleTestValue`] impls here one-to-one. +//! So if the two serializers agree the test passes, and if they drift it fails. +//! +//! Canonical values come from the synthesizer table ([`synthesizers`]) and the scalar registrations ([`scalars`]), +//! which mirror the TS synthesizer (`yarn-project/txe/src/oracle/test-resolver/default_fixtures.ts`) entry for +//! entry. //! //! ## The scenario convention //! -//! [`OracleTestValue`] maps a `seed` to an array of [`Scenario`]s, each a canonical value plus the name of the -//! serialization shape it exercises. Most types have a single, unnamed scenario; a type whose cases serialize -//! differently declares several named ones (`Option` has `some` and `none`). The macro emits one test per scenario. +//! Most types serialize in a single shape and have one test scenario; a type whose cases serialize differently has +//! several, each with a name its entry declares (`Option` has `some` and `none`). The macro emits one test per +//! scenario, and each test sends the selected names to the resolver (see [`add_scenario_to_oracle_test`]). //! //! ## What gets generated //! @@ -31,16 +33,8 @@ //! unconstrained fn nullifier_exists_opcode(siloed_nullifier: Field) -> bool {} //! ``` //! -//! the macro produces: -//! -//! ```noir -//! #[test] -//! unconstrained fn __oracle_test__nullifier_exists_opcode() { -//! let __arg_0 = >::oracle_test_value(0)[0]; -//! let __ret = >::oracle_test_value(0)[0]; -//! assert_eq(nullifier_exists_opcode(__arg_0.value), __ret.value, "..."); -//! } -//! ``` +//! the macro produces a test asserting that the oracle, called with the `Field` entry's value at seed 10, returns +//! the `bool` entry's value at seed 11 (see [`SEED_BASE`] for the seeding convention). //! //! ## Multiple scenarios //! @@ -56,7 +50,6 @@ //! (`__0`, `__1`, …). use crate::messages::delivery::OnchainDeliveryMode; -use crate::oracle::get_contract_instance::GetContractInstanceResult; use crate::protocol::{ abis::function_selector::FunctionSelector, address::{AztecAddress, EthAddress}, @@ -76,134 +69,364 @@ global MAX_SCENARIO_NAME_LEN: u32 = 64; /// the TS synthesizer (`default_fixtures.ts`). global SEED_BASE: u32 = 10; -/// One canonical test case for a type: the `value` to put on the wire (or expect back from it), and the `name` of the -/// serialization shape it exercises. Single-scenario types leave the name empty; it is never sent to the resolver. -pub struct Scenario { - pub value: T, - pub name: BoundedVec, +/// One serialization shape of a type's canonical value. Mirrors the TS `Scenario`. +comptime struct Scenario { + /// The name sent to the resolver, for kinds with more than one shape. Kinds with a single shape leave it + /// unset and send nothing. + name: Option, + /// The expression building the value to put on the wire (or expect back from it). + value: Quoted, } -impl Scenario { - pub fn unnamed(value: T) -> Self { - Self { value, name: BoundedVec::new() } - } +comptime fn unnamed(value: Quoted) -> Scenario { + Scenario { name: Option::none(), value } +} - pub fn named(value: T, name: str) -> Self { - Self { value, name: BoundedVec::from_array(name.as_bytes()) } - } +comptime fn named(name: Quoted, value: Quoted) -> Scenario { + Scenario { name: Option::some(name), value } } -/// Maps a `seed` to the canonical test scenarios for a type, one per serialization shape. `N` is the scenario count, -/// declared on each impl and read from there by [`num_scenarios`]. See [`SEED_BASE`] for what `seed` is. Mirrored -/// exactly by the TS resolver's fixture synthesizer (`default_fixtures.ts`). -pub trait OracleTestValue { - fn oracle_test_value(seed: u32) -> [Scenario; N]; +/// One type kind's synthesizer: everything the macro knows about the kind. +comptime struct TestValueSynthesizer { + /// Whether this entry synthesizes values for the type; [`synthesizer_for`] picks the first match in + /// [`synthesizers`] order. + matches: fn(Type) -> bool, + /// The value expression for each of the kind's serialization shapes at the given seed expression. + scenarios: fn(Type, Quoted) -> [Scenario], + /// The number of wire fields a value occupies, e.g. `1` for a scalar and `1 + width(inner)` for an `Option`. + width: fn(Type) -> u32, + /// The type quoted for annotation positions, with generic lengths pinned to the test constants (see + /// [`pinned_type`]). + pinned: fn(Type) -> Quoted, } -impl OracleTestValue<1> for Field { - fn oracle_test_value(seed: u32) -> [Scenario; 1] { - [Scenario::unnamed(seed as Field)] +/// The scalar registrations. +comptime fn scalars() -> [(Type, Quoted)] { + @[ + scalar(|seed: u32| seed as Field), + scalar(|seed: u32| (seed % 2) != 0), + scalar(|seed: u32| seed as u8), + scalar(|seed: u32| seed), + scalar(|seed: u32| seed as u64), + scalar(|seed: u32| seed as u128), + scalar(|seed: u32| AztecAddress::from_field(seed as Field)), + scalar(|seed: u32| EthAddress::from_field(seed as Field)), + scalar(|seed: u32| FunctionSelector::from_field(seed as Field)), + onchain_delivery_mode_scalar(), + ] +} + +/// One entry per type kind; [`synthesizer_for`] dispatches to the first match, so order matters. +comptime fn synthesizers() -> [TestValueSynthesizer] { + @[ + scalar_synth(), + option_synth(), + array_synth(), + bounded_vec_synth(), + tuple_synth(), + // Matches ANY named data type: must stay last (find-first). + struct_synth(), + ] +} + +/// The seed alternates between the only two valid modes. +comptime fn onchain_delivery_mode_scalar() -> (Type, Quoted) { + scalar( + |seed: u32| if (seed % 2) == 0 { + OnchainDeliveryMode::onchain_unconstrained() + } else { + OnchainDeliveryMode::onchain_constrained() + }, + ) +} + +/// One [`scalars`] entry: the scalar type `T`, paired with the function that builds its canonical test value from +/// a seed. +comptime fn scalar(value: fn(u32) -> T) -> (Type, Quoted) { + // There is no direct way to reflect the generic `T` as a `Type`, so a zeroed value is materialized purely to + // take its type. + (std::meta::type_of(std::mem::zeroed::()), quote { $value }) +} + +comptime fn scalar_synth() -> TestValueSynthesizer { + TestValueSynthesizer { + matches: |t: Type| maybe_scalar_builder(t).is_some(), + scenarios: |t: Type, seed: Quoted| { + let value_fn = maybe_scalar_builder(t).unwrap(); + @[unnamed(quote { $value_fn($seed) })] + }, + // Being a single wire field with no lengths to pin is what qualifies a type for the scalars table. + width: |_t: Type| 1, + pinned: |t: Type| quote { $t }, } } -impl OracleTestValue<1> for u32 { - fn oracle_test_value(seed: u32) -> [Scenario; 1] { - [Scenario::unnamed(seed)] + +/// Looks `typ` up in [`scalars`] and returns the builder lambda registered for it, or `Option::none()` if `typ` +/// is not a scalar. +comptime fn maybe_scalar_builder(typ: Type) -> Option { + let matching = scalars().filter(|entry: (Type, Quoted)| entry.0 == typ); + if matching.len() == 0 { + Option::none() + } else { + Option::some(matching[0].1) } } -impl OracleTestValue<1> for u64 { - fn oracle_test_value(seed: u32) -> [Scenario; 1] { - [Scenario::unnamed(seed as u64)] + +comptime fn option_synth() -> TestValueSynthesizer { + TestValueSynthesizer { + matches: |t: Type| is_data_type_named(t, quote { Option }), + scenarios: |t: Type, seed: Quoted| { + @[named(quote { "some" }, some_value_expr(t, seed)), named(quote { "none" }, none_value_expr(t, seed))] + }, + width: |t: Type| 1 + flat_width(t.as_data_type().unwrap().1[0]), + pinned: |t: Type| { + let inner = t.as_data_type().unwrap().1[0]; + let pinned_inner = pinned_type(inner); + quote { Option<$pinned_inner> } + }, } } -impl OracleTestValue<1> for u128 { - fn oracle_test_value(seed: u32) -> [Scenario; 1] { - [Scenario::unnamed(seed as u128)] + +/// `Option::some` around the inner type's value. +comptime fn some_value_expr(typ: Type, seed: Quoted) -> Quoted { + let inner = typ.as_data_type().unwrap().1[0]; + let inner_value = value_expr(inner, seed, 0); + quote { Option::some($inner_value) } +} + +/// `Option::none`, with the inner type pinned in the turbofish so generic lengths resolve even though no inner +/// value is built. +comptime fn none_value_expr(typ: Type, _seed: Quoted) -> Quoted { + let inner = typ.as_data_type().unwrap().1[0]; + let pinned_inner = pinned_type(inner); + quote { Option::<$pinned_inner>::none() } +} + +comptime fn array_synth() -> TestValueSynthesizer { + TestValueSynthesizer { + matches: |t: Type| t.as_array().is_some(), + scenarios: |t: Type, seed: Quoted| @[unnamed(array_value_expr(t, seed))], + width: |t: Type| { + let (element_type, length_type) = t.as_array().unwrap(); + let length = length_type.as_constant().unwrap_or(TEST_COLLECTION_LEN); + length * flat_width(element_type) + }, + pinned: |t: Type| { + let (element_type, length_type) = t.as_array().unwrap(); + let pinned_element = pinned_type(element_type); + // A concrete length (e.g. `[T; 1]`) is part of the oracle's signature and must be kept; only a generic + // length has no value to instantiate and gets pinned. + let len = length_type + .as_constant() + .map(|concrete_length: u32| f"{concrete_length}".quoted_contents()) + .unwrap_or_else(|| f"{TEST_COLLECTION_LEN}".quoted_contents()); + quote { [$pinned_element; $len] } + }, } } -impl OracleTestValue<1> for u8 { - fn oracle_test_value(seed: u32) -> [Scenario; 1] { - [Scenario::unnamed(seed as u8)] + +/// A call to [`oracle_test_array`], with the element's builder and the pinned element type and length. +comptime fn array_value_expr(typ: Type, seed: Quoted) -> Quoted { + let (element_type, length_type) = typ.as_array().unwrap(); + let length = length_type.as_constant().unwrap_or(TEST_COLLECTION_LEN); + let len_expr = f"{length}".quoted_contents(); + let pinned_element = pinned_type(element_type); + let build_element = builder_expr(element_type); + quote { + crate::macros::oracle_testing::oracle_test_array::<$pinned_element, $len_expr>($build_element, $seed) } } -impl OracleTestValue<1> for bool { - fn oracle_test_value(seed: u32) -> [Scenario; 1] { - [Scenario::unnamed((seed % 2) != 0)] + +/// Each element is seeded distinctly so element ordering is covered too. +pub(crate) fn oracle_test_array(build_element: fn(u32) -> T, seed: u32) -> [T; N] { + let mut array: [T; N] = std::mem::zeroed(); + for i in 0..N { + array[i] = build_element(seed + i); } + array } -impl OracleTestValue<1> for AztecAddress { - fn oracle_test_value(seed: u32) -> [Scenario; 1] { - [Scenario::unnamed(AztecAddress::from_field(seed as Field))] + +comptime fn bounded_vec_synth() -> TestValueSynthesizer { + TestValueSynthesizer { + matches: |t: Type| is_data_type_named(t, quote { BoundedVec }), + scenarios: |t: Type, seed: Quoted| @[unnamed(bounded_vec_value_expr(t, seed))], + width: |t: Type| { + let generics = t.as_data_type().unwrap().1; + let max_length = generics[1].as_constant().unwrap_or(TEST_BOUNDED_VEC_CAP); + max_length * flat_width(generics[0]) + 1 + }, + pinned: |t: Type| { + let generics = t.as_data_type().unwrap().1; + let pinned_element = pinned_type(generics[0]); + let capacity = generics[1].as_constant().unwrap_or(TEST_BOUNDED_VEC_CAP); + let cap = f"{capacity}".quoted_contents(); + quote { BoundedVec<$pinned_element, $cap> } + }, } } -impl OracleTestValue<1> for FunctionSelector { - fn oracle_test_value(seed: u32) -> [Scenario; 1] { - [Scenario::unnamed(FunctionSelector::from_field(seed as Field))] + +/// A call to [`oracle_test_bounded_vec`], with the element's builder and the pinned element type and capacity. +comptime fn bounded_vec_value_expr(typ: Type, seed: Quoted) -> Quoted { + let generics = typ.as_data_type().unwrap().1; + let element_type = generics[0]; + // A concrete capacity is part of the oracle's signature and is kept (the TS side must map it as + // FIXED_BOUNDED_VEC); only a generic capacity gets pinned. + let capacity = generics[1].as_constant().unwrap_or(TEST_BOUNDED_VEC_CAP); + let cap = f"{capacity}".quoted_contents(); + let pinned_element = pinned_type(element_type); + let build_element = builder_expr(element_type); + quote { + crate::macros::oracle_testing::oracle_test_bounded_vec::<$pinned_element, $cap>($build_element, $seed) } } -impl OracleTestValue<1> for EthAddress { - fn oracle_test_value(seed: u32) -> [Scenario; 1] { - [Scenario::unnamed(EthAddress::from_field(seed as Field))] + +/// Builds a bounded vec holding fewer elements than its capacity, so the unused tail exercises zero-padding on the +/// wire. A capacity smaller than TEST_COLLECTION_LEN clamps the element count instead. +pub(crate) fn oracle_test_bounded_vec( + build_element: fn(u32) -> T, + seed: u32, +) -> BoundedVec { + let mut bounded_vec: BoundedVec = BoundedVec::new(); + for i in 0..std::cmp::min(TEST_COLLECTION_LEN, MaxLen) { + bounded_vec.push(build_element(seed + i)); } + bounded_vec } -impl OracleTestValue<1> for OnchainDeliveryMode { - fn oracle_test_value(seed: u32) -> [Scenario; 1] { - // The seed alternates between the only two valid modes - let mode = if (seed % 2) == 0 { - OnchainDeliveryMode::onchain_unconstrained() - } else { - OnchainDeliveryMode::onchain_constrained() - }; - [Scenario::unnamed(mode)] + +comptime fn tuple_synth() -> TestValueSynthesizer { + TestValueSynthesizer { + matches: |t: Type| t.as_tuple().is_some(), + scenarios: |t: Type, seed: Quoted| @[unnamed(tuple_value_expr(t, seed))], + width: |t: Type| { + let mut width: u32 = 0; + for member_type in t.as_tuple().unwrap() { + width += flat_width(member_type); + } + width + }, + pinned: |t: Type| quote { $t }, } } -impl OracleTestValue<1> for GetContractInstanceResult { - fn oracle_test_value(seed: u32) -> [Scenario; 1] { - [ - Scenario::unnamed( - GetContractInstanceResult { exists: (seed % 2) != 0, member: (seed + 1) as Field }, - ), - ] + +/// A call to [`oracle_test_tuple2`], with each member's builder and flat wire offset. +comptime fn tuple_value_expr(typ: Type, seed: Quoted) -> Quoted { + let members = typ.as_tuple().unwrap(); + let mut member_builders: [Quoted] = @[]; + let mut offsets: [u32] = @[]; + let mut offset: u32 = 0; + for member_type in members { + member_builders = member_builders.push_back(builder_expr(member_type)); + offsets = offsets.push_back(offset); + offset += flat_width(member_type); + } + if members.len() == 2 { + let (a, b) = (member_builders[0], member_builders[1]); + let b_offset_value = offsets[1]; + let b_offset = f"{b_offset_value}".quoted_contents(); + quote { crate::macros::oracle_testing::oracle_test_tuple2($a, $b, $b_offset, $seed) } + } else { + panic( + f"cannot build an oracle test value for {typ}: add an oracle_test_tuple helper for this arity", + ) } } -impl OracleTestValue<2> for Option -where - T: OracleTestValue<1>, -{ - fn oracle_test_value(seed: u32) -> [Scenario; 2] { - [ - Scenario::named(Option::some(T::oracle_test_value(seed)[0].value), "some"), - Scenario::named(Option::none(), "none"), - ] + +pub(crate) fn oracle_test_tuple2( + build_a: fn(u32) -> A, + build_b: fn(u32) -> B, + b_offset: u32, + seed: u32, +) -> (A, B) { + (build_a(seed), build_b(seed + b_offset)) +} + +comptime fn struct_synth() -> TestValueSynthesizer { + TestValueSynthesizer { + matches: |t: Type| t.as_data_type().is_some(), + scenarios: |t: Type, seed: Quoted| @[unnamed(struct_value_expr(t, seed))], + width: |t: Type| { + let (definition, generics) = t.as_data_type().unwrap(); + let mut width: u32 = 0; + for field in definition.fields(generics) { + let (_name, field_type, _visibility) = field; + width += flat_width(field_type); + } + width + }, + pinned: |t: Type| quote { $t }, } } -impl OracleTestValue<1> for [T; N] -where - T: OracleTestValue<1>, -{ - fn oracle_test_value(seed: u32) -> [Scenario; 1] { - // `N` is the array length, which `pinned_type` set to `TEST_COLLECTION_LEN`; each element is seeded distinctly - // so element ordering is covered too. - let mut array: [T; N] = std::mem::zeroed(); - for i in 0..N { - array[i] = T::oracle_test_value(seed + i)[0].value; - } - [Scenario::unnamed(array)] + +/// The struct literal building `typ`'s canonical value inline: ordinary typed code whose fields recurse through +/// leaf builders, nested literals and composites, each seeded at its flat wire offset. +comptime fn struct_value_expr(typ: Type, seed: Quoted) -> Quoted { + let (definition, generics) = typ.as_data_type().unwrap(); + let mut initializers: [Quoted] = @[]; + let mut offset: u32 = 0; + for field in definition.fields(generics) { + let (field_name, field_type, visibility) = field; + // A private field cannot be initialized from the generated test's module, and would fail with a bare + // privacy error deep inside the generated code. + assert( + visibility != quote {}, + f"cannot build an oracle test value for {typ}: its field {field_name} is private. Register the type in scalars(), or exclude this oracle via generate_oracle_tests_excluding", + ); + let field_value = value_expr(field_type, offset_seed(seed, offset), 0); + initializers = initializers.push_back(quote { $field_name: $field_value }); + offset += flat_width(field_type); } + let initializers = initializers.join(quote {,}); + quote { $typ { $initializers } } } -impl OracleTestValue<1> for BoundedVec -where - T: OracleTestValue<1>, -{ - fn oracle_test_value(seed: u32) -> [Scenario; 1] { - // TEST_COLLECTION_LEN elements into a capacity the macro pins to TEST_BOUNDED_VEC_CAP, leaving a padded tail. - let mut bounded_vec: BoundedVec = BoundedVec::new(); - for i in 0..TEST_COLLECTION_LEN { - bounded_vec.push(T::oracle_test_value(seed + i)[0].value); - } - [Scenario::unnamed(bounded_vec)] + +/// The seed expression for a member at flat wire `offset` past `seed`. +comptime fn offset_seed(seed: Quoted, offset: u32) -> Quoted { + if offset == 0 { + seed + } else { + let offset_expr = f"{offset}".quoted_contents(); + quote { ($seed + $offset_expr) } } } +comptime fn synthesizer_for(typ: Type) -> TestValueSynthesizer { + let matching = synthesizers().filter(|e: TestValueSynthesizer| (e.matches)(typ)); + assert( + matching.len() >= 1, + f"cannot build an oracle test value for {typ}: no synthesizer entry matches. Add one, or exclude this oracle via generate_oracle_tests_excluding", + ); + matching[0] +} + +/// All serialization shapes of `typ`'s canonical value at `seed`, one [`Scenario`] per shape. +comptime fn scenarios_for(typ: Type, seed: Quoted) -> [Scenario] { + (synthesizer_for(typ).scenarios)(typ, seed) +} + +/// `typ`'s canonical value expression at `seed` for the given test `scenario` (wrapping around the type's own +/// scenario count). +comptime fn value_expr(typ: Type, seed: Quoted, scenario: u32) -> Quoted { + let scenarios = scenarios_for(typ, seed); + scenarios[scenario % scenarios.len()].value +} + +/// `typ`'s builder as an `fn(u32) -> T` expression, for passing into the typed composite helpers: a scalar's own +/// lambda, or a lambda wrapping the type's value expression (first scenario). +comptime fn builder_expr(typ: Type) -> Quoted { + let maybe_scalar = maybe_scalar_builder(typ); + if maybe_scalar.is_some() { + maybe_scalar.unwrap() + } else { + let value = value_expr(typ, quote { seed }, 0); + quote { |seed: u32| $value } + } +} + +/// `typ`'s wire width, from its entry. +comptime fn flat_width(typ: Type) -> u32 { + (synthesizer_for(typ).width)(typ) +} + /// Generates serialization roundtrip tests for every `#[oracle(...)]` declaration in a module. See the module docs for /// the full scheme. Use [`generate_oracle_tests_excluding`] when some of the module's oracles must be left out. /// @@ -213,7 +436,7 @@ where /// #[generate_oracle_tests] /// pub mod nullifiers; /// ``` -pub comptime fn generate_oracle_tests(m: Module) -> Quoted { +pub(crate) comptime fn generate_oracle_tests(m: Module) -> Quoted { let none: [Quoted] = @[]; generate_oracle_tests_excluding(m, none) } @@ -221,8 +444,8 @@ pub comptime fn generate_oracle_tests(m: Module) -> Quoted { /// Like [`generate_oracle_tests`], but leaves out the oracles named in `skip` (by Noir function name). /// /// Every oracle in the module must be testable or excluded: if an oracle's parameters or return type can't be -/// synthesized (no [`OracleTestValue`] impl), generating its test fails at compile time and the dev must add it to -/// `skip`. Every `skip` entry must in turn match an oracle in the module. +/// synthesized (no [`synthesizers`] entry matches), generating its test fails at compile time and the dev must add it +/// to `skip`. Every `skip` entry must in turn match an oracle in the module. /// /// ## Usage /// @@ -232,7 +455,7 @@ pub comptime fn generate_oracle_tests(m: Module) -> Quoted { /// ])] /// pub mod avm; /// ``` -pub comptime fn generate_oracle_tests_excluding(m: Module, skip: [Quoted]) -> Quoted { +pub(crate) comptime fn generate_oracle_tests_excluding(m: Module, skip: [Quoted]) -> Quoted { let oracles = m.functions().filter(|f: FunctionDefinition| f.has_named_attribute("oracle")); // Guard against stale skip-list entries: every name must match an oracle in this module. @@ -265,9 +488,9 @@ comptime fn oracle_test_for(f: FunctionDefinition) -> Quoted { /// Builds each generated test's `(setup, assertion)`, one per scenario. /// -/// For `fn get(slot: Field) -> Field` the single scenario's pair is: -/// setup = `let __arg_0 = ...; let __ret = ...;` (the `OracleTestValue` bindings, plus any announcements) -/// assertion = `assert_eq(get(__arg_0.value), __ret.value, "...");` +/// For `fn get_witness_oracle(key: Field) -> Option`, the `some` scenario's pair is: +/// setup = `set_scenario("some");` (the return is the only multi-scenario position) +/// assertion = `assert_eq(get_witness_oracle(…), Option::some(…), "…");` comptime fn prepare_scenarios(f: FunctionDefinition) -> [(Quoted, Quoted)] { let oracle = f.name(); let params = f.parameters(); @@ -281,18 +504,18 @@ comptime fn prepare_scenarios(f: FunctionDefinition) -> [(Quoted, Quoted)] { for i in 0..params.len() { let (_name, param_type) = params[i]; - let arg = f"__arg_{i}".quoted_contents(); - let (binding, value) = bind_scenario(param_type, arg, i, scenario); - setup = setup.push_back(binding); - call_args = call_args.push_back(value); + let seed = SEED_BASE + i; + let seed_expr = f"{seed}".quoted_contents(); + setup = setup.push_back(set_scenario_expr(param_type, scenario)); + call_args = call_args.push_back(value_expr(param_type, seed_expr, scenario)); } let args = call_args.join(quote {,}); - // The return's position is the slot right after the last parameter (params take 0..len-1), so its value is + // The return's seed is the slot right after the last parameter (params take 0..len-1), so its value is // distinct. - let return_position = params.len(); - let (return_binding, assertion) = build_assertion(oracle, args, return_type, return_position, scenario); - setup = setup.push_back(return_binding); + let return_seed = SEED_BASE + params.len(); + let (return_setup, assertion) = build_assertion(oracle, args, return_type, return_seed, scenario); + setup = setup.push_back(return_setup); result = result.push_back((setup.join(quote {}), assertion)); } @@ -331,79 +554,61 @@ comptime fn total_scenarios(params: [(Quoted, Type)], return_type: Type) -> u32 count } -/// For one value (a parameter or the return) in one scenario, returns `(setup, value)`: the `let $binding = …;` -/// statement that selects this scenario's [`Scenario`], and the expression that reads its value (`$binding.value`). -comptime fn bind_scenario(typ: Type, binding: Quoted, position: u32, scenario: u32) -> (Quoted, Quoted) { - let idx_val = scenario % num_scenarios(typ); - let idx = f"{idx_val}".quoted_contents(); - let list = scenarios_expr(typ, position); - let announce = set_scenario(typ, binding); - (quote { let $binding = $list[$idx]; $announce }, quote { $binding.value }) -} - /// The `assertion` for one scenario, plus the extra `setup` it needs. A unit-returning oracle just calls it -/// (`let _ = oracle(args)`) and needs no setup; otherwise this binds the expected return value (`__ret`) in -/// `return_binding` and asserts the oracle's result equals it. +/// (`let _ = oracle(args)`) and needs no setup; otherwise the assertion checks the oracle's result against the +/// expected canonical return value. comptime fn build_assertion( oracle: Quoted, args: Quoted, return_type: Type, - return_position: u32, + return_seed: u32, scenario: u32, ) -> (Quoted, Quoted) { if return_type.is_unit() { (quote {}, quote { let _ = $oracle($args); }) } else { let why = f"return serialization mismatch for {oracle}. Consider bumping the oracle version after fixing the mismatch"; - let (return_binding, ret_value) = bind_scenario(return_type, quote { __ret }, return_position, scenario); - (return_binding, quote { assert_eq($oracle($args), $ret_value, $why); }) + let setup = set_scenario_expr(return_type, scenario); + let return_seed_expr = f"{return_seed}".quoted_contents(); + let expected = value_expr(return_type, return_seed_expr, scenario); + (setup, quote { assert_eq($oracle($args), $expected, $why); }) } } -/// The number of scenarios a type exposes. The unit type (an oracle with no return value) counts as a single scenario; -/// every other type declares its count as the `N` in its `OracleTestValue` impl. +/// The number of scenarios a type exposes. The unit type (an oracle with no return value) counts as a single +/// scenario; every other type reports the length of its entry's scenario list. comptime fn num_scenarios(typ: Type) -> u32 { if typ.is_unit() { 1 } else { - let count = std::meta::typ::fresh_type_variable(); - let constraint = quote { crate::macros::oracle_testing::OracleTestValue<$count> }.as_trait_constraint(); - assert( - typ.implements(constraint), - f"cannot build an oracle test value for {typ}: it does not implement OracleTestValue. Implement it, or exclude this oracle via generate_oracle_tests_excluding", - ); - count.as_constant().unwrap() - } -} - -/// Builds the quoted expression that produces `typ`'s scenarios (a `[Scenario; N]`). -comptime fn scenarios_expr(typ: Type, position: u32) -> Quoted { - let seed = SEED_BASE + position; - let seed = f"{seed}".quoted_contents(); - let n = num_scenarios(typ); - let count = f"{n}".quoted_contents(); - let pinned = pinned_type(typ); - quote { - <$pinned as crate::macros::oracle_testing::OracleTestValue<$count>>::oracle_test_value($seed) + scenarios_for(typ, quote { 0 }).len() } } -/// For a multi-scenario type, emits an `add_scenario_to_oracle_test(...)` call passing the selected scenario's name -/// so the resolver knows which shape to expect. Single-scenario types emit nothing. -comptime fn set_scenario(typ: Type, binding: Quoted) -> Quoted { - if num_scenarios(typ) > 1 { - quote { crate::macros::oracle_testing::add_scenario_to_oracle_test($binding.name); } +/// For a multi-scenario position, the statement setting the selected scenario's name on the resolver. +/// Single-scenario positions have unnamed scenarios and set nothing. +comptime fn set_scenario_expr(typ: Type, scenario: u32) -> Quoted { + let scenarios = scenarios_for(typ, quote { 0 }); + let maybe_name = scenarios[scenario % scenarios.len()].name; + if maybe_name.is_some() { + let name = maybe_name.unwrap(); + quote { crate::macros::oracle_testing::set_scenario($name); } } else { quote {} } } +/// Sets the scenario `name` for the session's next oracle call (see [`add_scenario_to_oracle_test`]). +pub(crate) unconstrained fn set_scenario(name: str) { + add_scenario_to_oracle_test(BoundedVec::from_array(name.as_bytes())); +} + /// Sends a scenario name to the resolver for the session's next oracle call. Only the test resolver implements this /// oracle (PXE and TXE do not); the tests `#[generate_oracle_tests]` generates call it once per multi-scenario /// position before the oracle call, passing the selected [`Scenario`]'s name, and the resolver joins the names with /// `+`. #[oracle(aztec_oracle_test_set_scenario)] -pub(crate) unconstrained fn add_scenario_to_oracle_test(_name: BoundedVec) {} +unconstrained fn add_scenario_to_oracle_test(_name: BoundedVec) {} /// The number of elements the test puts in every array and bounded-vec value, and the capacity it pins bounded vecs /// to. These must match `DEFAULT_ARRAY_LENGTH` and `BOUNDED_VEC_MAX_LENGTH` in the TS synthesizer @@ -422,30 +627,7 @@ global TEST_BOUNDED_VEC_CAP: u32 = 5; /// that concretely-typed expected value at the call site. The pinned lengths match [`TEST_COLLECTION_LEN`] / /// [`TEST_BOUNDED_VEC_CAP`] and the TS synthesizer, so both sides build the same wire bytes. comptime fn pinned_type(typ: Type) -> Quoted { - let array = typ.as_array(); - if array.is_some() { - let (element_type, length_type) = array.unwrap(); - let pinned_element = pinned_type(element_type); - // A concrete length (e.g. `[T; 1]`) is part of the oracle's signature and must be kept; only a generic - // length has no value to instantiate and gets pinned. - let len = length_type - .as_constant() - .map(|concrete_length| f"{concrete_length}".quoted_contents()) - .unwrap_or_else(|| f"{TEST_COLLECTION_LEN}".quoted_contents()); - quote { [$pinned_element; $len] } - } else if is_data_type_named(typ, quote { BoundedVec }) { - let element_type = typ.as_data_type().unwrap().1[0]; - let pinned_element = pinned_type(element_type); - let cap = f"{TEST_BOUNDED_VEC_CAP}".quoted_contents(); - quote { BoundedVec<$pinned_element, $cap> } - } else if is_data_type_named(typ, quote { Option }) { - let inner = typ.as_data_type().unwrap().1[0]; - let pinned_inner = pinned_type(inner); - quote { Option<$pinned_inner> } - } else { - // Scalars carry no length to pin. - quote { $typ } - } + (synthesizer_for(typ).pinned)(typ) } /// Whether `typ` is a named data type (struct) whose name is `name`, e.g. `is_data_type_named(typ, quote { Option })`. diff --git a/noir-projects/aztec-nr/aztec/src/messages/delivery/builder.nr b/noir-projects/aztec-nr/aztec/src/messages/delivery/builder.nr index efd92c1019c6..cb8b3591b65b 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/delivery/builder.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/delivery/builder.nr @@ -134,9 +134,10 @@ impl MessageDelivery { /// /// The exception is the discovery tag. Reaching a recipient the sender has not handshaked with before establishes, /// by default, a non-interactive handshake. This reveals to anyone who knows the recipient's address that a - /// handshake was made with them, but not by whom, nor the contents. An interactive handshake would not leak this, - /// but it requires coordination with the recipient. Delivering to one of the wallet's own accounts instead uses an - /// address-derived secret and reveals nothing. + /// handshake was made with them, but not by whom, nor the contents. An interactive handshake + /// ([`OnchainUnconstrainedDelivery::via_interactive_handshake`]) does not leak this, but it requires the + /// recipient's cooperation. Delivering to one of the wallet's own accounts instead uses an address-derived + /// secret and reveals nothing. /// /// Delivering the message does produce on-chain information in the form of private logs, so transactions that /// deliver many messages this way might be identifiable by the large number of logs. @@ -187,7 +188,8 @@ impl MessageDelivery { /// The exception is the discovery tag. Constrained delivery is always backed by a handshake, established by /// default as a non-interactive one the first time the sender reaches the recipient. A non-interactive handshake /// reveals, to anyone who knows the recipient's address, that a handshake was made with them, but not by whom, - /// nor the contents. An interactive handshake would not leak this, but it requires coordination with the recipient. + /// nor the contents. An interactive handshake ([`OnchainConstrainedDelivery::via_interactive_handshake`]) does + /// not leak this, but it requires the recipient's cooperation. /// /// Delivering the message does produce on-chain information in the form of private logs and nullifiers, so /// transactions that deliver many messages this way might be identifiable by these markers. @@ -257,6 +259,18 @@ impl OnchainUnconstrainedDelivery { *self } + /// Derives the discovery tag from an interactive handshake. + /// + /// Reuses an existing handshake for the pair, creating a fresh interactive one only when none exists. + /// + /// Unlike a non-interactive handshake, establishing an interactive one announces nothing onchain, but it requires + /// the recipient's cooperation: they must answer the registry's signed-authorization request, so the send fails + /// if they cannot be reached. + pub fn via_interactive_handshake(&mut self) -> Self { + self.tag_derivation = Option::some(TagDerivation::interactive_handshake()); + *self + } + /// Derives the discovery tag from the address-derived secret for the `(sender, recipient)` pair, established via /// Diffie-Hellman between their addresses. Leaves no on-chain trace and never consults the handshake registry. /// @@ -324,6 +338,19 @@ impl OnchainConstrainedDelivery { self.tag_derivation = Option::some(TagDerivation::non_interactive_handshake()); *self } + + /// Derives the discovery tag from an interactive handshake. + /// + /// Reuses an existing handshake for the pair, creating a fresh interactive one only when none exists. Overrides + /// the wallet's default resolution. + /// + /// Unlike a non-interactive handshake, establishing an interactive one announces nothing onchain, but it requires + /// the recipient's cooperation: they must answer the registry's signed-authorization request, so the send fails + /// if they cannot be reached. + pub fn via_interactive_handshake(&mut self) -> Self { + self.tag_derivation = Option::some(TagDerivation::interactive_handshake()); + *self + } } impl MessageDeliveryBuilder for OnchainConstrainedDelivery { @@ -402,5 +429,13 @@ mod test { let constrained_delivery = MessageDelivery::onchain_constrained().via_non_interactive_handshake().build_message_delivery(); assert_eq(constrained_delivery.tag_derivation(), Option::some(TagDerivation::non_interactive_handshake())); + + let unconstrained_interactive = + MessageDelivery::onchain_unconstrained().via_interactive_handshake().build_message_delivery(); + assert_eq(unconstrained_interactive.tag_derivation(), Option::some(TagDerivation::interactive_handshake())); + + let constrained_interactive = + MessageDelivery::onchain_constrained().via_interactive_handshake().build_message_delivery(); + assert_eq(constrained_interactive.tag_derivation(), Option::some(TagDerivation::interactive_handshake())); } } diff --git a/noir-projects/aztec-nr/aztec/src/messages/delivery/handshake.nr b/noir-projects/aztec-nr/aztec/src/messages/delivery/handshake.nr index d0cddc1d93ae..d0e8276e3716 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/delivery/handshake.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/delivery/handshake.nr @@ -52,6 +52,8 @@ pub global GET_APP_SILOED_SECRETS_SELECTOR: FunctionSelector = comptime { FunctionSelector::from_signature("get_app_siloed_secrets((Field),(Field))") }; pub global NON_INTERACTIVE_HANDSHAKE_SELECTOR: FunctionSelector = comptime { FunctionSelector::from_signature("non_interactive_handshake((Field),(Field))") }; +pub global INTERACTIVE_HANDSHAKE_SELECTOR: FunctionSelector = + comptime { FunctionSelector::from_signature("interactive_handshake((Field),(Field))") }; pub global GET_NON_INTERACTIVE_HANDSHAKES_SELECTOR: FunctionSelector = comptime { FunctionSelector::from_signature("get_non_interactive_handshakes((Field),u32)") }; @@ -82,7 +84,7 @@ pub(crate) unconstrained fn get_existing_app_siloed_handshake_secrets( /// /// The registry inserts a fresh handshake note and returns the app-siloed secrets. The constrained return value is the /// source of truth for the secrets, so constrained delivery anchoring a freshly bootstrapped handshake needs no -/// separate `validate_handshake`. +/// separate call to the registry's `validate_handshake`. /// /// Any handshake already registered for the pair is overwritten. pub(crate) fn create_non_interactive_handshake( @@ -91,13 +93,29 @@ pub(crate) fn create_non_interactive_handshake( sender: AztecAddress, recipient: AztecAddress, ) -> AppSiloedHandshakeSecrets { - // TODO(F-660): dispatch to `perform_handshake(sender, recipient, handshake_type)` once interactive handshakes - // are supported. context .call_private_function(registry, NON_INTERACTIVE_HANDSHAKE_SELECTOR, [sender.to_field(), recipient.to_field()]) .get_preimage() } +/// Establishes an interactive handshake for `(sender, recipient)` and returns its app-siloed secrets. +/// +/// As with [`create_non_interactive_handshake`], the constrained return value is the source of truth for the +/// secrets, so constrained delivery anchoring a freshly bootstrapped handshake needs no separate call to the +/// registry's `validate_handshake`. +/// +/// Any handshake already registered for the pair is overwritten. +pub(crate) fn create_interactive_handshake( + context: &mut PrivateContext, + registry: AztecAddress, + sender: AztecAddress, + recipient: AztecAddress, +) -> AppSiloedHandshakeSecrets { + context + .call_private_function(registry, INTERACTIVE_HANDSHAKE_SELECTOR, [sender.to_field(), recipient.to_field()]) + .get_preimage() +} + /// 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. diff --git a/noir-projects/aztec-nr/aztec/src/messages/delivery/resolved_tagging_strategy.nr b/noir-projects/aztec-nr/aztec/src/messages/delivery/resolved_tagging_strategy.nr index e6af56261a6c..71fa0ef5417b 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/delivery/resolved_tagging_strategy.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/delivery/resolved_tagging_strategy.nr @@ -4,6 +4,7 @@ use super::tag_secret_source::TagSecretSource; global NON_INTERACTIVE_HANDSHAKE: u8 = 1; global UNCONSTRAINED_SECRET: u8 = 2; +global INTERACTIVE_HANDSHAKE: u8 = 3; /// The tagging secret the PXE hands to the contract after applying the wallet's tagging secret strategy. It is already /// app-siloed, so the contract uses it directly to derive the message tag and never sees raw key material. @@ -24,6 +25,11 @@ impl ResolvedTaggingStrategy { Self { kind: UNCONSTRAINED_SECRET, secret } } + /// An interactive handshake: the recipient learns the secret while signing its authorization. + pub fn interactive_handshake() -> Self { + Self { kind: INTERACTIVE_HANDSHAKE, secret: 0 } + } + pub fn is_unconstrained_secret(self) -> bool { self.kind == UNCONSTRAINED_SECRET } @@ -37,6 +43,8 @@ impl ResolvedTaggingStrategy { ) -> TagSecretSource { if self.kind == NON_INTERACTIVE_HANDSHAKE { TagSecretSource::new_non_interactive_handshake(context, sender, recipient) + } else if self.kind == INTERACTIVE_HANDSHAKE { + TagSecretSource::new_interactive_handshake(context, sender, recipient) } else { TagSecretSource::unconstrained_secret(self.secret) } @@ -46,7 +54,8 @@ impl ResolvedTaggingStrategy { fn from_parts(kind: u8, secret: Field) -> Self { let resolved = Self { kind, secret }; assert( - ((kind == NON_INTERACTIVE_HANDSHAKE) & (secret == 0)) | (kind == UNCONSTRAINED_SECRET), + (((kind == NON_INTERACTIVE_HANDSHAKE) | (kind == INTERACTIVE_HANDSHAKE)) & (secret == 0)) + | (kind == UNCONSTRAINED_SECRET), f"unrecognized resolved tagging strategy kind: {kind}", ); resolved @@ -75,9 +84,11 @@ mod test { fn resolved_roundtrips_through_serialization() { let non_interactive = ResolvedTaggingStrategy::non_interactive_handshake(); let unconstrained_secret = ResolvedTaggingStrategy::unconstrained_secret(42); + let interactive = ResolvedTaggingStrategy::interactive_handshake(); assert(ResolvedTaggingStrategy::deserialize(non_interactive.serialize()) == non_interactive); assert(ResolvedTaggingStrategy::deserialize(unconstrained_secret.serialize()) == unconstrained_secret); + assert(ResolvedTaggingStrategy::deserialize(interactive.serialize()) == interactive); } #[test(should_fail_with = "unrecognized resolved tagging strategy kind")] @@ -89,4 +100,9 @@ mod test { fn deserializing_handshake_with_nonzero_secret_fails() { let _ = ResolvedTaggingStrategy::deserialize([1, 123]); } + + #[test(should_fail_with = "unrecognized resolved tagging strategy kind")] + fn deserializing_interactive_handshake_with_nonzero_secret_fails() { + let _ = ResolvedTaggingStrategy::deserialize([3, 123]); + } } diff --git a/noir-projects/aztec-nr/aztec/src/messages/delivery/tag_derivation.nr b/noir-projects/aztec-nr/aztec/src/messages/delivery/tag_derivation.nr index d2090a2f3f45..104d8afea3b3 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/delivery/tag_derivation.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/delivery/tag_derivation.nr @@ -9,6 +9,7 @@ use crate::standard_addresses::STANDARD_HANDSHAKE_REGISTRY_ADDRESS; global NON_INTERACTIVE_HANDSHAKE: u8 = 1; global ADDRESS_DERIVED: u8 = 2; +global INTERACTIVE_HANDSHAKE: u8 = 3; /// A contract's choice of tag-secret derivation. /// @@ -23,11 +24,16 @@ pub(crate) struct TagDerivation { } impl TagDerivation { - /// Reuses an existing non-interactive handshake for the pair, creating a fresh one only when none exists. + /// Reuses an existing handshake for the pair, creating a fresh non-interactive one only when none exists. pub(crate) fn non_interactive_handshake() -> Self { Self { kind: NON_INTERACTIVE_HANDSHAKE } } + /// Reuses an existing handshake for the pair, creating a fresh interactive one only when none exists. + pub(crate) fn interactive_handshake() -> Self { + Self { kind: INTERACTIVE_HANDSHAKE } + } + /// Derives the tag from the address-derived secret for the `(sender, recipient)` pair (a Diffie-Hellman exchange /// between their addresses). pub(crate) fn address_derived() -> Self { @@ -52,6 +58,12 @@ impl TagDerivation { recipient, || TagSecretSource::new_non_interactive_handshake(context, sender, recipient), ) + } else if self.kind == INTERACTIVE_HANDSHAKE { + existing_handshake_secrets_or_else( + sender, + recipient, + || TagSecretSource::new_interactive_handshake(context, sender, recipient), + ) } else { // Safety: the secret is untrusted, but address-derived tagging is unconstrained-only, where a wrong // secret only yields an undiscoverable tag. An invalid recipient has no shared secret, so we fall back @@ -62,8 +74,8 @@ impl TagDerivation { } } -/// Resolves to the secrets of a non-interactive handshake already registered for `(sender, recipient)`, or to -/// `fallback()` when none exists yet. +/// Resolves to the secrets of a handshake already registered for `(sender, recipient)` (handshakes of either type +/// back both delivery modes), or to `fallback()` when none exists yet. pub(crate) fn existing_handshake_secrets_or_else( sender: AztecAddress, recipient: AztecAddress, @@ -145,11 +157,38 @@ mod test { }); } + #[test] + unconstrained fn interactive_handshake_reuses_an_existing_handshake() { + let env = TestEnvironment::new(); + let secrets = AppSiloedHandshakeSecrets { shared: 7, sender_only: 99 }; + + env.private_context(|context| { + mock_existing_handshake_secrets(Option::some(secrets)); + + let source = TagDerivation::interactive_handshake().into_tag_secret_source(context, SENDER, RECIPIENT); + assert_eq(source, TagSecretSource::existing_handshake(secrets)); + }); + } + + #[test] + unconstrained fn interactive_handshake_creates_a_fresh_one_when_none_exists() { + let env = TestEnvironment::new(); + let secrets = AppSiloedHandshakeSecrets { shared: 7, sender_only: 99 }; + + env.private_context(|context| { + mock_existing_handshake_secrets(Option::none()); + mock_handshake_creation(context, secrets); + + let source = TagDerivation::interactive_handshake().into_tag_secret_source(context, SENDER, RECIPIENT); + assert_eq(source.tag_secret(), secrets.shared); + }); + } + unconstrained fn mock_existing_handshake_secrets(maybe_secrets: Option) { let _ = OracleMock::mock("aztec_utl_callUtilityFunction").returns(maybe_secrets.serialize()); } - // The registry's `non_interactive_handshake` call returns the app-siloed secrets via the returns cache. + // The registry's handshake creation call returns the app-siloed secrets via the returns cache. unconstrained fn mock_handshake_creation(context: &mut PrivateContext, secrets: AppSiloedHandshakeSecrets) { let returns = secrets.serialize(); let returns_hash = hash_args(returns); diff --git a/noir-projects/aztec-nr/aztec/src/messages/delivery/tag_secret_source.nr b/noir-projects/aztec-nr/aztec/src/messages/delivery/tag_secret_source.nr index 5ea04faecfbb..9bef8c7a9a75 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/delivery/tag_secret_source.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/delivery/tag_secret_source.nr @@ -1,7 +1,7 @@ use crate::context::PrivateContext; use crate::messages::delivery::{ constrained_delivery::{constrain_preexisting_handshake_secrets, emit_sequence_nullifier}, - handshake::{AppSiloedHandshakeSecrets, create_non_interactive_handshake}, + handshake::{AppSiloedHandshakeSecrets, create_interactive_handshake, create_non_interactive_handshake}, }; use crate::protocol::address::AztecAddress; use crate::standard_addresses::STANDARD_HANDSHAKE_REGISTRY_ADDRESS; @@ -9,6 +9,7 @@ use crate::standard_addresses::STANDARD_HANDSHAKE_REGISTRY_ADDRESS; global EXISTING_HANDSHAKE: u8 = 1; global NEW_NON_INTERACTIVE_HANDSHAKE: u8 = 2; global UNCONSTRAINED_SECRET: u8 = 3; +global NEW_INTERACTIVE_HANDSHAKE: u8 = 4; /// How a message's tag secret is sourced and, for constrained delivery, constrained. #[derive(Eq)] @@ -42,6 +43,21 @@ impl TagSecretSource { Self { kind: NEW_NON_INTERACTIVE_HANDSHAKE, tag_secret: secrets.shared, sender_only: secrets.sender_only } } + /// Establishes a fresh interactive handshake, publishing nothing about the recipient onchain. + pub(crate) fn new_interactive_handshake( + context: &mut PrivateContext, + sender: AztecAddress, + recipient: AztecAddress, + ) -> Self { + let secrets = create_interactive_handshake( + context, + STANDARD_HANDSHAKE_REGISTRY_ADDRESS, + sender, + recipient, + ); + Self { kind: NEW_INTERACTIVE_HANDSHAKE, tag_secret: secrets.shared, sender_only: secrets.sender_only } + } + /// A ready-to-use unconstrained secret the wallet resolved. Sound only for unconstrained delivery, which never /// anchors a sequence, so no sender-only secret is involved. pub(crate) fn unconstrained_secret(secret: Field) -> Self { @@ -73,7 +89,7 @@ impl TagSecretSource { secrets, index, ); - } else if self.kind == NEW_NON_INTERACTIVE_HANDSHAKE { + } else if (self.kind == NEW_NON_INTERACTIVE_HANDSHAKE) | (self.kind == NEW_INTERACTIVE_HANDSHAKE) { // The secrets were already obtained in a constrained manner, so we only need to verify that the index is 0. assert(index == 0, "freshly bootstrapped secret must start at index 0"); } else { @@ -145,6 +161,22 @@ mod test { }); } + #[test] + unconstrained fn new_interactive_handshake_creates_and_constrains_the_bootstrapped_secrets() { + let env = TestEnvironment::new(); + let secrets = AppSiloedHandshakeSecrets { shared: 12, sender_only: 34 }; + + env.private_context(|context| { + mock_handshake_creation(context, secrets); + + let source = TagSecretSource::new_interactive_handshake(context, SENDER, RECIPIENT); + assert_eq(source.tag_secret(), secrets.shared); + + source.constrain_tag_secret(context, SENDER, RECIPIENT, 0); + assert_emitted_sequence_nullifier(context, secrets, 0); + }); + } + // A freshly bootstrapped handshake secret is the source of truth, so its sequence must start at index 0. #[test(should_fail_with = "freshly bootstrapped secret must start at index 0")] unconstrained fn fresh_handshake_secret_must_start_at_index_zero() { @@ -164,6 +196,24 @@ mod test { }); } + #[test(should_fail_with = "freshly bootstrapped secret must start at index 0")] + unconstrained fn fresh_interactive_handshake_secret_must_start_at_index_zero() { + let env = TestEnvironment::new(); + env.private_context(|context| { + mock_handshake_creation( + context, + AppSiloedHandshakeSecrets { shared: 0, sender_only: 0 }, + ); + + TagSecretSource::new_interactive_handshake(context, SENDER, RECIPIENT).constrain_tag_secret( + context, + SENDER, + RECIPIENT, + 1, + ); + }); + } + #[test(should_fail_with = "an unconstrained tagging secret cannot back constrained delivery")] unconstrained fn unconstrained_secret_cannot_back_constrained_delivery() { let env = TestEnvironment::new(); @@ -189,7 +239,7 @@ mod test { ); } - // The registry's `non_interactive_handshake` call returns the app-siloed secrets via the returns cache. + // The registry's handshake creation call returns the app-siloed secrets via the returns cache. unconstrained fn mock_handshake_creation(context: &mut PrivateContext, secrets: AppSiloedHandshakeSecrets) { let returns = secrets.serialize(); let returns_hash = hash_args(returns); diff --git a/noir-projects/aztec-nr/aztec/src/oracle/execution.nr b/noir-projects/aztec-nr/aztec/src/oracle/execution.nr index 120d6da53b8e..9d903f266a68 100644 --- a/noir-projects/aztec-nr/aztec/src/oracle/execution.nr +++ b/noir-projects/aztec-nr/aztec/src/oracle/execution.nr @@ -1,10 +1,20 @@ use crate::context::UtilityContext; +use crate::protocol::{abis::block_header::BlockHeader, address::AztecAddress}; + +/// Wire shape of [`get_utility_context_oracle`]'s response. [`UtilityContext`] is built from it rather than returned +/// directly so its fields stay private to its module. +#[derive(Eq)] +pub(crate) struct UtilityContextData { + pub(crate) block_header: BlockHeader, + pub(crate) contract_address: AztecAddress, + pub(crate) msg_sender: AztecAddress, +} #[oracle(aztec_utl_getUtilityContext)] -unconstrained fn get_utility_context_oracle() -> UtilityContext {} +unconstrained fn get_utility_context_oracle() -> UtilityContextData {} /// Returns a utility context built from the global variables of anchor block and the contract address of the function /// being executed. pub unconstrained fn get_utility_context() -> UtilityContext { - get_utility_context_oracle() + UtilityContext::from(get_utility_context_oracle()) } diff --git a/noir-projects/aztec-nr/aztec/src/oracle/mod.nr b/noir-projects/aztec-nr/aztec/src/oracle/mod.nr index 07892bff88fd..3fe750eb3e10 100644 --- a/noir-projects/aztec-nr/aztec/src/oracle/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/oracle/mod.nr @@ -16,16 +16,25 @@ use crate::macros::oracle_testing::{generate_oracle_tests, generate_oracle_tests quote { calldata_copy_opcode }, quote { return_opcode }, quote { revert_opcode }, - // TODO: implement once we support more complex types + // TODO: implement once we support slices quote { emit_public_log_opcode }, quote { returndata_copy_opcode }, ], )] pub mod avm; +#[generate_oracle_tests_excluding( + @[ + // TODO: cover once the iv/sym_key BUFFER mapping is expressible as a plain fixed array of bytes, so the + // fixture synthesizer does not need to special-case it. + quote { aes128_decrypt_oracle }, + ], +)] pub mod aes128_decrypt; #[generate_oracle_tests] pub mod auth_witness; +#[generate_oracle_tests] pub mod block_header; +#[generate_oracle_tests] pub mod call_private_function; #[generate_oracle_tests] pub mod call_utility_function; @@ -39,45 +48,71 @@ pub(crate) mod transient_oracles; pub mod contract_sync; #[generate_oracle_tests_excluding( @[ - quote { record_fact_oracle }, // TODO: implement once we support more complex types - quote { get_fact_collection_oracle }, // TODO: implement once we support more complex types - quote { get_fact_collections_by_type_oracle }, // TODO: implement once we support more complex types + // TODO: implement once the test resolver can serve EphemeralArray contents. + quote { record_fact_oracle }, + quote { get_fact_collection_oracle }, + quote { get_fact_collections_by_type_oracle }, ], )] pub mod fact_store; #[generate_oracle_tests] pub mod public_call; +// TODO: cover with oracle tests once the test resolver can serve EphemeralArray contents. pub mod tx_resolution; #[generate_oracle_tests] pub mod resolve_custom_request; #[generate_oracle_tests_excluding( @[ - quote { resolve_tagging_strategy_oracle }, // TODO: implement once we support more complex types + // TODO: needs a hand-written multi-scenario synthesizer entry (enum-like type with private fields, + // one named scenario per constructor) on both sides. + quote { resolve_tagging_strategy_oracle }, ], )] pub mod resolve_tagging_strategy; #[generate_oracle_tests] pub mod tx_phase; +#[generate_oracle_tests] pub mod execution; #[generate_oracle_tests] pub mod execution_cache; +#[generate_oracle_tests] +pub mod get_contract_instance; #[generate_oracle_tests_excluding( @[ - quote { get_contract_instance_oracle }, // TODO: implement once we support more complex types + // TODO: cover once the sibling-path mapping is expressible as a plain fixed array + quote { get_l1_to_l2_membership_witness_oracle }, ], )] -pub mod get_contract_instance; pub mod get_l1_to_l2_membership_witness; +#[generate_oracle_tests_excluding( + @[ + // TODO: cover once the sibling-path mapping is expressible as a plain fixed array + quote { get_low_nullifier_membership_witness_oracle }, + quote { get_nullifier_membership_witness_oracle }, + ], +)] pub mod get_nullifier_membership_witness; +#[generate_oracle_tests_excluding( + @[ + // TODO: cover once the sibling-path mapping is expressible as a plain fixed array + quote { get_public_data_witness_oracle }, + ], +)] pub mod get_public_data_witness; +#[generate_oracle_tests] pub mod get_membership_witness; +#[generate_oracle_tests] pub mod keys; +#[generate_oracle_tests] pub mod key_validation_request; +// TODO: cover with oracle tests once the two sides agree on the param grouping (TS wraps them in a single struct). pub mod logs; +// TODO: cover with oracle tests once the test resolver can serve EphemeralArray contents. pub mod message_processing; #[generate_oracle_tests_excluding( @[ - quote { get_notes_oracle }, // TODO: implement once we support more complex types + // TODO: implement once the test resolver can serve EphemeralArray contents. + quote { get_notes_oracle }, ], )] pub mod notes; @@ -89,6 +124,7 @@ pub mod offchain_effect; pub mod version; #[generate_oracle_tests] pub mod random; +// TODO: cover with oracle tests once the test resolver can serve EphemeralArray contents. pub mod shared_secret; #[generate_oracle_tests] pub mod storage; diff --git a/noir-projects/aztec-nr/aztec/src/standard_addresses.nr b/noir-projects/aztec-nr/aztec/src/standard_addresses.nr index 6ed389df503e..28d43ef6fbc9 100644 --- a/noir-projects/aztec-nr/aztec/src/standard_addresses.nr +++ b/noir-projects/aztec-nr/aztec/src/standard_addresses.nr @@ -2,17 +2,17 @@ use protocol_types::{address::AztecAddress, traits::FromField}; pub global STANDARD_AUTH_REGISTRY_ADDRESS: AztecAddress = AztecAddress::from_field( - 0x0bbe366bb57059b03929228f7e886b17210cf268e021608392f58cb8f2574f43, + 0x0292b01f4e566555534c40ed729eb023cc08afaca647b8c3642738449673480c, ); pub global STANDARD_MULTI_CALL_ENTRYPOINT_ADDRESS: AztecAddress = AztecAddress::from_field( - 0x252e4f53e6bef46e70a37168c52a9eba7501944a40c63f0aa807cf2f823260d9, + 0x18470168ca7a775b442cde110b668732f7b6390a505f3784b50f39993c5f3dce, ); pub global STANDARD_PUBLIC_CHECKS_ADDRESS: AztecAddress = AztecAddress::from_field( - 0x05c207d39f7a51de9de6cfd37ab7764bbc9d0434481c4448fc6ddbeac2f67636, + 0x2d7745526508f5ceb278bb773a49f776842d1d2f0854c4a385bb661a2fabb9a8, ); pub global STANDARD_HANDSHAKE_REGISTRY_ADDRESS: AztecAddress = AztecAddress::from_field( - 0x0fe4285eada74ef13ea09468db7fcb69861161276f81f24a99e152e5871c5081, + 0x0bd62e76a9a3a103dae2e73040e05d3970ac900baff5637ae9f183396745ec7e, ); diff --git a/noir-projects/aztec-nr/aztec/src/test/helpers/tagging_secret_strategy.nr b/noir-projects/aztec-nr/aztec/src/test/helpers/tagging_secret_strategy.nr index a4f7b52d91ba..93f4baa0b0c8 100644 --- a/noir-projects/aztec-nr/aztec/src/test/helpers/tagging_secret_strategy.nr +++ b/noir-projects/aztec-nr/aztec/src/test/helpers/tagging_secret_strategy.nr @@ -3,6 +3,7 @@ use crate::protocol::{point::EmbeddedCurvePoint, traits::{Deserialize, Serialize global NON_INTERACTIVE_HANDSHAKE: u8 = 1; global ARBITRARY_SECRET: u8 = 2; global ADDRESS_DERIVED: u8 = 3; +global INTERACTIVE_HANDSHAKE: u8 = 4; /// How a message's tagging secret is chosen: the wallet's strategy. /// @@ -22,6 +23,11 @@ impl TaggingSecretStrategy { Self { kind: NON_INTERACTIVE_HANDSHAKE, secret: EmbeddedCurvePoint { x: 0, y: 0 } } } + /// A secret established through an interactive handshake. + pub fn interactive_handshake() -> Self { + Self { kind: INTERACTIVE_HANDSHAKE, secret: EmbeddedCurvePoint { x: 0, y: 0 } } + } + /// A secret derived from the sender's and recipient's address keys via Diffie-Hellman. /// /// The PXE performs the key exchange, so the wallet supplies no material. @@ -40,7 +46,10 @@ impl TaggingSecretStrategy { fn from_parts(kind: u8, secret: EmbeddedCurvePoint) -> Self { let strategy = Self { kind, secret }; assert( - (((kind == NON_INTERACTIVE_HANDSHAKE) | (kind == ADDRESS_DERIVED)) & secret.is_infinite()) + ( + ((kind == NON_INTERACTIVE_HANDSHAKE) | (kind == ADDRESS_DERIVED) | (kind == INTERACTIVE_HANDSHAKE)) + & secret.is_infinite() + ) | (kind == ARBITRARY_SECRET), f"unrecognized tagging secret strategy kind: {kind}", ); @@ -74,10 +83,12 @@ mod test { let non_interactive = TaggingSecretStrategy::non_interactive_handshake(); let address = TaggingSecretStrategy::address_derived(); let provided = TaggingSecretStrategy::arbitrary_secret(EmbeddedCurvePoint { x: 7, y: 11 }); + let interactive = TaggingSecretStrategy::interactive_handshake(); assert(TaggingSecretStrategy::deserialize(non_interactive.serialize()) == non_interactive); assert(TaggingSecretStrategy::deserialize(address.serialize()) == address); assert(TaggingSecretStrategy::deserialize(provided.serialize()) == provided); + assert(TaggingSecretStrategy::deserialize(interactive.serialize()) == interactive); } #[test(should_fail_with = "unrecognized tagging secret strategy kind")] @@ -94,4 +105,9 @@ mod test { fn deserializing_address_derived_with_noninfinite_point_fails() { let _ = TaggingSecretStrategy::deserialize([3, 7, 11]); } + + #[test(should_fail_with = "unrecognized tagging secret strategy kind")] + fn deserializing_interactive_handshake_with_noninfinite_point_fails() { + let _ = TaggingSecretStrategy::deserialize([4, 7, 11]); + } } diff --git a/noir-projects/aztec-nr/aztec/src/test/helpers/test_environment/test/resolve_tagging_strategy.nr b/noir-projects/aztec-nr/aztec/src/test/helpers/test_environment/test/resolve_tagging_strategy.nr index 2d08d3e96664..b02994d2e6ca 100644 --- a/noir-projects/aztec-nr/aztec/src/test/helpers/test_environment/test/resolve_tagging_strategy.nr +++ b/noir-projects/aztec-nr/aztec/src/test/helpers/test_environment/test/resolve_tagging_strategy.nr @@ -86,6 +86,25 @@ unconstrained fn constrained_delivery_succeeds_with_a_configured_strategy() { }); } +#[test] +unconstrained fn applies_a_configured_interactive_handshake_strategy() { + let mut env = TestEnvironment::new_opts(TestEnvironmentOptions::new().with_tagging_secret_strategy( + TaggingSecretStrategy::interactive_handshake(), + )); + // The hook path looks up the executing contract's class ID, so run in a deployed contract's context. + let app = env.create_contract_account(); + let recipient = AztecAddress::from_field(8); + + env.private_context_at(app, |_| { + let resolved = resolve_tagging_strategy( + AztecAddress::from_field(1), + recipient, + OnchainDeliveryMode::onchain_constrained(), + ); + assert_eq(resolved, ResolvedTaggingStrategy::interactive_handshake()); + }); +} + #[test] unconstrained fn applies_the_strategy_set_in_the_options() { let mut env = TestEnvironment::new_opts(TestEnvironmentOptions::new().with_tagging_secret_strategy( diff --git a/noir-projects/aztec-nr/aztec/src/test/helpers/txe_oracles.nr b/noir-projects/aztec-nr/aztec/src/test/helpers/txe_oracles.nr index c388db5e56cb..ad6a98ffcaae 100644 --- a/noir-projects/aztec-nr/aztec/src/test/helpers/txe_oracles.nr +++ b/noir-projects/aztec-nr/aztec/src/test/helpers/txe_oracles.nr @@ -22,7 +22,7 @@ use crate::protocol::{ /// /// The TypeScript counterparts are in `yarn-project/txe/src/txe_oracle_version.ts`. pub global TXE_ORACLE_VERSION_MAJOR: Field = 2; -pub global TXE_ORACLE_VERSION_MINOR: Field = 2; +pub global TXE_ORACLE_VERSION_MINOR: Field = 3; /// Asserts that the TXE oracle interface version is compatible. pub unconstrained fn assert_compatible_txe_oracle_version() { diff --git a/noir-projects/noir-contracts/Nargo.toml b/noir-projects/noir-contracts/Nargo.toml index 0692eebc6bc6..2fa302c83073 100644 --- a/noir-projects/noir-contracts/Nargo.toml +++ b/noir-projects/noir-contracts/Nargo.toml @@ -46,7 +46,7 @@ members = [ "contracts/test/benchmarking_contract", "contracts/test/calldata_limit_test_contract", "contracts/test/child_contract", - "contracts/test/constrained_delivery_test_contract", + "contracts/test/onchain_delivery_test_contract", "contracts/test/counter/counter_contract", "contracts/test/custom_message_contract", "contracts/test/custom_sync_state_contract", diff --git a/noir-projects/noir-contracts/contracts/standard/handshake_registry_contract/src/handshake_note.nr b/noir-projects/noir-contracts/contracts/standard/handshake_registry_contract/src/handshake_note.nr index 99b6d6e6096a..580b783cc77f 100644 --- a/noir-projects/noir-contracts/contracts/standard/handshake_registry_contract/src/handshake_note.nr +++ b/noir-projects/noir-contracts/contracts/standard/handshake_registry_contract/src/handshake_note.nr @@ -19,12 +19,6 @@ use aztec::{ #[derive(Deserialize, Eq, Packable, Serialize)] #[note] pub struct HandshakeNote { - /// Stored so contracts can constrain the kind of handshake they accept (e.g. an app may want to require interactive - /// handshakes only). - handshake_type: u8, - /// The recipient this handshake authorizes. Part of the note preimage so a contract proving note - /// existence can bind the proof to the intended recipient. - recipient: AztecAddress, /// The raw ECDH shared-secret point `S = eph_sk * recipient_address_point`. Only this module can read it for /// siloing, and it is never returned by an external function. secret: EmbeddedCurvePoint, @@ -36,13 +30,8 @@ pub struct HandshakeNote { } impl HandshakeNote { - pub(crate) fn new( - shared_secret: EmbeddedCurvePoint, - handshake_type: u8, - recipient: AztecAddress, - sender_secret: Field, - ) -> Self { - Self { handshake_type, recipient, secret: shared_secret, sender_secret } + pub(crate) fn new(shared_secret: EmbeddedCurvePoint, sender_secret: Field) -> Self { + Self { secret: shared_secret, sender_secret } } /// Returns the [app-siloed handshake secrets][AppSiloedHandshakeSecrets] for `caller`: the shared secret (via diff --git a/noir-projects/noir-contracts/contracts/standard/handshake_registry_contract/src/main.nr b/noir-projects/noir-contracts/contracts/standard/handshake_registry_contract/src/main.nr index 08ba4544cd02..87259c082726 100644 --- a/noir-projects/noir-contracts/contracts/standard/handshake_registry_contract/src/main.nr +++ b/noir-projects/noir-contracts/contracts/standard/handshake_registry_contract/src/main.nr @@ -6,14 +6,6 @@ mod test; use sync::handshake_registry_sync; -/// Handshake type identifier for non-interactive handshakes (sender-generated random shared secret, no recipient -/// signature). See [`HandshakeRegistry::non_interactive_handshake`]. -global NON_INTERACTIVE_HANDSHAKE: u8 = 1; - -/// Handshake type identifier for interactive handshakes (recipient-signed). See -/// [`HandshakeRegistry::interactive_handshake`]. -global INTERACTIVE_HANDSHAKE: u8 = 2; - /// Registry for the message-delivery handshake protocol. /// /// The registry establishes a master shared-secret point `S` between a sender and a recipient and stores one current @@ -34,8 +26,8 @@ global INTERACTIVE_HANDSHAKE: u8 = 2; #[aztec(::aztec::macros::AztecConfig::new().custom_sync_state(crate::handshake_registry_sync))] pub contract HandshakeRegistry { use crate::{ - handshake_note::HandshakeNote, INTERACTIVE_HANDSHAKE, NON_INTERACTIVE_HANDSHAKE, - recipient_signature::RecipientSignature, sync::discovered_handshakes::get_all_discovered_handshakes, + handshake_note::HandshakeNote, recipient_signature::RecipientSignature, + sync::discovered_handshakes::get_all_discovered_handshakes, }; use aztec::{ keys::{ecdh_shared_secret::derive_ecdh_shared_secret, ephemeral::generate_positive_ephemeral_key_pair}, @@ -86,7 +78,7 @@ pub contract HandshakeRegistry { #[external("private")] fn non_interactive_handshake(sender: AztecAddress, recipient: AztecAddress) -> AppSiloedHandshakeSecrets { let (eph_pk, s_raw) = self.internal._generate_shared_secret(recipient); - let secrets = self.internal._store(sender, recipient, s_raw, NON_INTERACTIVE_HANDSHAKE); + let secrets = self.internal._store(sender, recipient, s_raw); // Announce the handshake: an encrypted private log under the recipient-keyed tag carrying `[eph_pk.x]`, so // the recipient can discover the handshake by scanning their tag and recover `S` via their own ECDH. @@ -118,7 +110,7 @@ pub contract HandshakeRegistry { fn interactive_handshake(sender: AztecAddress, recipient: AztecAddress) -> AppSiloedHandshakeSecrets { let (eph_pk, s_raw) = self.internal._generate_shared_secret(recipient); self.internal._request_and_verify_signature(recipient, eph_pk); - self.internal._store(sender, recipient, s_raw, INTERACTIVE_HANDSHAKE) + self.internal._store(sender, recipient, s_raw) } /// Asserts that `secrets` match the stored handshake from `sender` to `recipient`, for the caller (`msg_sender()`). @@ -211,17 +203,12 @@ pub contract HandshakeRegistry { /// Inserts or replaces the current [`HandshakeNote`] owned by `sender` for `recipient` and returns the /// app-siloed secrets for the caller (`msg_sender()`). #[internal("private")] - fn _store( - sender: AztecAddress, - recipient: AztecAddress, - s_raw: EmbeddedCurvePoint, - handshake_type: u8, - ) -> AppSiloedHandshakeSecrets { + fn _store(sender: AztecAddress, recipient: AztecAddress, s_raw: EmbeddedCurvePoint) -> AppSiloedHandshakeSecrets { // Safety: an uncooperative sender could pick a non-random value, but the sender-only secret only protects // the sender's own ability to advance their sequence, so a bad value only harms themselves. It is a fresh // random secret known only to the sender, which will be folded into constrained-delivery nullifiers. let sender_secret = unsafe { random() }; - let note = HandshakeNote::new(s_raw, handshake_type, recipient, sender_secret); + let note = HandshakeNote::new(s_raw, sender_secret); // We deliver onchain unconstrained rather than offchain so the note is discoverable via normal PXE sync. This // is a self-send (the note is owned by and delivered to `sender`), so the wallet would resolve to an diff --git a/noir-projects/noir-contracts/contracts/standard/handshake_registry_contract/src/test.nr b/noir-projects/noir-contracts/contracts/standard/handshake_registry_contract/src/test.nr index 09e4aebcc03e..72e8280e1749 100644 --- a/noir-projects/noir-contracts/contracts/standard/handshake_registry_contract/src/test.nr +++ b/noir-projects/noir-contracts/contracts/standard/handshake_registry_contract/src/test.nr @@ -7,7 +7,7 @@ use aztec::{ constrained_delivery::VALIDATE_HANDSHAKE_SELECTOR, handshake::{ AppSiloedHandshakeSecrets, GET_APP_SILOED_SECRETS_SELECTOR, GET_NON_INTERACTIVE_HANDSHAKES_SELECTOR, - MAX_HANDSHAKES_PER_PAGE, NON_INTERACTIVE_HANDSHAKE_SELECTOR, + INTERACTIVE_HANDSHAKE_SELECTOR, MAX_HANDSHAKES_PER_PAGE, NON_INTERACTIVE_HANDSHAKE_SELECTOR, }, }, oracle::shared_secret::get_shared_secret, @@ -58,6 +58,7 @@ unconstrained fn selectors_match_the_constrained_delivery_helper() { assert_eq(registry.get_app_siloed_secrets(sender, recipient).selector, GET_APP_SILOED_SECRETS_SELECTOR); assert_eq(registry.non_interactive_handshake(sender, recipient).selector, NON_INTERACTIVE_HANDSHAKE_SELECTOR); + assert_eq(registry.interactive_handshake(sender, recipient).selector, INTERACTIVE_HANDSHAKE_SELECTOR); assert_eq(registry.validate_handshake(sender, recipient, secrets).selector, VALIDATE_HANDSHAKE_SELECTOR); assert_eq(registry.get_non_interactive_handshakes(recipient, 0).selector, GET_NON_INTERACTIVE_HANDSHAKES_SELECTOR); } diff --git a/noir-projects/noir-contracts/contracts/test/constrained_delivery_test_contract/Nargo.toml b/noir-projects/noir-contracts/contracts/test/onchain_delivery_test_contract/Nargo.toml similarity index 88% rename from noir-projects/noir-contracts/contracts/test/constrained_delivery_test_contract/Nargo.toml rename to noir-projects/noir-contracts/contracts/test/onchain_delivery_test_contract/Nargo.toml index 6c29997b4b4b..ed97e1969c01 100644 --- a/noir-projects/noir-contracts/contracts/test/constrained_delivery_test_contract/Nargo.toml +++ b/noir-projects/noir-contracts/contracts/test/onchain_delivery_test_contract/Nargo.toml @@ -1,5 +1,5 @@ [package] -name = "constrained_delivery_test_contract" +name = "onchain_delivery_test_contract" authors = [""] compiler_version = ">=0.25.0" type = "contract" diff --git a/noir-projects/noir-contracts/contracts/test/constrained_delivery_test_contract/src/main.nr b/noir-projects/noir-contracts/contracts/test/onchain_delivery_test_contract/src/main.nr similarity index 81% rename from noir-projects/noir-contracts/contracts/test/constrained_delivery_test_contract/src/main.nr rename to noir-projects/noir-contracts/contracts/test/onchain_delivery_test_contract/src/main.nr index 6274a64cb05c..1fa646cdc699 100644 --- a/noir-projects/noir-contracts/contracts/test/constrained_delivery_test_contract/src/main.nr +++ b/noir-projects/noir-contracts/contracts/test/onchain_delivery_test_contract/src/main.nr @@ -1,10 +1,10 @@ -//! Thin wrappers around constrained delivery for TXE tests. +//! Thin wrappers around onchain message delivery for tests. use aztec::macros::aztec; mod test; #[aztec] -pub contract ConstrainedDeliveryTest { +pub contract OnchainDeliveryTest { use aztec::{ macros::{events::event, functions::external, storage::storage}, messages::delivery::MessageDelivery, @@ -56,6 +56,24 @@ pub contract ConstrainedDeliveryTest { self.emit(DeliveryEvent { value }).deliver_to(recipient, MessageDelivery::onchain_constrained()); } + #[external("private")] + fn emit_event_via_interactive_handshake(recipient: AztecAddress, value: Field) { + self.emit(DeliveryEvent { value }).deliver_to( + recipient, + MessageDelivery::onchain_constrained().via_interactive_handshake(), + ); + } + + #[external("private")] + fn emit_note_unconstrained(recipient: AztecAddress, value: Field) { + self.storage.notes.at(recipient).insert(FieldNote { value }).deliver(MessageDelivery::onchain_unconstrained()); + } + + #[external("private")] + fn emit_event_unconstrained(recipient: AztecAddress, value: Field) { + self.emit(DeliveryEvent { value }).deliver_to(recipient, MessageDelivery::onchain_unconstrained()); + } + #[external("private")] fn emit_two_events(recipient: AztecAddress) { self.emit(DeliveryEvent { value: 1 }).deliver_to(recipient, MessageDelivery::onchain_constrained()); diff --git a/noir-projects/noir-contracts/contracts/test/constrained_delivery_test_contract/src/test.nr b/noir-projects/noir-contracts/contracts/test/onchain_delivery_test_contract/src/test.nr similarity index 65% rename from noir-projects/noir-contracts/contracts/test/constrained_delivery_test_contract/src/test.nr rename to noir-projects/noir-contracts/contracts/test/onchain_delivery_test_contract/src/test.nr index 45f4279d149e..32b62ead583d 100644 --- a/noir-projects/noir-contracts/contracts/test/constrained_delivery_test_contract/src/test.nr +++ b/noir-projects/noir-contracts/contracts/test/onchain_delivery_test_contract/src/test.nr @@ -4,7 +4,7 @@ //! bootstrap the app-siloed handshake secret, reserve the next per-secret index, constrain that index, emit the //! sequence nullifier, then emit the tagged private log. The malformed-log test lands a constrained tag without its //! sequence nullifier so the next real delivery must reject the broken sequence. -use crate::ConstrainedDeliveryTest; +use crate::OnchainDeliveryTest; use aztec::{ protocol::address::AztecAddress, @@ -24,7 +24,7 @@ unconstrained fn setup() -> (TestEnvironment, AztecAddress, AztecAddress, AztecA .without_initializer(); assert_eq(registry_address, STANDARD_HANDSHAKE_REGISTRY_ADDRESS); - let test_address = env.deploy("ConstrainedDeliveryTest").without_initializer(); + let test_address = env.deploy("OnchainDeliveryTest").without_initializer(); (env, registry_address, test_address, sender, recipient) } @@ -38,7 +38,7 @@ unconstrained fn authorizing(registry_address: AztecAddress) -> CallPrivateOptio #[test] unconstrained fn rehandshake_replaces_registry_secret_for_future_delivery() { let (env, registry_address, test_address, sender, recipient) = setup(); - let test_contract = ConstrainedDeliveryTest::at(test_address); + let test_contract = OnchainDeliveryTest::at(test_address); let registry = HandshakeRegistry::at(registry_address); env.call_private_opts(sender, authorizing(registry_address), test_contract.emit_event(recipient, 1)); @@ -66,10 +66,52 @@ unconstrained fn rehandshake_replaces_registry_secret_for_future_delivery() { assert_eq(next_index, 1); } +// The TXE environment configures no `resolveCustomRequest` resolver, so a fresh interactive handshake fails at the +// registry's signature request. Reaching that failure proves the builder option dispatches through the delivery stack +// to the registry's `interactive_handshake` entrypoint. +#[test(should_fail_with = "no resolveCustomRequest hook is configured")] +unconstrained fn interactive_handshake_dispatches_to_the_registry() { + let (env, registry_address, test_address, sender, recipient) = setup(); + let test_contract = OnchainDeliveryTest::at(test_address); + + let _ = env.call_private_opts( + sender, + authorizing(registry_address), + test_contract.emit_event_via_interactive_handshake(recipient, 1), + ); +} + +#[test] +unconstrained fn interactive_handshake_reuses_an_existing_registry_handshake() { + let (env, registry_address, test_address, sender, recipient) = setup(); + let test_contract = OnchainDeliveryTest::at(test_address); + let registry = HandshakeRegistry::at(registry_address); + + let _ = env.call_private(sender, registry.non_interactive_handshake(sender, recipient)); + let secret = env + .execute_utility_opts( + ExecuteUtilityOptions::new().with_from(test_address), + registry.get_app_siloed_secrets(sender, recipient), + ) + .expect(f"handshake should be siloed for the test contract") + .shared; + + // No resolver is configured, so this only succeeds because the existing handshake is reused instead of + // establishing a fresh interactive one. + env.call_private_opts( + sender, + authorizing(registry_address), + test_contract.emit_event_via_interactive_handshake(recipient, 1), + ); + + let next_index = env.call_private(sender, test_contract.next_index_for_secret(secret)); + assert_eq(next_index, 1); +} + #[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 test_contract = OnchainDeliveryTest::at(test_address); let registry = HandshakeRegistry::at(registry_address); let _ = env.call_private(sender, registry.non_interactive_handshake(sender, recipient)); diff --git a/noir-projects/noir-contracts/pinned-standard-contracts.tar.gz b/noir-projects/noir-contracts/pinned-standard-contracts.tar.gz index 5f81f7d992e3..853c5bf34f10 100644 Binary files a/noir-projects/noir-contracts/pinned-standard-contracts.tar.gz and b/noir-projects/noir-contracts/pinned-standard-contracts.tar.gz differ diff --git a/yarn-project/end-to-end/bootstrap.sh b/yarn-project/end-to-end/bootstrap.sh index 8ea2bb11b9c7..829d6aac14af 100755 --- a/yarn-project/end-to-end/bootstrap.sh +++ b/yarn-project/end-to-end/bootstrap.sh @@ -52,6 +52,9 @@ function test_cmds { local tests=( # List all standalone and nested tests, except for the ones listed above. + # Keep these globs non-overlapping: docker_isolate derives the container name from the test path, so a + # duplicated path emits two sibling invocations racing on the same name — you'll see docker "Conflict, + # name already in use" errors, or one copy's docker rm -f killing the other's live container mid-run. src/automine/*.test.ts src/automine/!(simulation)/**/*.test.ts src/automine/simulation/!(avm_simulator).test.ts diff --git a/yarn-project/end-to-end/src/e2e_constrained_delivery.test.ts b/yarn-project/end-to-end/src/automine/delivery/constrained.test.ts similarity index 58% rename from yarn-project/end-to-end/src/e2e_constrained_delivery.test.ts rename to yarn-project/end-to-end/src/automine/delivery/constrained.test.ts index 6e2b9803acd2..d3c1b1bef8ce 100644 --- a/yarn-project/end-to-end/src/e2e_constrained_delivery.test.ts +++ b/yarn-project/end-to-end/src/automine/delivery/constrained.test.ts @@ -1,25 +1,18 @@ -import { type InitialAccountData, generateSchnorrAccounts } from '@aztec/accounts/testing'; -import { NO_FROM } from '@aztec/aztec.js/account'; import type { AztecAddress } from '@aztec/aztec.js/addresses'; import { BatchCall } from '@aztec/aztec.js/contracts'; -import type { AztecNode } from '@aztec/aztec.js/node'; import type { Wallet } from '@aztec/aztec.js/wallet'; -import { BlockNumber } from '@aztec/foundation/branded-types'; +import { Point } from '@aztec/foundation/curves/grumpkin'; import { HandshakeRegistryContract } from '@aztec/noir-contracts.js/HandshakeRegistry'; -import { - ConstrainedDeliveryTestContract, - type DeliveryEvent, -} from '@aztec/noir-test-contracts.js/ConstrainedDeliveryTest'; +import { OnchainDeliveryTestContract } from '@aztec/noir-test-contracts.js/OnchainDeliveryTest'; import { STANDARD_HANDSHAKE_REGISTRY_ADDRESS } from '@aztec/standard-contracts/handshake-registry/constants'; -import type { AztecNodeDebug } from '@aztec/stdlib/interfaces/client'; import { jest } from '@jest/globals'; -import { AUTOMINE_E2E_OPTS } from './fixtures/fixtures.js'; -import { ensureHandshakeRegistryPublished, setup, setupPXEAndGetWallet } from './fixtures/setup.js'; -import { TestWallet } from './test-wallet/test_wallet.js'; +import { AUTOMINE_E2E_OPTS } from '../../fixtures/fixtures.js'; +import { ensureHandshakeRegistryPublished, setup } from '../../fixtures/setup.js'; -describe('constrained delivery', () => { +// Delivery-method-specific tests that don't fit the generic (strategy, mode) matrix in `onchain.test.ts` +describe('delivery/constrained', () => { jest.setTimeout(300_000); let teardown: () => Promise; @@ -30,7 +23,7 @@ describe('constrained delivery', () => { let batchRecipient2: AztecAddress; let batchRecipient3: AztecAddress; let batchRecipient4: AztecAddress; - let contract: ConstrainedDeliveryTestContract; + let contract: OnchainDeliveryTestContract; let registry: HandshakeRegistryContract; beforeAll(async () => { @@ -41,7 +34,7 @@ describe('constrained delivery', () => { } = await setup(6, { ...AUTOMINE_E2E_OPTS })); await ensureHandshakeRegistryPublished(wallet, sender); - ({ contract } = await ConstrainedDeliveryTestContract.deploy(wallet).send({ from: sender })); + ({ contract } = await OnchainDeliveryTestContract.deploy(wallet).send({ from: sender })); registry = HandshakeRegistryContract.at(STANDARD_HANDSHAKE_REGISTRY_ADDRESS, wallet); }); @@ -159,109 +152,32 @@ describe('constrained delivery', () => { }); }); -// A constrained message sent by an account on one PXE is discovered and read by a recipient whose account lives on a -// separate PXE, with no shared in-memory state: PXE B finds the message purely from on-chain logs plus the -// HandshakeRegistry. The two PXEs share one node, following the e2e_2_pxes pattern. -describe('cross-PXE constrained delivery', () => { +// This test builds its own PXE via setup() rather than reusing the wallet from the describe block above, because +// it needs a resolveTaggingSecretStrategy hook that only exists as a PXE-creation-time option. +describe('delivery/constrained: rejects unsound sources', () => { jest.setTimeout(300_000); - let aztecNode: AztecNode & AztecNodeDebug; - let walletSender: TestWallet; - let walletRecipient: TestWallet; - let sender: AztecAddress; - let recipient: AztecAddress; - let additionallyFundedAccounts: InitialAccountData[]; - let contractSender: ConstrainedDeliveryTestContract; - let teardownSender: () => Promise; - let teardownRecipient: () => Promise; - - beforeAll(async () => { - // PXE A holds the sender. The recipient is funded at genesis here but created and deployed on PXE B below. - ({ - aztecNode, - additionallyFundedAccounts, - wallet: walletSender, - accounts: [sender], - teardown: teardownSender, - } = await setup(1, { + it('rejects a constrained send backed by an arbitrary secret', async () => { + const { + teardown, + wallet, + accounts: [sender, recipient], + } = await setup(2, { ...AUTOMINE_E2E_OPTS, - additionallyFundedAccounts: await generateSchnorrAccounts(1, 'schnorr'), - })); - - // PXE B holds the recipient on the same node; the recipient account's keys live only here. - ({ wallet: walletRecipient, teardown: teardownRecipient } = await setupPXEAndGetWallet( - aztecNode, - aztecNode, - {}, - undefined, - 'pxe-b', - )); - const recipientAccount = await walletRecipient.createSchnorrAccount( - additionallyFundedAccounts[0].secret, - additionallyFundedAccounts[0].salt, - additionallyFundedAccounts[0].signingKey, - ); - await (await recipientAccount.getDeployMethod()).send({ from: NO_FROM }); - recipient = recipientAccount.address; - - await ensureHandshakeRegistryPublished(walletSender, sender); - const { contract: deployed, instance } = await ConstrainedDeliveryTestContract.deploy(walletSender).send({ - from: sender, - }); - contractSender = deployed; - - await ensureHandshakeRegistryPublished(walletRecipient, recipient); - await walletRecipient.registerContract(instance, ConstrainedDeliveryTestContract.artifact); - }); - - afterAll(async () => { - await teardownRecipient(); - await teardownSender(); - }); - - it('delivers multiple constrained events from PXE A that PXE B discovers', async () => { - // Distinct values prove PXE B decrypts each message's content, not merely that a tagged log arrived; delivering - // several on one sequence proves both the bootstrap send (index 0) and the reused-handshake sends are - // discovered. Constrained sends to one pair are strictly ordered, so they go one tx at a time. - const eventValues = [10n, 20n, 30n]; - const blockNumbers: number[] = []; - for (const value of eventValues) { - const { receipt } = await contractSender.methods.emit_event(recipient, value).send({ from: sender }); - blockNumbers.push(receipt.blockNumber!); - } - - await walletRecipient.sync(); - - const events = await walletRecipient.getPrivateEvents( - ConstrainedDeliveryTestContract.events.DeliveryEvent, - { - contractAddress: contractSender.address, - fromBlock: BlockNumber(Math.min(...blockNumbers)), - toBlock: BlockNumber(Math.max(...blockNumbers) + 1), - scopes: [recipient], + pxeCreationOptions: { + hooks: { + resolveTaggingSecretStrategy: async () => ({ type: 'arbitrary-secret', secret: await Point.random() }), + }, }, - ); - - const discovered = events.map(e => e.event.value); - expect(discovered.length).toBe(eventValues.length); - for (const value of eventValues) { - expect(discovered).toContainEqual(value); - } - }); - - it('delivers multiple constrained notes from PXE A that PXE B reads back', async () => { - // Same recipient and handshake as the events above, so these notes land at the following sequence indices. - const noteValues = [40n, 50n, 60n]; - for (const value of noteValues) { - await contractSender.methods.emit_note(recipient, value).send({ from: sender }); + }); + try { + await ensureHandshakeRegistryPublished(wallet, sender); + const { contract } = await OnchainDeliveryTestContract.deploy(wallet).send({ from: sender }); + await expect(contract.methods.emit_event(recipient, 1).send({ from: sender })).rejects.toThrow( + 'an unconstrained tagging secret cannot back constrained delivery', + ); + } finally { + await teardown(); } - - await walletRecipient.sync(); - - const contractRecipient = ConstrainedDeliveryTestContract.at(contractSender.address, walletRecipient); - // Count proves every delivered note was discovered; the sum of distinct values proves each was decrypted. - const { result } = await contractRecipient.methods.get_note_values(recipient).simulate({ from: recipient }); - const values: bigint[] = result.storage.slice(0, Number(result.len)); - expect(values).toEqual(noteValues); }); }); diff --git a/yarn-project/end-to-end/src/automine/delivery/handshake_reuse.test.ts b/yarn-project/end-to-end/src/automine/delivery/handshake_reuse.test.ts new file mode 100644 index 000000000000..165d7056e4ed --- /dev/null +++ b/yarn-project/end-to-end/src/automine/delivery/handshake_reuse.test.ts @@ -0,0 +1,41 @@ +import { AppTaggingSecretKind } from '@aztec/stdlib/logs'; + +import { buildMessageDeliveryTest } from './onchain_delivery_harness.js'; + +// Pins mode-agnostic handshake reuse: a handshake bootstrapped under one delivery mode must be reused when the other +// mode sends next. +describe('handshake_reuse', () => { + const forwardHookCalls: AppTaggingSecretKind[] = []; + buildMessageDeliveryTest({ + strategy: 'non-interactive handshake', + mode: { events: 'constrained', notes: 'unconstrained' }, + senderHook: ({ deliveryMode }) => { + forwardHookCalls.push(deliveryMode); + return Promise.resolve({ type: 'non-interactive-handshake' }); + }, + additionalTests: () => { + it('the strategy hook fires exactly once, to bootstrap the handshake on the first constrained send', () => { + expect(forwardHookCalls).toHaveLength(1); + expect(forwardHookCalls[0]).toBe(AppTaggingSecretKind.CONSTRAINED); + }); + }, + }); + + // Reverse direction also pins the constrained sequence to a fresh index 0 after an unconstrained bootstrap; a leaked + // unconstrained counter would make the first constrained send require a predecessor nullifier that does not exist. + const reverseHookCalls: AppTaggingSecretKind[] = []; + buildMessageDeliveryTest({ + strategy: 'non-interactive handshake', + mode: { events: 'unconstrained', notes: 'constrained' }, + senderHook: ({ deliveryMode }) => { + reverseHookCalls.push(deliveryMode); + return Promise.resolve({ type: 'non-interactive-handshake' }); + }, + additionalTests: () => { + it('the strategy hook fires exactly once, to bootstrap the handshake on the first unconstrained send', () => { + expect(reverseHookCalls).toHaveLength(1); + expect(reverseHookCalls[0]).toBe(AppTaggingSecretKind.UNCONSTRAINED); + }); + }, + }); +}); diff --git a/yarn-project/end-to-end/src/automine/delivery/onchain.test.ts b/yarn-project/end-to-end/src/automine/delivery/onchain.test.ts new file mode 100644 index 000000000000..cdab97abcf31 --- /dev/null +++ b/yarn-project/end-to-end/src/automine/delivery/onchain.test.ts @@ -0,0 +1,47 @@ +import { Point } from '@aztec/foundation/curves/grumpkin'; + +import { buildMessageDeliveryTest } from './onchain_delivery_harness.js'; + +describe('onchain delivery', () => { + let arbitrarySecret: Point; + + beforeAll(async () => { + arbitrarySecret = await Point.random(); + }); + + buildMessageDeliveryTest({ + strategy: 'non-interactive handshake', + mode: 'constrained', + senderHook: () => Promise.resolve({ type: 'non-interactive-handshake' }), + }); + + buildMessageDeliveryTest({ + strategy: 'non-interactive handshake', + mode: 'unconstrained', + senderHook: () => Promise.resolve({ type: 'non-interactive-handshake' }), + }); + + buildMessageDeliveryTest({ + strategy: 'arbitrary secret', + mode: 'unconstrained', + senderHook: () => Promise.resolve({ type: 'arbitrary-secret', secret: arbitrarySecret }), + recipientRegistration: async (recipientWallet, recipientAddress) => { + await recipientWallet.registerTaggingSecretSource({ + kind: 'arbitrary-secret', + recipient: recipientAddress, + secret: arbitrarySecret, + }); + }, + }); + + // With the recipient registering the sender, + // the recipient PXE reconstructs the address-derived tag and discovers the delivery. + buildMessageDeliveryTest({ + strategy: 'address-derived', + mode: 'unconstrained', + senderHook: () => Promise.resolve({ type: 'address-derived' }), + recipientRegistration: async (recipientWallet, _recipientAddress, senderAddress) => { + await recipientWallet.registerSender(senderAddress); + }, + }); +}); diff --git a/yarn-project/end-to-end/src/automine/delivery/onchain_delivery_harness.ts b/yarn-project/end-to-end/src/automine/delivery/onchain_delivery_harness.ts new file mode 100644 index 000000000000..d10ae9196aea --- /dev/null +++ b/yarn-project/end-to-end/src/automine/delivery/onchain_delivery_harness.ts @@ -0,0 +1,177 @@ +import { type InitialAccountData, generateSchnorrAccounts } from '@aztec/accounts/testing'; +import type { FieldLike } from '@aztec/aztec.js/abi'; +import { NO_FROM } from '@aztec/aztec.js/account'; +import type { AztecAddress } from '@aztec/aztec.js/addresses'; +import type { AztecNode } from '@aztec/aztec.js/node'; +import { BlockNumber } from '@aztec/foundation/branded-types'; +import { type DeliveryEvent, OnchainDeliveryTestContract } from '@aztec/noir-test-contracts.js/OnchainDeliveryTest'; +import type { PXECreationOptions } from '@aztec/pxe/server'; +import type { AztecNodeDebug } from '@aztec/stdlib/interfaces/client'; + +import { jest } from '@jest/globals'; + +import { AUTOMINE_E2E_OPTS } from '../../fixtures/fixtures.js'; +import { ensureHandshakeRegistryPublished, setup, setupPXEAndGetWallet } from '../../fixtures/setup.js'; +import { TestWallet } from '../../test-wallet/test_wallet.js'; + +// The wallet hook that selects a message's tagging-secret source. Derived from the exported PXE options +// rather than importing the hook type, which `@aztec/pxe/server` does not re-export. +export type SenderHook = NonNullable['resolveTaggingSecretStrategy']>; + +export type Mode = 'constrained' | 'unconstrained'; + +// A single mode applies to both the event and note sends; `{ events, notes }` sends each in its own mode, which +// exercises cross-mode handshake reuse: bootstrap in one mode, deliver in the other on the same handshake. +export type DeliveryMode = Mode | { events: Mode; notes: Mode }; + +const formatMode = (mode: DeliveryMode): string => (typeof mode === 'string' ? mode : `${mode.events}->${mode.notes}`); + +// Onchain private delivery has two orthogonal axes: the delivery MODE (constrained = nullifier-chained sequence; +// unconstrained = no nullifier, windowed scan) and the tagging-secret SOURCE, which the wallet's +// `resolveTaggingSecretStrategy` hook selects. This harness exercises (strategy, mode) cells end to end across two +// PXEs that share only a node: the sender PXE sends, the recipient PXE discovers purely from onchain logs plus the +// HandshakeRegistry. +// +// Cross-PXE is the meaningful setup: the recipient PXE holds no sender state, so a cell only "discovers" a message if +// the source truly reached it. +export function buildMessageDeliveryTest(opts: { + // Names the tagging-secret source, e.g. 'non-interactive handshake' or 'arbitrary secret'. The describe title is + // derived as `${strategy} x ${mode}`. + strategy: string; + mode: DeliveryMode; + // Required: every cell states its source explicitly rather than leaning on the PXE default (covered by unit tests). + senderHook: SenderHook; + // Recipient-side setup the source requires (e.g. registering a raw arbitrary secret); runs once after deployment. + recipientRegistration?: ( + recipient: TestWallet, + recipientAddress: AztecAddress, + sender: AztecAddress, + ) => Promise; + // Extra `it()`s to register in this cell's suite, e.g. assertions against state a custom `senderHook` recorded. + // Called inside the same `describe`, after the two baseline assertions below, so it shares their `beforeAll` + // instead of depending on Jest's cross-`describe` execution order. + additionalTests?: () => void; +}) { + const { strategy, mode, senderHook, recipientRegistration, additionalTests } = opts; + const description = `${strategy} x ${formatMode(mode)}`; + + describe(description, () => { + jest.setTimeout(300_000); + + const eventValues = [10n, 20n, 30n]; + const noteValues = [40n, 50n, 60n]; + + let aztecNode: AztecNode & AztecNodeDebug; + let walletSender: TestWallet; + let walletRecipient: TestWallet; + let sender: AztecAddress; + let recipient: AztecAddress; + let contractSender: OnchainDeliveryTestContract; + let teardownSender: () => Promise; + let teardownRecipient: () => Promise; + // Discovery results captured in beforeAll, so the assertions in the actual tests below stay pure. + // A failure during delivery fails beforeAll loudly instead of being swallowed as an expected red. + let discoveredEvents: FieldLike[]; + let readNotes: bigint[]; + + const { events: eventMode, notes: noteMode } = typeof mode === 'string' ? { events: mode, notes: mode } : mode; + const sendEvent = (value: bigint) => + eventMode === 'constrained' + ? contractSender.methods.emit_event(recipient, value) + : contractSender.methods.emit_event_unconstrained(recipient, value); + const sendNote = (value: bigint) => + noteMode === 'constrained' + ? contractSender.methods.emit_note(recipient, value) + : contractSender.methods.emit_note_unconstrained(recipient, value); + + beforeAll(async () => { + // The sender PXE holds the sender and carries this cell's tagging-secret-strategy hook. The recipient is funded + // at genesis here but created and deployed on the isolated recipient PXE below, so it carries no sender state + // from other cells. + let additionallyFundedAccounts: InitialAccountData[]; + ({ + aztecNode, + additionallyFundedAccounts, + wallet: walletSender, + accounts: [sender], + teardown: teardownSender, + } = await setup(1, { + ...AUTOMINE_E2E_OPTS, + additionallyFundedAccounts: await generateSchnorrAccounts(1, 'schnorr'), + pxeCreationOptions: { hooks: { resolveTaggingSecretStrategy: senderHook } }, + })); + + ({ wallet: walletRecipient, teardown: teardownRecipient } = await setupPXEAndGetWallet( + aztecNode, + aztecNode, + {}, + undefined, + 'pxe-recipient', + )); + const recipientAccount = await walletRecipient.createSchnorrAccount( + additionallyFundedAccounts[0].secret, + additionallyFundedAccounts[0].salt, + additionallyFundedAccounts[0].signingKey, + ); + await (await recipientAccount.getDeployMethod()).send({ from: NO_FROM }); + recipient = recipientAccount.address; + + await ensureHandshakeRegistryPublished(walletSender, sender); + const { contract: deployed, instance } = await OnchainDeliveryTestContract.deploy(walletSender).send({ + from: sender, + }); + contractSender = deployed; + + await ensureHandshakeRegistryPublished(walletRecipient, recipient); + await walletRecipient.registerContract(instance, OnchainDeliveryTestContract.artifact); + + await recipientRegistration?.(walletRecipient, recipient, sender); + + // Constrained sends to one pair are strictly ordered, so deliver one tx at a time. The first send bootstraps the + // handshake (when the source is a handshake); the rest reuse it. + const blockNumbers: number[] = []; + for (const value of eventValues) { + const { receipt } = await sendEvent(value).send({ from: sender }); + blockNumbers.push(receipt.blockNumber!); + } + for (const value of noteValues) { + await sendNote(value).send({ from: sender }); + } + + await walletRecipient.sync(); + + const events = await walletRecipient.getPrivateEvents( + OnchainDeliveryTestContract.events.DeliveryEvent, + { + contractAddress: contractSender.address, + fromBlock: BlockNumber(Math.min(...blockNumbers)), + toBlock: BlockNumber(Math.max(...blockNumbers) + 1), + scopes: [recipient], + }, + ); + discoveredEvents = events.map(e => e.event.value); + + const contractRecipient = OnchainDeliveryTestContract.at(contractSender.address, walletRecipient); + const { result } = await contractRecipient.methods.get_note_values(recipient).simulate({ from: recipient }); + readNotes = result.storage.slice(0, Number(result.len)); + }); + + afterAll(async () => { + await teardownRecipient(); + await teardownSender(); + }); + + it('the recipient PXE discovers the events delivered by the sender PXE', () => { + expect(discoveredEvents.length).toBe(eventValues.length); + for (const value of eventValues) { + expect(discoveredEvents).toContainEqual(value); + } + }); + + it('the recipient PXE reads back the notes delivered by the sender PXE', () => { + expect(readNotes).toEqual(noteValues); + }); + + additionalTests?.(); + }); +} diff --git a/yarn-project/end-to-end/src/test-wallet/test_wallet.ts b/yarn-project/end-to-end/src/test-wallet/test_wallet.ts index 1a3ad3ec6c07..4bafc511e205 100644 --- a/yarn-project/end-to-end/src/test-wallet/test_wallet.ts +++ b/yarn-project/end-to-end/src/test-wallet/test_wallet.ts @@ -23,7 +23,7 @@ import { Fq, Fr } from '@aztec/foundation/curves/bn254'; import { GrumpkinScalar } from '@aztec/foundation/curves/grumpkin'; import type { NotesFilter } from '@aztec/pxe/client/lazy'; import { type PXEConfig, getPXEConfig } from '@aztec/pxe/config'; -import { PXE, type PXECreationOptions, createPXE } from '@aztec/pxe/server'; +import { PXE, type PXECreationOptions, type TaggingSecretSource, createPXE } from '@aztec/pxe/server'; import { AuthWitness } from '@aztec/stdlib/auth-witness'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; import { getContractClassFromArtifact } from '@aztec/stdlib/contract'; @@ -411,6 +411,15 @@ export class TestWallet extends BaseWallet { return this.pxe.sync(); } + /** + * Registers a non-sender tagging-secret source (e.g. a raw out-of-band shared secret) so this PXE discovers messages + * tagged with it. Test-only surface over {@link PXE.registerTaggingSecretSource}, which the base `Wallet` does not + * expose. The `address-derived` (sender) variant is excluded: use {@link Wallet.registerSender} for that. + */ + registerTaggingSecretSource(source: Exclude): Promise { + return this.pxe.registerTaggingSecretSource(source); + } + stop(): Promise { return this.pxe.stop(); } diff --git a/yarn-project/pxe/src/contract_function_simulator/index.ts b/yarn-project/pxe/src/contract_function_simulator/index.ts index 8f75549fcdd4..1453126d9d15 100644 --- a/yarn-project/pxe/src/contract_function_simulator/index.ts +++ b/yarn-project/pxe/src/contract_function_simulator/index.ts @@ -32,13 +32,16 @@ export { PENDING_TAGGED_LOG, POINT, PROVIDED_SECRET, + SLOT_NUMBER, STR, STRUCT, U32, + tryFieldWidth, type InputSlot, type MaybePromise, type OutputSlot, type SlotShape, + type StructField, type TypeMapping, } from './oracle/oracle_type_mappings.js'; export { ExecutionNoteCache } from './execution_note_cache.js'; diff --git a/yarn-project/pxe/src/contract_function_simulator/noir-structs/resolved_tagging_strategy.ts b/yarn-project/pxe/src/contract_function_simulator/noir-structs/resolved_tagging_strategy.ts index f82901466851..8b1930281d3e 100644 --- a/yarn-project/pxe/src/contract_function_simulator/noir-structs/resolved_tagging_strategy.ts +++ b/yarn-project/pxe/src/contract_function_simulator/noir-structs/resolved_tagging_strategy.ts @@ -3,6 +3,7 @@ import { Fr } from '@aztec/foundation/curves/bn254'; // Keep in sync with aztec::messages::delivery::ResolvedTaggingStrategy const NON_INTERACTIVE_HANDSHAKE = 1; const UNCONSTRAINED_SECRET = 2; +const INTERACTIVE_HANDSHAKE = 3; /** * The tagging secret PXE hands to the contract after applying the wallet's {@link TaggingSecretStrategy} choice @@ -13,6 +14,14 @@ export type ResolvedTaggingStrategy = /** A non-interactive handshake: the recipient re-derives the secret from the onchain handshake registry. */ type: 'non-interactive-handshake'; } + | { + /** + * An interactive handshake: the registry obtains the recipient's signed authorization through the custom + * request oracle, and the recipient learns the secret while signing. Nothing announcing the handshake is + * published onchain. + */ + type: 'interactive-handshake'; + } | { /** * An app-siloed, recipient-directional secret, ready to use. Only sound for unconstrained delivery. @@ -29,6 +38,8 @@ export function resolvedTaggingStrategyToFields(resolved: ResolvedTaggingStrateg return [new Fr(NON_INTERACTIVE_HANDSHAKE), Fr.ZERO]; case 'unconstrained-secret': return [new Fr(UNCONSTRAINED_SECRET), resolved.secret]; + case 'interactive-handshake': + return [new Fr(INTERACTIVE_HANDSHAKE), Fr.ZERO]; } } @@ -37,6 +48,8 @@ export function resolvedTaggingStrategyFromFields(kind: number, secret: Fr): Res switch (kind) { case NON_INTERACTIVE_HANDSHAKE: return { type: 'non-interactive-handshake' }; + case INTERACTIVE_HANDSHAKE: + return { type: 'interactive-handshake' }; case UNCONSTRAINED_SECRET: return { type: 'unconstrained-secret', secret }; default: diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_type_mappings.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_type_mappings.ts index ef0bb6ec29b1..2dc5ea63d7fd 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_type_mappings.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_type_mappings.ts @@ -292,7 +292,7 @@ export const ETH_ADDRESS: TypeMapping = { shape: ['scalar'], }; -const SLOT_NUMBER: TypeMapping = { +export const SLOT_NUMBER: TypeMapping = { serialization: { fn: v => [new Fr(v)] }, shape: ['scalar'], }; @@ -619,20 +619,26 @@ export function ARRAY(inner: TypeMapping): TypeMapping & { kind: 'arr } /** - * Noir's fixed-length array `[T; maxLength]` in one slot: serializes `values` (each flattened via `element`) - * zero-padded to exactly `maxLength * elementWidth` fields, and deserializes all `maxLength` elements back. An absent + * Noir's fixed-length array `[T; length]` in one slot: serializes `values` (each flattened via `element`) + * zero-padded to exactly `length * elementWidth` fields, and deserializes all `length` elements back. An absent * element is the zero encoding, so the padding is derived from the shape. */ -export function FIXED_ARRAY(element: TypeMapping, maxLength: number): TypeMapping { +export function FIXED_ARRAY( + element: TypeMapping, + length: number, +): TypeMapping & { kind: 'fixed-array'; inner: TypeMapping; length: number } { const elementWidth = fieldWidth(element.shape); return { + kind: 'fixed-array', + inner: element, + length, serialization: element.serialization - ? { fn: values => [padArrayEnd(packElements(element, values), Fr.ZERO, maxLength * elementWidth)] } + ? { fn: values => [padArrayEnd(packElements(element, values), Fr.ZERO, length * elementWidth)] } : undefined, deserialization: element.deserialization - ? { fn: ([reader]) => unpackElements(element, reader, maxLength) } + ? { fn: ([reader]) => unpackElements(element, reader, length) } : undefined, - shape: [{ len: maxLength * elementWidth }], + shape: [{ len: length * elementWidth }], }; } @@ -692,9 +698,15 @@ export function BOUNDED_VEC( * (zero-padded) followed by the actual length, with no length prefix, so the width is statically known. Serialize-only. * Throws if the input exceeds `maxLength`. */ -export function FIXED_BOUNDED_VEC(element: TypeMapping, maxLength: number): TypeMapping { +export function FIXED_BOUNDED_VEC( + element: TypeMapping, + maxLength: number, +): TypeMapping & { kind: 'fixed-bounded-vec'; inner: TypeMapping; maxLength: number } { const width = fieldWidth(element.shape); return { + kind: 'fixed-bounded-vec', + inner: element, + maxLength, serialization: element.serialization ? { fn: values => { @@ -805,15 +817,19 @@ export function EPHEMERAL_ARRAY(element: TypeMapping): TypeMapping = { name: TName; type: TypeMapping }; +export type StructField = { name: TName; type: TypeMapping }; /** * A Noir struct: its `shape` and (de)serialization are the concatenation of its fields', so callers never hand-write a * `shape`. `T` is the struct's TS value type and must match the field layout — serialization reads each field by name * off the value, deserialization returns the decoded bag as `T`; convert in the handler, not here, when `T` differs. */ -export function STRUCT(fields: readonly StructField[]): TypeMapping { +export function STRUCT( + fields: readonly StructField[], +): TypeMapping & { kind: 'struct'; fields: readonly StructField[] } { return { + kind: 'struct', + fields, serialization: fields.every(f => f.type.serialization) ? { fn: (value: T) => { @@ -846,7 +862,7 @@ export function slotsOf(mapping: TypeMapping): number { } /** Number of fields a fully-static shape occupies, or `undefined` if any slot is variable-width. */ -function tryFieldWidth(shape: SlotShape[]): number | undefined { +export function tryFieldWidth(shape: SlotShape[]): number | undefined { let total = 0; for (const slot of shape) { if (slot === 'scalar') { diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.test.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.test.ts index 8e70f85d4175..a9bca1ccab5d 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.test.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.test.ts @@ -127,6 +127,40 @@ describe('PrivateExecutionOracle', () => { ); }); + it('resolves an interactive-handshake strategy', async () => { + const { oracle } = makeHookedOracle({ strategy: { type: 'interactive-handshake' } }); + + await expect(oracle.resolveTaggingStrategy(sender, recipient, AppTaggingSecretKind.CONSTRAINED)).resolves.toEqual( + { + type: 'interactive-handshake', + }, + ); + }); + + it('overrides a hooked interactive handshake on an unconstrained self-send with an address-derived secret', async () => { + const { oracle } = makeHookedOracle({ + strategy: { type: 'interactive-handshake' }, + keyStore: makeKeyStore({ ownsRecipient: true }), + }); + const secret = Fr.random(); + jest.spyOn(oracle, 'getAppTaggingSecret').mockResolvedValue(Option.some(secret)); + + await expect( + oracle.resolveTaggingStrategy(sender, recipient, AppTaggingSecretKind.UNCONSTRAINED), + ).resolves.toEqual({ type: 'unconstrained-secret', secret }); + }); + + it('keeps a hooked interactive handshake under constrained delivery even when the wallet owns the recipient', async () => { + const { oracle } = makeHookedOracle({ + strategy: { type: 'interactive-handshake' }, + keyStore: makeKeyStore({ ownsRecipient: true }), + }); + + await expect(oracle.resolveTaggingStrategy(sender, recipient, AppTaggingSecretKind.CONSTRAINED)).resolves.toEqual( + { type: 'interactive-handshake' }, + ); + }); + it('resolves an address-derived strategy to the unconstrained secret', async () => { const { oracle } = makeHookedOracle({ strategy: { type: 'address-derived' } }); const secret = Fr.random(); diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.ts index cc08e3b25e2e..e8158f1f36cd 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.ts @@ -278,6 +278,8 @@ export class PrivateExecutionOracle extends UtilityExecutionOracle implements IP switch (strategy.type) { case 'non-interactive-handshake': return { type: 'non-interactive-handshake' }; + case 'interactive-handshake': + return { type: 'interactive-handshake' }; case 'address-derived': return this.#addressDerivedSecret(sender, recipient); case 'arbitrary-secret': { diff --git a/yarn-project/pxe/src/hooks/resolve_tagging_secret_strategy.ts b/yarn-project/pxe/src/hooks/resolve_tagging_secret_strategy.ts index aac3e9ce1fbd..6ca37cc05e68 100644 --- a/yarn-project/pxe/src/hooks/resolve_tagging_secret_strategy.ts +++ b/yarn-project/pxe/src/hooks/resolve_tagging_secret_strategy.ts @@ -3,6 +3,9 @@ import type { Point } from '@aztec/foundation/curves/grumpkin'; import type { AztecAddress } from '@aztec/stdlib/aztec-address'; import type { AppTaggingSecretKind } from '@aztec/stdlib/logs'; +// eslint-disable-next-line @typescript-eslint/no-unused-vars -- only referenced by a {@link} doc tag +import type { ResolveCustomRequest } from './resolve_custom_request.js'; + /** * How a message's tagging secret is chosen: the wallet's strategy, returned by the `resolveTaggingSecretStrategy` hook * when no onchain handshake has been registered for the sender/recipient pair. This is intent (plus, for an arbitrary @@ -13,6 +16,14 @@ export type TaggingSecretStrategy = /** Establish a fresh non-interactive handshake via the onchain registry; reveals the recipient onchain. */ type: 'non-interactive-handshake'; } + | { + /** + * Establish a fresh interactive handshake via the onchain registry. Reveals nothing about the recipient + * onchain, but requires the recipient to answer the registry's signed-authorization request (served through + * the {@link ResolveCustomRequest} hook), so the send fails when it cannot be served. + */ + type: 'interactive-handshake'; + } | { /** Derive the secret from the sender's and recipient's address keys via ECDH. PXE computes and silos it. */ type: 'address-derived'; diff --git a/yarn-project/pxe/src/logs/log_service.test.ts b/yarn-project/pxe/src/logs/log_service.test.ts index 3a3b1120262d..39b5ff298001 100644 --- a/yarn-project/pxe/src/logs/log_service.test.ts +++ b/yarn-project/pxe/src/logs/log_service.test.ts @@ -1,13 +1,24 @@ import { INITIAL_L2_BLOCK_NUM } from '@aztec/constants'; import { BlockNumber } from '@aztec/foundation/branded-types'; import { randomInt } from '@aztec/foundation/crypto/random'; +import { Fr } from '@aztec/foundation/curves/bn254'; +import { Point } from '@aztec/foundation/curves/grumpkin'; import { KeyStore } from '@aztec/key-store'; import { openTmpStore } from '@aztec/kv-store/lmdb-v2'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; import type { L2TipsProvider } from '@aztec/stdlib/block'; +import type { CompleteAddress } from '@aztec/stdlib/contract'; import type { AztecNode } from '@aztec/stdlib/interfaces/server'; -import { SiloedTag, Tag } from '@aztec/stdlib/logs'; -import { makeBlockHeader, randomPrivateLogResult } from '@aztec/stdlib/testing'; +import { deriveKeys } from '@aztec/stdlib/keys'; +import { + AppTaggingSecret, + AppTaggingSecretKind, + type LogResult, + SiloedTag, + Tag, + computeSharedTaggingSecret, +} from '@aztec/stdlib/logs'; +import { makeBlockHeader, makeL2Tips, randomPrivateLogResult } from '@aztec/stdlib/testing'; import { type MockProxy, mock } from 'jest-mock-extended'; @@ -25,7 +36,6 @@ describe('LogService', () => { let contractAddress: AztecAddress; let aztecNode: MockProxy; let keyStore: KeyStore; - let recipientTaggingStore: RecipientTaggingStore; let addressStore: AddressStore; let taggingSecretSourcesStore: TaggingSecretSourcesStore; let logService: LogService; @@ -34,32 +44,12 @@ describe('LogService', () => { const tag = Tag.random(); beforeEach(async () => { - // Set up contract address contractAddress = await AztecAddress.random(); - keyStore = new KeyStore(await openTmpStore('test')); - recipientTaggingStore = new RecipientTaggingStore(await openTmpStore('test')); - taggingSecretSourcesStore = new TaggingSecretSourcesStore(await openTmpStore('test')); - addressStore = new AddressStore(await openTmpStore('test')); - - aztecNode = mock(); + ({ aztecNode, keyStore, taggingSecretSourcesStore, addressStore, logService } = await createTestLogService()); aztecNode.getPrivateLogsByTags.mockReset(); aztecNode.getPublicLogsByTags.mockReset(); aztecNode.getTxEffect.mockReset(); - - // Set up anchor block header (required for bulkRetrieveLogs) - const anchorBlockHeader = makeBlockHeader(randomInt(1000), { blockNumber: BlockNumber(INITIAL_L2_BLOCK_NUM) }); - - logService = new LogService( - aztecNode, - anchorBlockHeader, - mock(), - keyStore, - recipientTaggingStore, - taggingSecretSourcesStore, - addressStore, - 'test', - ); }); it('returns empty arrays if no logs are found', async () => { @@ -322,8 +312,207 @@ describe('LogService', () => { }); }); }); + + describe('fetchTaggedLogs', () => { + let recipient: AztecAddress; + let sharedSecret: Point; + + beforeEach(async () => { + contractAddress = await AztecAddress.random(); + + const l2TipsProvider = mock(); + ({ aztecNode, keyStore, taggingSecretSourcesStore, addressStore, logService } = + await createTestLogService(l2TipsProvider)); + l2TipsProvider.getL2Tips.mockResolvedValue(makeL2Tips(0)); + + const completeAddress = await keyStore.addAccount(await deriveKeys(Fr.random()), Fr.random()); + await addressStore.addCompleteAddress(completeAddress); + recipient = completeAddress.address; + + sharedSecret = await Point.random(); + }); + + it('scans handshake secrets under the handshake derivation for both delivery modes', async () => { + await taggingSecretSourcesStore.addSharedSecret(recipient, 'handshake', sharedSecret); + const [unconstrainedTag, constrainedTag] = await handshakeTags(sharedSecret, contractAddress); + + const unconstrainedLog = randomPrivateLogResult({ includeEffects: true }); + const constrainedLog = randomPrivateLogResult({ includeEffects: true }); + servePrivateLogsByTag( + aztecNode, + new Map([ + [unconstrainedTag.toString(), unconstrainedLog], + [constrainedTag.toString(), constrainedLog], + ]), + ); + + const logs = await logService.fetchTaggedLogs(contractAddress, recipient, []); + + const txHashes = logs.map(l => l.context.txHash); + expect(txHashes).toContainEqual(unconstrainedLog.txHash); + expect(txHashes).toContainEqual(constrainedLog.txHash); + }); + + it('does not scan arbitrary secrets under the handshake derivation', async () => { + await taggingSecretSourcesStore.addSharedSecret(recipient, 'arbitrary-secret', sharedSecret); + const [unconstrainedTag, constrainedTag] = await handshakeTags(sharedSecret, contractAddress); + + const directionalLog = randomPrivateLogResult({ includeEffects: true }); + const handshakeStreamLog = randomPrivateLogResult({ includeEffects: true }); + servePrivateLogsByTag( + aztecNode, + new Map([ + [(await directionalTag(sharedSecret, contractAddress, recipient)).toString(), directionalLog], + [unconstrainedTag.toString(), handshakeStreamLog], + [constrainedTag.toString(), handshakeStreamLog], + ]), + ); + + const logs = await logService.fetchTaggedLogs(contractAddress, recipient, []); + + const txHashes = logs.map(l => l.context.txHash); + expect(txHashes).toContainEqual(directionalLog.txHash); + expect(txHashes).not.toContainEqual(handshakeStreamLog.txHash); + }); + + it('does not scan handshake secrets under the directional derivation', async () => { + await taggingSecretSourcesStore.addSharedSecret(recipient, 'handshake', sharedSecret); + const [unconstrainedTag] = await handshakeTags(sharedSecret, contractAddress); + + const handshakeStreamLog = randomPrivateLogResult({ includeEffects: true }); + const directionalLog = randomPrivateLogResult({ includeEffects: true }); + servePrivateLogsByTag( + aztecNode, + new Map([ + [unconstrainedTag.toString(), handshakeStreamLog], + [(await directionalTag(sharedSecret, contractAddress, recipient)).toString(), directionalLog], + ]), + ); + + const logs = await logService.fetchTaggedLogs(contractAddress, recipient, []); + + const txHashes = logs.map(l => l.context.txHash); + expect(txHashes).toContainEqual(handshakeStreamLog.txHash); + expect(txHashes).not.toContainEqual(directionalLog.txHash); + }); + + function handshakeTags(secret: Point, app: AztecAddress): Promise { + return Promise.all( + [AppTaggingSecretKind.UNCONSTRAINED, AppTaggingSecretKind.CONSTRAINED].map(async kind => + SiloedTag.compute({ extendedSecret: await AppTaggingSecret.computeAppSiloed(secret, app, kind), index: 0 }), + ), + ); + } + + async function directionalTag(secret: Point, app: AztecAddress, directedTo: AztecAddress): Promise { + const directionalSecret = await AppTaggingSecret.computeDirectional(secret, app, directedTo); + return SiloedTag.compute({ extendedSecret: directionalSecret, index: 0 }); + } + }); + + describe('address-derived discovery requires a registered sender', () => { + let recipientCompleteAddress: CompleteAddress; + let recipient: AztecAddress; + let sender: AztecAddress; + let senderIndex0Tag: SiloedTag; + let senderLog: LogResult; + + beforeEach(async () => { + contractAddress = await AztecAddress.random(); + + const l2TipsProvider = mock(); + const testContext = await createTestLogService(l2TipsProvider); + ({ aztecNode, keyStore, taggingSecretSourcesStore, addressStore, logService } = testContext); + l2TipsProvider.getL2Tips.mockResolvedValue(makeL2Tips(testContext.anchorBlockHeader.globalVariables.blockNumber)); + + // A real recipient account, so the ECDH tag derivation has the keys and address preimage it needs. + recipientCompleteAddress = await keyStore.addAccount(await deriveKeys(new Fr(1)), Fr.random()); + recipient = recipientCompleteAddress.address; + await addressStore.addCompleteAddress(recipientCompleteAddress); + + sender = await AztecAddress.random(); + + // Recompute, from the same ECDH inputs the service uses, the tag the recipient would scan for the sender's + // first message. The node returns the sender's log only for this exact tag, so discovery proves the tag was + // scanned, which only happens when the sender is registered. + const recipientIvsk = await keyStore.getMasterIncomingViewingSecretKey(recipient); + const sharedSecret = await computeSharedTaggingSecret(recipientCompleteAddress, recipientIvsk, sender); + const appSecret = await AppTaggingSecret.computeDirectional(sharedSecret!, contractAddress, recipient); + senderIndex0Tag = await SiloedTag.compute({ extendedSecret: appSecret, index: 0 }); + + // Past the anchor block, so the log is unfinalized and the scan completes in a single round. + senderLog = randomPrivateLogResult({ blockNumber: INITIAL_L2_BLOCK_NUM + 1, includeEffects: true }); + aztecNode.getPrivateLogsByTags.mockImplementation(({ tags }) => + Promise.resolve( + // A tag query is either a bare tag (first page) or a `{ tag, afterLog }` cursor (paginated follow-ups). + tags.map(query => { + const siloedTag = query instanceof SiloedTag ? query : query.tag; + return siloedTag.equals(senderIndex0Tag) ? [senderLog] : []; + }), + ), + ); + }); + + it('does not discover messages from an unregistered sender', async () => { + const discovered = await logService.fetchTaggedLogs(contractAddress, recipient, []); + expect(discovered).toEqual([]); + }); + + it('discovers the sender messages once the sender is registered', async () => { + await taggingSecretSourcesStore.addSender(sender); + + const discovered = await logService.fetchTaggedLogs(contractAddress, recipient, []); + + expect(discovered).toHaveLength(1); + expect(discovered[0].context.txHash).toEqual(senderLog.txHash); + }); + }); }); +async function createTestLogService(l2TipsProvider: MockProxy = mock()) { + const keyStore = new KeyStore(await openTmpStore('test')); + const recipientTaggingStore = new RecipientTaggingStore(await openTmpStore('test')); + const taggingSecretSourcesStore = new TaggingSecretSourcesStore(await openTmpStore('test')); + const addressStore = new AddressStore(await openTmpStore('test')); + const aztecNode = mock(); + // Anchor block header is required for bulkRetrieveLogs. + const anchorBlockHeader = makeBlockHeader(randomInt(1000), { blockNumber: BlockNumber(INITIAL_L2_BLOCK_NUM) }); + + const logService = new LogService( + aztecNode, + anchorBlockHeader, + l2TipsProvider, + keyStore, + recipientTaggingStore, + taggingSecretSourcesStore, + addressStore, + 'test', + ); + + return { + aztecNode, + keyStore, + recipientTaggingStore, + taggingSecretSourcesStore, + addressStore, + anchorBlockHeader, + logService, + }; +} + +/** Serves one private log per matching siloed tag and an empty page for every other tag. */ +function servePrivateLogsByTag(aztecNode: MockProxy, logsByTag: Map) { + aztecNode.getPrivateLogsByTags.mockImplementation(({ tags }) => + Promise.resolve( + tags.map(tagQuery => { + const tag = tagQuery instanceof SiloedTag ? tagQuery : tagQuery.tag; + const log = logsByTag.get(tag.toString()); + return log ? [log] : []; + }), + ), + ); +} + function makeLogRetrievalRequest( contractAddress: AztecAddress, tag: Tag, diff --git a/yarn-project/pxe/src/logs/log_service.ts b/yarn-project/pxe/src/logs/log_service.ts index f632741f190f..18dd7d2d2a55 100644 --- a/yarn-project/pxe/src/logs/log_service.ts +++ b/yarn-project/pxe/src/logs/log_service.ts @@ -7,7 +7,13 @@ import { AztecAddress } from '@aztec/stdlib/aztec-address'; import type { BlockHash, L2TipsProvider } from '@aztec/stdlib/block'; import type { CompleteAddress } from '@aztec/stdlib/contract'; import type { AztecNode } from '@aztec/stdlib/interfaces/server'; -import { AppTaggingSecret, type LogResult, SiloedTag, computeSharedTaggingSecret } from '@aztec/stdlib/logs'; +import { + AppTaggingSecret, + AppTaggingSecretKind, + type LogResult, + SiloedTag, + computeSharedTaggingSecret, +} from '@aztec/stdlib/logs'; import type { BlockHeader } from '@aztec/stdlib/tx'; import { @@ -222,10 +228,10 @@ export class LogService { /** * Computes the tagging secrets PXE can enumerate for a recipient: one per known sender (via ECDH) plus any - * pre-shared secret points registered directly for the recipient, each siloed to `contractAddress` and directed to - * `recipient`. These require knowing the recipient's address preimage and keys, so returns an empty array when those - * are unavailable. App-supplied secrets (e.g. handshake-derived) are handled separately by the caller and do not go - * through here. + * pre-shared secrets registered directly for the recipient. Each registered secret is scanned under the tag + * streams its kind can back. Deriving the sender-based secrets requires the recipient's address preimage and keys, + * so returns an empty array when those are unavailable. App-supplied secrets (e.g. derived from discovered + * handshakes) are handled separately by the caller and do not go through here. */ async #getPointDerivedSecrets(contractAddress: AztecAddress, recipient: AztecAddress): Promise { const recipientCompleteAddress = await this.addressStore.getCompleteAddress(recipient); @@ -237,11 +243,26 @@ export class LogService { } const recipientIvsk = await this.keyStore.getMasterIncomingViewingSecretKey(recipient); - const points = [ - ...(await this.#getSecretsForSenders(recipientCompleteAddress, recipientIvsk)), - ...(await this.taggingSecretSourcesStore.getSharedSecretsForRecipient(recipient)), - ]; - return Promise.all(points.map(secret => AppTaggingSecret.computeDirectional(secret, contractAddress, recipient))); + const [senderPoints, registeredSecrets] = await Promise.all([ + this.#getSecretsForSenders(recipientCompleteAddress, recipientIvsk), + this.taggingSecretSourcesStore.getSharedSecretsForRecipient(recipient), + ]); + return Promise.all([ + ...senderPoints.map(secret => AppTaggingSecret.computeDirectional(secret, contractAddress, recipient)), + ...registeredSecrets.flatMap(({ kind, secret }) => { + switch (kind) { + case 'arbitrary-secret': + return [AppTaggingSecret.computeDirectional(secret, contractAddress, recipient)]; + case 'handshake': + // A handshake-backed sender tags messages with the bare app-siloed secret, one tag domain per delivery + // mode. + return [ + AppTaggingSecret.computeAppSiloed(secret, contractAddress, AppTaggingSecretKind.UNCONSTRAINED), + AppTaggingSecret.computeAppSiloed(secret, contractAddress, AppTaggingSecretKind.CONSTRAINED), + ]; + } + }), + ]); } async #getSecretsForSenders( diff --git a/yarn-project/pxe/src/pxe.test.ts b/yarn-project/pxe/src/pxe.test.ts index 92a3ab96f260..0dbf2e1af7af 100644 --- a/yarn-project/pxe/src/pxe.test.ts +++ b/yarn-project/pxe/src/pxe.test.ts @@ -29,7 +29,8 @@ import { getContractClassFromArtifact, } from '@aztec/stdlib/contract'; import type { AztecNode, AztecNodeDebug, BlockResponse } from '@aztec/stdlib/interfaces/client'; -import { deriveKeys } from '@aztec/stdlib/keys'; +import { computeAddressSecret, deriveKeys } from '@aztec/stdlib/keys'; +import { deriveEcdhSharedSecretPoint } from '@aztec/stdlib/logs'; import { randomContractArtifact, randomContractInstanceWithAddress, @@ -42,7 +43,7 @@ import { mock } from 'jest-mock-extended'; import type { MockProxy } from 'jest-mock-extended/lib/Mock.js'; import type { PXEConfig } from './config/index.js'; -import { PXE, type PackedPrivateEvent, type TaggingSecretSource } from './pxe.js'; +import { PXE, type PackedPrivateEvent } from './pxe.js'; import { PrivateEventStore } from './storage/private_event_store/private_event_store.js'; describe('PXE', () => { @@ -153,65 +154,114 @@ describe('PXE', () => { }); it('lists registered senders and arbitrary secrets together', async () => { - const senderSource: TaggingSecretSource = { - kind: 'address-derived', - sender: (await CompleteAddress.random()).address, - }; - const secretSource: TaggingSecretSource = { - kind: 'arbitrary-secret', - recipient: (await CompleteAddress.random()).address, - secret: await Point.random(), - }; - - await pxe.registerTaggingSecretSource(senderSource); - await pxe.registerTaggingSecretSource(secretSource); - - expect(await pxe.getTaggingSecretSources()).toEqual(expect.arrayContaining([senderSource, secretSource])); + const sender = (await CompleteAddress.random()).address; + const recipient = (await CompleteAddress.random()).address; + const secret = await Point.random(); + + await pxe.registerTaggingSecretSource({ kind: 'address-derived', sender }); + await pxe.registerTaggingSecretSource({ kind: 'arbitrary-secret', recipient, secret }); + + expect(await pxe.getTaggingSecretSources()).toEqual( + expect.arrayContaining([ + { kind: 'address-derived', sender }, + { kind: 'arbitrary-secret', recipient, secret }, + ]), + ); }); it('filters tagging secret sources by kind', async () => { - const senderSource: TaggingSecretSource = { - kind: 'address-derived', - sender: (await CompleteAddress.random()).address, - }; - const secretSource: TaggingSecretSource = { - kind: 'arbitrary-secret', - recipient: (await CompleteAddress.random()).address, - secret: await Point.random(), - }; + const sender = (await CompleteAddress.random()).address; + const recipient = (await CompleteAddress.random()).address; + const secret = await Point.random(); - await pxe.registerTaggingSecretSource(senderSource); - await pxe.registerTaggingSecretSource(secretSource); + await pxe.registerTaggingSecretSource({ kind: 'address-derived', sender }); + await pxe.registerTaggingSecretSource({ kind: 'arbitrary-secret', recipient, secret }); const senders = await pxe.getTaggingSecretSources({ kind: 'address-derived' }); - expect(senders).toContainEqual(senderSource); - expect(senders).not.toContainEqual(secretSource); + expect(senders).toContainEqual({ kind: 'address-derived', sender }); + expect(senders).not.toContainEqual({ kind: 'arbitrary-secret', recipient, secret }); const secrets = await pxe.getTaggingSecretSources({ kind: 'arbitrary-secret' }); - expect(secrets).toContainEqual(secretSource); - expect(secrets).not.toContainEqual(senderSource); + expect(secrets).toContainEqual({ kind: 'arbitrary-secret', recipient, secret }); + expect(secrets).not.toContainEqual({ kind: 'address-derived', sender }); }); it('removes registered senders and arbitrary secrets', async () => { - const senderSource: TaggingSecretSource = { - kind: 'address-derived', - sender: (await CompleteAddress.random()).address, - }; - const secretSource: TaggingSecretSource = { - kind: 'arbitrary-secret', - recipient: (await CompleteAddress.random()).address, - secret: await Point.random(), - }; + const sender = (await CompleteAddress.random()).address; + const recipient = (await CompleteAddress.random()).address; + const secret = await Point.random(); - await pxe.registerTaggingSecretSource(senderSource); - await pxe.registerTaggingSecretSource(secretSource); + await pxe.registerTaggingSecretSource({ kind: 'address-derived', sender }); + await pxe.registerTaggingSecretSource({ kind: 'arbitrary-secret', recipient, secret }); - await pxe.removeTaggingSecretSource(senderSource); - await pxe.removeTaggingSecretSource(secretSource); + await pxe.removeTaggingSecretSource({ kind: 'address-derived', sender }); + await pxe.removeTaggingSecretSource({ kind: 'arbitrary-secret', recipient, secret }); const remaining = await pxe.getTaggingSecretSources(); - expect(remaining).not.toContainEqual(senderSource); - expect(remaining).not.toContainEqual(secretSource); + expect(remaining).not.toContainEqual({ kind: 'address-derived', sender }); + expect(remaining).not.toContainEqual({ kind: 'arbitrary-secret', recipient, secret }); + }); + + it('registers a handshake by ephemeral key and lists its derived shared secret', async () => { + const privacyKeys = await deriveKeys(Fr.random()); + const completeAddress = await pxe.registerAccount(privacyKeys, Fr.random()); + const recipient = completeAddress.address; + const ephPk = (await Point.random()).x; + + await pxe.registerTaggingSecretSource({ kind: 'handshake', recipient, ephPk }); + + const addressSecret = await computeAddressSecret( + await completeAddress.getPreaddress(), + privacyKeys.masterIncomingViewingSecretKey, + ); + const expectedSecret = await deriveEcdhSharedSecretPoint(addressSecret, await Point.fromXAndSign(ephPk, true)); + expect(await pxe.getTaggingSecretSources({ kind: 'handshake' })).toContainEqual({ + kind: 'handshake', + recipient, + secret: expectedSecret, + }); + }); + + it('ignores a handshake registered twice for the same ephemeral key', async () => { + const { address: recipient } = await pxe.registerAccount(await deriveKeys(Fr.random()), Fr.random()); + const ephPk = (await Point.random()).x; + + await pxe.registerTaggingSecretSource({ kind: 'handshake', recipient, ephPk }); + await pxe.registerTaggingSecretSource({ kind: 'handshake', recipient, ephPk }); + + const secrets = await pxe.getTaggingSecretSources({ kind: 'handshake' }); + expect(secrets.filter(s => s.recipient.equals(recipient))).toHaveLength(1); + }); + + it('refuses to register a handshake for a recipient that is not an account', async () => { + const recipient = (await CompleteAddress.random()).address; + await expect( + pxe.registerTaggingSecretSource({ kind: 'handshake', recipient, ephPk: (await Point.random()).x }), + ).rejects.toThrow(/not an account/); + }); + + it('refuses to register a handshake whose ephemeral key x-coordinate is not on the curve', async () => { + const { address: recipient } = await pxe.registerAccount(await deriveKeys(Fr.random()), Fr.random()); + // x = 3 is not a valid x-coordinate on the Grumpkin curve + const ephPk = new Fr(3); + await expect(pxe.registerTaggingSecretSource({ kind: 'handshake', recipient, ephPk })).rejects.toThrow( + `Ephemeral public key x-coordinate ${ephPk} does not correspond to a Grumpkin curve point.`, + ); + }); + + it('removes a registered handshake by its listed secret', async () => { + const { address: recipient } = await pxe.registerAccount(await deriveKeys(Fr.random()), Fr.random()); + const ephPk = (await Point.random()).x; + + await pxe.registerTaggingSecretSource({ kind: 'handshake', recipient, ephPk }); + + const [listed] = (await pxe.getTaggingSecretSources({ kind: 'handshake' })).filter(s => + s.recipient.equals(recipient), + ); + await pxe.removeTaggingSecretSource(listed); + + const secrets = await pxe.getTaggingSecretSources({ kind: 'handshake' }); + expect(secrets.filter(s => s.recipient.equals(recipient))).toHaveLength(0); }); it('successfully adds a contract', async () => { diff --git a/yarn-project/pxe/src/pxe.ts b/yarn-project/pxe/src/pxe.ts index 0e443ad9e45f..d27cc8b58803 100644 --- a/yarn-project/pxe/src/pxe.ts +++ b/yarn-project/pxe/src/pxe.ts @@ -36,6 +36,8 @@ import type { PrivateKernelExecutionProofOutput, PrivateKernelTailCircuitPublicInputs, } from '@aztec/stdlib/kernel'; +import { computeAddressSecret } from '@aztec/stdlib/keys'; +import { deriveEcdhSharedSecretPoint } from '@aztec/stdlib/logs'; import { BlockHeader, type ContractOverrides, @@ -87,7 +89,10 @@ import { openPxeStores } from './storage/open_pxe_stores.js'; import { PrivateEventStore } from './storage/private_event_store/private_event_store.js'; import { RecipientTaggingStore } from './storage/tagging_store/recipient_tagging_store.js'; import { SenderTaggingStore } from './storage/tagging_store/sender_tagging_store.js'; -import { TaggingSecretSourcesStore } from './storage/tagging_store/tagging_secret_sources_store.js'; +import { + type SharedSecretKind, + TaggingSecretSourcesStore, +} from './storage/tagging_store/tagging_secret_sources_store.js'; import { persistSenderTaggingIndexRangesForTx } from './tagging/index.js'; export type PackedPrivateEvent = InTx & { @@ -191,18 +196,33 @@ export type PXECreateArgs = { hooks?: ExecutionHooks; }; -/** - * A source from which PXE derives the tagging secrets it scans for to discover incoming private logs. - * - * - `address-derived`: derives a shared secret via ECDH from an external `sender` address against every account - * registered in this PXE (present and future), so registering one sender applies it to all of them. The address is - * not secret, so unlike `arbitrary-secret` it can be reused freely across recipients. - * - `arbitrary-secret`: a shared secret point provided directly, scoped to a single recipient. It bypasses ECDH, so it - * must not be reused across recipients (each would then be able to find the others' tags). - */ +/** A source from which PXE derives the tagging secrets it scans for to discover incoming private logs. */ export type TaggingSecretSource = + /** + * Derives a shared secret via ECDH from an external `sender` address against every account registered in this PXE + * (present and future), so registering one sender applies it to all of them. The address is not secret, so unlike + * `arbitrary-secret` it can be reused freely across recipients. + */ | { kind: 'address-derived'; sender: AztecAddress } - | { kind: 'arbitrary-secret'; recipient: AztecAddress; secret: Point }; + /** + * A shared secret point provided directly, scoped to a single recipient. It bypasses ECDH, so it must not be + * reused across recipients (each would then be able to find the others' tags). + */ + | { kind: 'arbitrary-secret'; recipient: AztecAddress; secret: Point } + /** + * A handshake known by the x-coordinate of its ephemeral public key (the value a recipient learns while authorizing + * an interactive handshake, which emits no discoverable announcement). The ephemeral key's y-coordinate is always + * positive by protocol construction, so the x-coordinate alone identifies it. + */ + | { kind: 'handshake'; recipient: AztecAddress; ephPk: Fr }; + +/** + * The registered form of a {@link TaggingSecretSource}. + */ +export type RegisteredTaggingSecretSource = + | Exclude + /** A handshake's resolved shared secret, derived at registration from the handshake's ephemeral key. */ + | { kind: 'handshake'; recipient: AztecAddress; secret: Point }; /** * Private eXecution Environment (PXE) is a library used by wallets to simulate private phase of transactions and to @@ -769,7 +789,14 @@ export class PXE { wasAdded = await this.#registerSender(source.sender); break; case 'arbitrary-secret': - wasAdded = await this.#registerArbitrarySecret(source.recipient, source.secret); + wasAdded = await this.#registerSharedSecret(source.recipient, 'arbitrary-secret', source.secret); + break; + case 'handshake': + wasAdded = await this.#registerSharedSecret( + source.recipient, + 'handshake', + await this.#deriveHandshakeSecret(source.recipient, source.ephPk), + ); break; default: { const _: never = source; @@ -784,9 +811,10 @@ export class PXE { } /** - * Removes a previously registered tagging secret source. Does nothing if it was not registered. + * Removes a previously registered tagging secret source, identified by its registered form (see + * {@link getTaggingSecretSources}). Does nothing if it was not registered. */ - public async removeTaggingSecretSource(source: TaggingSecretSource): Promise { + public async removeTaggingSecretSource(source: RegisteredTaggingSecretSource): Promise { switch (source.kind) { case 'address-derived': { const { sender } = source; @@ -798,13 +826,14 @@ export class PXE { ); break; } - case 'arbitrary-secret': { - const { recipient, secret } = source; - const wasRemoved = await this.taggingSecretSourcesStore.removeSharedSecret(recipient, secret); + case 'arbitrary-secret': + case 'handshake': { + const { kind, recipient, secret } = source; + const wasRemoved = await this.taggingSecretSourcesStore.removeSharedSecret(recipient, kind, secret); this.log.info( wasRemoved - ? `Removed shared secret for recipient:\n ${recipient.toString()}` - : `Shared secret not registered for recipient:\n ${recipient.toString()}`, + ? `Removed ${kind} shared secret for recipient:\n ${recipient.toString()}` + : `No ${kind} shared secret registered for recipient:\n ${recipient.toString()}`, ); break; } @@ -816,24 +845,24 @@ export class PXE { } /** - * Retrieves the tagging secret sources registered in this PXE. Without a filter it returns every source; pass - * `{ kind }` to narrow to a single variant. See {@link TaggingSecretSource}. + * Retrieves the tagging secret sources registered in this PXE, in their registered form. Without a filter it + * returns every source; pass `{ kind }` to narrow to a single variant. See {@link RegisteredTaggingSecretSource}. */ - public getTaggingSecretSources(filter: { + public getTaggingSecretSources(filter: { kind: K; - }): Promise[]>; - public getTaggingSecretSources(): Promise; + }): Promise[]>; + public getTaggingSecretSources(): Promise; public async getTaggingSecretSources(filter?: { - kind?: TaggingSecretSource['kind']; - }): Promise { + kind?: RegisteredTaggingSecretSource['kind']; + }): Promise { const [senders, secrets] = await Promise.all([ this.taggingSecretSourcesStore.getSenders(), this.taggingSecretSourcesStore.getAllSharedSecrets(), ]); - const sources: TaggingSecretSource[] = [ - ...senders.map((sender): TaggingSecretSource => ({ kind: 'address-derived', sender })), - ...secrets.map(({ recipient, secret }): TaggingSecretSource => ({ kind: 'arbitrary-secret', recipient, secret })), + const sources: RegisteredTaggingSecretSource[] = [ + ...senders.map((sender): RegisteredTaggingSecretSource => ({ kind: 'address-derived', sender })), + ...secrets.map(({ recipient, kind, secret }): RegisteredTaggingSecretSource => ({ kind, recipient, secret })), ]; return filter?.kind ? sources.filter(source => source.kind === filter.kind) : sources; @@ -860,8 +889,36 @@ export class PXE { return wasAdded; } - /** Registers a directly-provided shared secret scoped to a recipient. Returns whether it was newly added. */ - async #registerArbitrarySecret(recipient: AztecAddress, secret: Point): Promise { + /** + * Derives the shared secret of a handshake from its ephemeral public key: `S = addressSecret(recipient) * ephPk`, + * the same derivation the recipient-side scan uses for handshakes discovered onchain. The ephemeral key is + * reconstructed from its x-coordinate with a positive y, matching how the protocol generates it. + * + * @throws If `ephPk` is not the x-coordinate of a Grumpkin curve point, or if `recipient` is not an account of + * this PXE, since the derivation needs its keys. + */ + async #deriveHandshakeSecret(recipient: AztecAddress, ephPk: Fr): Promise { + let ephPkPoint: Point; + try { + ephPkPoint = await Point.fromXAndSign(ephPk, true); + } catch { + throw new Error(`Ephemeral public key x-coordinate ${ephPk} does not correspond to a Grumpkin curve point.`); + } + + const completeAddress = await this.addressStore.getCompleteAddress(recipient); + if (!completeAddress || !(await this.keyStore.hasAccount(recipient))) { + throw new Error( + `Recipient ${recipient} is not an account of this PXE. A handshake's shared secret can only be derived for an account whose keys are held.`, + ); + } + + const ivskM = await this.keyStore.getMasterIncomingViewingSecretKey(recipient); + const addressSecret = await computeAddressSecret(await completeAddress.getPreaddress(), ivskM); + return deriveEcdhSharedSecretPoint(addressSecret, ephPkPoint); + } + + /** Registers a resolved shared secret scoped to a recipient. Returns whether it was newly added. */ + async #registerSharedSecret(recipient: AztecAddress, kind: SharedSecretKind, secret: Point): Promise { if (!(await recipient.isValid())) { throw new Error( `Recipient ${recipient} is not valid: it does not correspond to a point on the Grumpkin curve. Cannot register a shared secret for it.`, @@ -872,11 +929,11 @@ export class PXE { throw new Error(`Shared secret ${secret} is not a valid non-zero point on the Grumpkin curve.`); } - const wasAdded = await this.taggingSecretSourcesStore.addSharedSecret(recipient, secret); + const wasAdded = await this.taggingSecretSourcesStore.addSharedSecret(recipient, kind, secret); this.log.info( wasAdded - ? `Added shared secret for recipient:\n ${recipient.toString()}` - : `Shared secret already registered for recipient:\n ${recipient.toString()}`, + ? `Added ${kind} shared secret for recipient:\n ${recipient.toString()}` + : `${kind} shared secret already registered for recipient:\n ${recipient.toString()}`, ); return wasAdded; } diff --git a/yarn-project/pxe/src/storage/backwards_compatibility_tests/__snapshots__/TaggingSecretSourcesStore.json b/yarn-project/pxe/src/storage/backwards_compatibility_tests/__snapshots__/TaggingSecretSourcesStore.json index 1c612022458a..61cebcc03e02 100644 --- a/yarn-project/pxe/src/storage/backwards_compatibility_tests/__snapshots__/TaggingSecretSourcesStore.json +++ b/yarn-project/pxe/src/storage/backwards_compatibility_tests/__snapshots__/TaggingSecretSourcesStore.json @@ -16,15 +16,15 @@ "recipient_shared_secrets": [ { "key": "utf8:0x0000000000000000000000000000000000000000000000000000000000000007", - "value": "utf8:0x00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000003" + "value": "{\"kind\":\"arbitrary-secret\",\"secret\":\"0x00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000003\"}" }, { "key": "utf8:0x0000000000000000000000000000000000000000000000000000000000000007", - "value": "utf8:0x00000000000000000000000000000000000000000000000000000000000000050000000000000000000000000000000000000000000000000000000000000007" + "value": "{\"kind\":\"handshake\",\"secret\":\"0x00000000000000000000000000000000000000000000000000000000000000050000000000000000000000000000000000000000000000000000000000000007\"}" }, { "key": "utf8:0x000000000000000000000000000000000000000000000000000000000000000b", - "value": "utf8:0x000000000000000000000000000000000000000000000000000000000000000d0000000000000000000000000000000000000000000000000000000000000011" + "value": "{\"kind\":\"arbitrary-secret\",\"secret\":\"0x000000000000000000000000000000000000000000000000000000000000000d0000000000000000000000000000000000000000000000000000000000000011\"}" } ] } diff --git a/yarn-project/pxe/src/storage/backwards_compatibility_tests/__snapshots__/opened_stores.json b/yarn-project/pxe/src/storage/backwards_compatibility_tests/__snapshots__/opened_stores.json index b4ad55b3b0ad..5afea8e276cf 100644 --- a/yarn-project/pxe/src/storage/backwards_compatibility_tests/__snapshots__/opened_stores.json +++ b/yarn-project/pxe/src/storage/backwards_compatibility_tests/__snapshots__/opened_stores.json @@ -1,5 +1,5 @@ { - "schemaVersion": 11, + "schemaVersion": 12, "stores": [ { "name": "capsules", diff --git a/yarn-project/pxe/src/storage/backwards_compatibility_tests/schema_tests.ts b/yarn-project/pxe/src/storage/backwards_compatibility_tests/schema_tests.ts index 49734a0d4228..196f97b4cd67 100644 --- a/yarn-project/pxe/src/storage/backwards_compatibility_tests/schema_tests.ts +++ b/yarn-project/pxe/src/storage/backwards_compatibility_tests/schema_tests.ts @@ -553,20 +553,25 @@ export const SCHEMA_TESTS: readonly SchemaTest[] = [ await taggingSecretSourcesStore.addSharedSecret( AztecAddress.fromBigIntUnsafe(7n), + 'arbitrary-secret', new Point(new Fr(2n), new Fr(3n)), ); await taggingSecretSourcesStore.addSharedSecret( AztecAddress.fromBigIntUnsafe(7n), + 'handshake', new Point(new Fr(5n), new Fr(7n)), ); await taggingSecretSourcesStore.addSharedSecret( AztecAddress.fromBigIntUnsafe(11n), + 'arbitrary-secret', new Point(new Fr(13n), new Fr(17n)), ); }, snapshotStore: async kvStore => ({ senders: await snapshotMap(kvStore.openMap('senders')), - recipient_shared_secrets: await snapshotMap(kvStore.openMultiMap('recipient_shared_secrets')), + recipient_shared_secrets: await snapshotMap( + kvStore.openMultiMap('recipient_shared_secrets'), + ), }), }, diff --git a/yarn-project/pxe/src/storage/metadata.ts b/yarn-project/pxe/src/storage/metadata.ts index 7a2e20431042..ca02f2aa5901 100644 --- a/yarn-project/pxe/src/storage/metadata.ts +++ b/yarn-project/pxe/src/storage/metadata.ts @@ -1 +1 @@ -export const PXE_DATA_SCHEMA_VERSION = 11; +export const PXE_DATA_SCHEMA_VERSION = 12; diff --git a/yarn-project/pxe/src/storage/tagging_store/tagging_secret_sources_store.test.ts b/yarn-project/pxe/src/storage/tagging_store/tagging_secret_sources_store.test.ts index 688ce7986a76..c62ed74bfbe0 100644 --- a/yarn-project/pxe/src/storage/tagging_store/tagging_secret_sources_store.test.ts +++ b/yarn-project/pxe/src/storage/tagging_store/tagging_secret_sources_store.test.ts @@ -26,21 +26,41 @@ describe('TaggingSecretSourcesStore', () => { }); describe('shared secrets', () => { - it('adds and retrieves shared secrets scoped to a recipient', async () => { + it('adds and retrieves an arbitrary secret scoped to a recipient', async () => { const recipient = await AztecAddress.random(); const secret = await Point.random(); - expect(await store.addSharedSecret(recipient, secret)).toBe(true); - expect(await store.getSharedSecretsForRecipient(recipient)).toEqual([secret]); + expect(await store.addSharedSecret(recipient, 'arbitrary-secret', secret)).toBe(true); + expect(await store.getSharedSecretsForRecipient(recipient)).toEqual([{ kind: 'arbitrary-secret', secret }]); + }); + + it('round-trips the kind of a handshake secret', async () => { + const recipient = await AztecAddress.random(); + const secret = await Point.random(); + + expect(await store.addSharedSecret(recipient, 'handshake', secret)).toBe(true); + expect(await store.getSharedSecretsForRecipient(recipient)).toEqual([{ kind: 'handshake', secret }]); }); it('returns false when adding a duplicate secret for the same recipient', async () => { const recipient = await AztecAddress.random(); const secret = await Point.random(); - expect(await store.addSharedSecret(recipient, secret)).toBe(true); - expect(await store.addSharedSecret(recipient, secret)).toBe(false); - expect(await store.getSharedSecretsForRecipient(recipient)).toEqual([secret]); + expect(await store.addSharedSecret(recipient, 'arbitrary-secret', secret)).toBe(true); + expect(await store.addSharedSecret(recipient, 'arbitrary-secret', secret)).toBe(false); + expect(await store.getSharedSecretsForRecipient(recipient)).toEqual([{ kind: 'arbitrary-secret', secret }]); + }); + + it('rejects re-registering a secret under a different kind', async () => { + const recipient = await AztecAddress.random(); + const secret = await Point.random(); + + await store.addSharedSecret(recipient, 'handshake', secret); + + await expect(store.addSharedSecret(recipient, 'arbitrary-secret', secret)).rejects.toThrow( + `Secret already registered for recipient with kind 'handshake', cannot re-register it as 'arbitrary-secret'.`, + ); + expect(await store.getSharedSecretsForRecipient(recipient)).toEqual([{ kind: 'handshake', secret }]); }); it('scopes secrets per recipient', async () => { @@ -49,11 +69,15 @@ describe('TaggingSecretSourcesStore', () => { const secretA = await Point.random(); const secretB = await Point.random(); - await store.addSharedSecret(recipientA, secretA); - await store.addSharedSecret(recipientB, secretB); + await store.addSharedSecret(recipientA, 'arbitrary-secret', secretA); + await store.addSharedSecret(recipientB, 'arbitrary-secret', secretB); - expect(await store.getSharedSecretsForRecipient(recipientA)).toEqual([secretA]); - expect(await store.getSharedSecretsForRecipient(recipientB)).toEqual([secretB]); + expect(await store.getSharedSecretsForRecipient(recipientA)).toEqual([ + { kind: 'arbitrary-secret', secret: secretA }, + ]); + expect(await store.getSharedSecretsForRecipient(recipientB)).toEqual([ + { kind: 'arbitrary-secret', secret: secretB }, + ]); }); it('lists every shared secret across recipients', async () => { @@ -63,18 +87,18 @@ describe('TaggingSecretSourcesStore', () => { const secretA2 = await Point.random(); const secretB = await Point.random(); - await store.addSharedSecret(recipientA, secretA1); - await store.addSharedSecret(recipientA, secretA2); - await store.addSharedSecret(recipientB, secretB); + await store.addSharedSecret(recipientA, 'arbitrary-secret', secretA1); + await store.addSharedSecret(recipientA, 'handshake', secretA2); + await store.addSharedSecret(recipientB, 'arbitrary-secret', secretB); const all = await store.getAllSharedSecrets(); expect(all).toHaveLength(3); expect(all).toEqual( expect.arrayContaining([ - { recipient: recipientA, secret: secretA1 }, - { recipient: recipientA, secret: secretA2 }, - { recipient: recipientB, secret: secretB }, + { recipient: recipientA, kind: 'arbitrary-secret', secret: secretA1 }, + { recipient: recipientA, kind: 'handshake', secret: secretA2 }, + { recipient: recipientB, kind: 'arbitrary-secret', secret: secretB }, ]), ); }); @@ -87,9 +111,9 @@ describe('TaggingSecretSourcesStore', () => { const recipient = await AztecAddress.random(); const secret = await Point.random(); - await store.addSharedSecret(recipient, secret); + await store.addSharedSecret(recipient, 'arbitrary-secret', secret); - expect(await store.removeSharedSecret(recipient, secret)).toBe(true); + expect(await store.removeSharedSecret(recipient, 'arbitrary-secret', secret)).toBe(true); expect(await store.getSharedSecretsForRecipient(recipient)).toEqual([]); }); @@ -97,7 +121,17 @@ describe('TaggingSecretSourcesStore', () => { const recipient = await AztecAddress.random(); const secret = await Point.random(); - expect(await store.removeSharedSecret(recipient, secret)).toBe(false); + expect(await store.removeSharedSecret(recipient, 'arbitrary-secret', secret)).toBe(false); + }); + + it('does not remove a secret when the kind does not match', async () => { + const recipient = await AztecAddress.random(); + const secret = await Point.random(); + + await store.addSharedSecret(recipient, 'handshake', secret); + + expect(await store.removeSharedSecret(recipient, 'arbitrary-secret', secret)).toBe(false); + expect(await store.getSharedSecretsForRecipient(recipient)).toEqual([{ kind: 'handshake', secret }]); }); it('keeps senders and shared secrets separate', async () => { @@ -106,10 +140,10 @@ describe('TaggingSecretSourcesStore', () => { const secret = await Point.random(); await store.addSender(sender); - await store.addSharedSecret(recipient, secret); + await store.addSharedSecret(recipient, 'arbitrary-secret', secret); expect(await store.getSenders()).toEqual([sender]); - expect(await store.getSharedSecretsForRecipient(recipient)).toEqual([secret]); + expect(await store.getSharedSecretsForRecipient(recipient)).toEqual([{ kind: 'arbitrary-secret', secret }]); }); }); }); diff --git a/yarn-project/pxe/src/storage/tagging_store/tagging_secret_sources_store.ts b/yarn-project/pxe/src/storage/tagging_store/tagging_secret_sources_store.ts index 5e228340448a..b30ab04a4465 100644 --- a/yarn-project/pxe/src/storage/tagging_store/tagging_secret_sources_store.ts +++ b/yarn-project/pxe/src/storage/tagging_store/tagging_secret_sources_store.ts @@ -3,6 +3,14 @@ import { toArray } from '@aztec/foundation/iterable'; import type { AztecAsyncKVStore, AztecAsyncMap, AztecAsyncMultiMap } from '@aztec/kv-store'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; +/** + * The kinds of shared secret an entry can hold. + */ +export type SharedSecretKind = 'arbitrary-secret' | 'handshake'; + +/** A shared secret registered for a recipient, with the kind that determines which tag streams it backs. */ +export type SharedSecret = { kind: SharedSecretKind; secret: Point }; + /** * Stores the sources from which directional app tagging secrets are derived during recipient log synchronization. * @@ -13,12 +21,13 @@ import { AztecAddress } from '@aztec/stdlib/aztec-address'; * - Pre-shared tagging secrets: shared secret points registered directly, bypassing ECDH. These are scoped to a * specific recipient, since the derivation of the directional app tagging secret does not require any secret * recipient data: given the original secret anyone can derive a recipient's app-siloed directional tagging secret, - * and so these must not be reused to preserve privacy. + * and so these must not be reused across recipients to preserve privacy. Each carries the {@link SharedSecretKind} + * it was registered through, which determines the tag streams it is scanned under. */ export class TaggingSecretSourcesStore { #store: AztecAsyncKVStore; #senders: AztecAsyncMap; - #sharedSecretsByRecipient: AztecAsyncMultiMap; + #sharedSecretsByRecipient: AztecAsyncMultiMap; constructor(store: AztecAsyncKVStore) { this.#store = store; @@ -61,52 +70,57 @@ export class TaggingSecretSourcesStore { * Registers a pre-shared tagging secret scoped to a recipient. * @returns true if the secret was newly added, false if it was already registered for that recipient. */ - addSharedSecret(recipient: AztecAddress, secret: Point): Promise { + addSharedSecret(recipient: AztecAddress, kind: SharedSecretKind, secret: Point): Promise { return this.#store.transactionAsync(async () => { const secretStr = secret.toString(); // MultiMap.set silently ignores an identical (key, value), so we scan to report whether this is a new secret. for await (const existing of this.#sharedSecretsByRecipient.getValuesAsync(recipient.toString())) { - if (existing === secretStr) { + if (existing.secret === secretStr) { + if (existing.kind !== kind) { + throw new Error( + `Secret already registered for recipient with kind '${existing.kind}', cannot re-register it as '${kind}'.`, + ); + } return false; } } - await this.#sharedSecretsByRecipient.set(recipient.toString(), secretStr); + await this.#sharedSecretsByRecipient.set(recipient.toString(), { kind, secret: secretStr }); return true; }); } /** Returns the pre-shared tagging secrets registered for a given recipient. */ - getSharedSecretsForRecipient(recipient: AztecAddress): Promise { + getSharedSecretsForRecipient(recipient: AztecAddress): Promise { return this.#store.transactionAsync(async () => { - return (await toArray(this.#sharedSecretsByRecipient.getValuesAsync(recipient.toString()))).map(secret => - Point.fromString(secret), + return (await toArray(this.#sharedSecretsByRecipient.getValuesAsync(recipient.toString()))).map( + deserializeSharedSecret, ); }); } /** Returns every registered pre-shared tagging secret, each paired with the recipient it is scoped to. */ - getAllSharedSecrets(): Promise<{ recipient: AztecAddress; secret: Point }[]> { + getAllSharedSecrets(): Promise<{ recipient: AztecAddress; kind: SharedSecretKind; secret: Point }[]> { return this.#store.transactionAsync(async () => { const entries = await toArray(this.#sharedSecretsByRecipient.entriesAsync()); - return entries.map(([recipient, secret]) => ({ + return entries.map(([recipient, entry]) => ({ recipient: AztecAddress.fromStringUnsafe(recipient), - secret: Point.fromString(secret), + ...deserializeSharedSecret(entry), })); }); } /** - * Removes a pre-shared tagging secret scoped to a recipient. + * Removes a pre-shared tagging secret scoped to a recipient. Both the secret and its kind must match. * @returns true if the secret was registered and removed, false if it was not registered for that recipient. */ - removeSharedSecret(recipient: AztecAddress, secret: Point): Promise { + removeSharedSecret(recipient: AztecAddress, kind: SharedSecretKind, secret: Point): Promise { return this.#store.transactionAsync(async () => { const secretStr = secret.toString(); for await (const existing of this.#sharedSecretsByRecipient.getValuesAsync(recipient.toString())) { - if (existing === secretStr) { - await this.#sharedSecretsByRecipient.deleteValue(recipient.toString(), secretStr); + if (existing.kind === kind && existing.secret === secretStr) { + await this.#sharedSecretsByRecipient.deleteValue(recipient.toString(), existing); return true; } } @@ -115,3 +129,10 @@ export class TaggingSecretSourcesStore { }); } } + +/** The wire form a shared secret entry is persisted as. */ +type StoredSharedSecret = { kind: SharedSecretKind; secret: string }; + +function deserializeSharedSecret(entry: StoredSharedSecret): SharedSecret { + return { kind: entry.kind, secret: Point.fromString(entry.secret) }; +} diff --git a/yarn-project/standard-contracts/src/standard_contract_data.ts b/yarn-project/standard-contracts/src/standard_contract_data.ts index e8c5fc069cd5..fac883e67b33 100644 --- a/yarn-project/standard-contracts/src/standard_contract_data.ts +++ b/yarn-project/standard-contracts/src/standard_contract_data.ts @@ -20,21 +20,21 @@ export const StandardContractSalt: Record = { }; export const StandardContractAddress: Record = { - AuthRegistry: AztecAddress.fromStringUnsafe('0x0bbe366bb57059b03929228f7e886b17210cf268e021608392f58cb8f2574f43'), + AuthRegistry: AztecAddress.fromStringUnsafe('0x0292b01f4e566555534c40ed729eb023cc08afaca647b8c3642738449673480c'), MultiCallEntrypoint: AztecAddress.fromStringUnsafe( - '0x252e4f53e6bef46e70a37168c52a9eba7501944a40c63f0aa807cf2f823260d9', + '0x18470168ca7a775b442cde110b668732f7b6390a505f3784b50f39993c5f3dce', ), - PublicChecks: AztecAddress.fromStringUnsafe('0x05c207d39f7a51de9de6cfd37ab7764bbc9d0434481c4448fc6ddbeac2f67636'), + PublicChecks: AztecAddress.fromStringUnsafe('0x2d7745526508f5ceb278bb773a49f776842d1d2f0854c4a385bb661a2fabb9a8'), HandshakeRegistry: AztecAddress.fromStringUnsafe( - '0x0fe4285eada74ef13ea09468db7fcb69861161276f81f24a99e152e5871c5081', + '0x0bd62e76a9a3a103dae2e73040e05d3970ac900baff5637ae9f183396745ec7e', ), }; export const StandardContractClassId: Record = { - AuthRegistry: Fr.fromString('0x2f4a272a86482af67aa5beb9db9ea648080f0e140baf55a0af153421ab19e12d'), - MultiCallEntrypoint: Fr.fromString('0x2b2d3d423944a96cc01b3d332ba25e8a53aa486740d68047ca4df1b1c849a8e5'), - PublicChecks: Fr.fromString('0x1c14245c8afceb64326b033fd1841e78e844124e40c49a36712f57dfffd258d6'), - HandshakeRegistry: Fr.fromString('0x250d9a5696197bc0aaa73bd6fe01b89e33a796d91b18b847cc419e433a5cfaf9'), + AuthRegistry: Fr.fromString('0x1f8810cff8690ef53281da87fcda9d4820154d6dcd76012c90bf459b3ba3ab7a'), + MultiCallEntrypoint: Fr.fromString('0x282930170063677f616bcf9dc9fbfd4968a577dbbcdec0ea3c06283a44bcc743'), + PublicChecks: Fr.fromString('0x2a0f1592c7f4c979b328ed030039a4e9a469aab30afa3a036735c338067abbcd'), + HandshakeRegistry: Fr.fromString('0x10fbd0602fe72a04c86e25728c8c2b94a359c684738768cbe4e44fb05f6a908c'), }; export const StandardContractClassIdPreimage: Record< @@ -42,23 +42,23 @@ export const StandardContractClassIdPreimage: Record< { artifactHash: Fr; privateFunctionsRoot: Fr; publicBytecodeCommitment: Fr } > = { AuthRegistry: { - artifactHash: Fr.fromString('0x01f4d890c55a73e9164007dfe9833d7fc9daaf29f49886d412188ba02b4c4b81'), + artifactHash: Fr.fromString('0x1bfa3469d2f70892f9ba54117abcf4ca52bb758e66be19b5d0ab86600202ab73'), privateFunctionsRoot: Fr.fromString('0x17b584350f4c3ccafd8f688729afb9feab8976114fb40012e9dee65022c072a4'), publicBytecodeCommitment: Fr.fromString('0x2545f39893766508ce37bb5cea5e4dcab04c6f7f79f3089b1c076876e9d268b2'), }, MultiCallEntrypoint: { - artifactHash: Fr.fromString('0x158b25c22e41ecc134945b6f7fed7b0d523841a6e0ac78be1bdac989eb1ae6c0'), + artifactHash: Fr.fromString('0x05d7e89374f90684063f132a79de9b0f9553508de9ec1b83b8f6110d6ebbc21f'), privateFunctionsRoot: Fr.fromString('0x0e68dfbb256e80b08b3aef47aca1f2669e97a9c6259787893c1223ac083ad5d5'), publicBytecodeCommitment: Fr.fromString('0x0ce4c618c3ed7f3a20410e618c06bb701e150af7fe28a3e92f68e7733809f33e'), }, PublicChecks: { - artifactHash: Fr.fromString('0x18fbef2f3a4f54681c07f1eb9474851e43d5dce9a52cf1ebc0ca4bbebb96527f'), + artifactHash: Fr.fromString('0x0636977743e164f84fbd38ca408ad33794768c994f28dd8609729a8406d352dc'), privateFunctionsRoot: Fr.fromString('0x202860adb1b8975971eeaf571aaaa88a27f4035290d58532ae7d60b0dfaad54c'), publicBytecodeCommitment: Fr.fromString('0x013c4f854a5c87c9daf86c5f9bc07a42c2a061f1d924a5b3564ec7edc8e18cb7'), }, HandshakeRegistry: { - artifactHash: Fr.fromString('0x0e781457b14303befa6080e70bcec27e82a0081191063b9128561a4183722c59'), - privateFunctionsRoot: Fr.fromString('0x02a4dba36389845b8ef0108562f7536d3284f07ca678558fc3c3bce3b24ee821'), + artifactHash: Fr.fromString('0x2fc28e54f5f227307378f4620d793db72a4e1a7937e8cb31396b61a48ea7abef'), + privateFunctionsRoot: Fr.fromString('0x14f4bfbedd0d76e9b66d1f836a71457cb0f5b4f40280eec4a3ed40439440e252'), publicBytecodeCommitment: Fr.fromString('0x0ce4c618c3ed7f3a20410e618c06bb701e150af7fe28a3e92f68e7733809f33e'), }, }; @@ -96,19 +96,19 @@ export const StandardContractPrivateFunctions: Record< selector: FunctionSelector.fromField( Fr.fromString('0x0000000000000000000000000000000000000000000000000000000019f8b409'), ), - vkHash: Fr.fromString('0x25d87da668c7c52b9c521579f191bb02486b3598d1ec0063927d5c0af2608daf'), + vkHash: Fr.fromString('0x0b9ec5c76f08f8025800691659d7ba1432ddb8a30e40fdbd4d8f5c398b8c9ab7'), }, { selector: FunctionSelector.fromField( Fr.fromString('0x00000000000000000000000000000000000000000000000000000000db548fcf'), ), - vkHash: Fr.fromString('0x0f59fe525df9ff4a400e33dec33fd1dc07e25d95f860734a35b909085cf07809'), + vkHash: Fr.fromString('0x1c0f79ad358fc72c0f8cbac535d2d67f49dcea2f7d6c658eb38c7669f8ae2f93'), }, { selector: FunctionSelector.fromField( Fr.fromString('0x00000000000000000000000000000000000000000000000000000000f1ff839b'), ), - vkHash: Fr.fromString('0x12f43f384aa64661b012b52b1d80eb1d2202b8e459b50f56cfe270b0672c16ff'), + vkHash: Fr.fromString('0x2c2961a5e83daa909242c9ce441526c0a95379fda0976877acba4ffe7be949f3'), }, ], }; diff --git a/yarn-project/stdlib/src/logs/app_tagging_secret.ts b/yarn-project/stdlib/src/logs/app_tagging_secret.ts index 71c1a38a93e8..ad263cee3b06 100644 --- a/yarn-project/stdlib/src/logs/app_tagging_secret.ts +++ b/yarn-project/stdlib/src/logs/app_tagging_secret.ts @@ -56,6 +56,18 @@ export class AppTaggingSecret { return new AppTaggingSecret(directionalAppTaggingSecret, app); } + /** + * Derives the bare app-siloed tagging secret from a shared secret point via {@link appSiloEcdhSharedSecretPoint}, + * under the given delivery-mode kind. + */ + static async computeAppSiloed( + taggingSecretPoint: Point, + app: AztecAddress, + kind: AppTaggingSecretKind, + ): Promise { + return new AppTaggingSecret(await appSiloEcdhSharedSecretPoint(taggingSecretPoint, app), app, kind); + } + /** * Derives the tagging secret for `(externalAddress, recipient, app)` by performing an ECDH key exchange against * `externalAddress` to obtain the shared point, then siloing and directing it via {@link computeDirectional}. diff --git a/yarn-project/stdlib/src/logs/shared_secret_derivation.ts b/yarn-project/stdlib/src/logs/shared_secret_derivation.ts index 48c16284a683..4edf554b8c78 100644 --- a/yarn-project/stdlib/src/logs/shared_secret_derivation.ts +++ b/yarn-project/stdlib/src/logs/shared_secret_derivation.ts @@ -8,8 +8,22 @@ import type { AztecAddress } from '../aztec-address/index.js'; import type { PublicKey } from '../keys/public_key.js'; /** - * Derives an app-siloed ECDH shared secret from keys: ECDHs `S = secretKey * publicKey`, then app-silos it via - * {@link appSiloEcdhSharedSecretPoint}. + * Derives the raw ECDH shared secret point `S = secretKey * publicKey`. + * + * @throws If the publicKey is zero. + */ +export function deriveEcdhSharedSecretPoint(secretKey: GrumpkinScalar, publicKey: PublicKey): Promise { + if (publicKey.isZero()) { + throw new Error( + `Attempting to derive a shared secret with a zero public key. You have probably passed a zero public key in your Noir code somewhere thinking that the note won't be broadcast... but it was.`, + ); + } + return Grumpkin.mul(publicKey, secretKey); +} + +/** + * Derives an app-siloed ECDH shared secret from keys: ECDHs `S = secretKey * publicKey` via + * {@link deriveEcdhSharedSecretPoint}, then app-silos it via {@link appSiloEcdhSharedSecretPoint}. * * @param secretKey - The secret key used to derive shared secret. * @param publicKey - The public key used to derive shared secret. @@ -22,12 +36,7 @@ export async function appSiloEcdhSharedSecret( publicKey: PublicKey, contractAddress: AztecAddress, ): Promise { - if (publicKey.isZero()) { - throw new Error( - `Attempting to derive a shared secret with a zero public key. You have probably passed a zero public key in your Noir code somewhere thinking that the note won't be broadcast... but it was.`, - ); - } - const rawSharedSecret = await Grumpkin.mul(publicKey, secretKey); + const rawSharedSecret = await deriveEcdhSharedSecretPoint(secretKey, publicKey); return appSiloEcdhSharedSecretPoint(rawSharedSecret, contractAddress); } diff --git a/yarn-project/txe/src/index.ts b/yarn-project/txe/src/index.ts index 1ef4abeece02..65ab7c2ad7f8 100644 --- a/yarn-project/txe/src/index.ts +++ b/yarn-project/txe/src/index.ts @@ -21,9 +21,11 @@ import { } from './utils/encoding.js'; import { TXEArtifactResolver } from './utils/txe_artifact_resolver.js'; -// Protocol contracts TXE registers in its contract store. Only AuthRegistry is needed for the -// current test suites; add a contract here if a lookup against a `0x000…00X` address fails. -export const TXE_REQUIRED_PROTOCOL_CONTRACTS: ProtocolContractName[] = []; +export const TXE_REQUIRED_PROTOCOL_CONTRACTS: ProtocolContractName[] = [ + 'ContractClassRegistry', + 'ContractInstanceRegistry', + 'FeeJuice', +]; const sessions = new Map(); diff --git a/yarn-project/txe/src/oracle/test-resolver/default_fixtures.ts b/yarn-project/txe/src/oracle/test-resolver/default_fixtures.ts index d3a2034734a0..600ffbd980a4 100644 --- a/yarn-project/txe/src/oracle/test-resolver/default_fixtures.ts +++ b/yarn-project/txe/src/oracle/test-resolver/default_fixtures.ts @@ -1,5 +1,5 @@ import { AztecAddress } from '@aztec/aztec.js/addresses'; -import { BlockNumber } from '@aztec/foundation/branded-types'; +import { BlockNumber, SlotNumber } from '@aztec/foundation/branded-types'; import { Fr } from '@aztec/foundation/curves/bn254'; import { EthAddress } from '@aztec/foundation/eth-address'; import { @@ -17,20 +17,22 @@ import { NOTE_SELECTOR, Option, type OracleRegistryEntry, + SLOT_NUMBER, + type StructField, type TypeMapping, U32, + tryFieldWidth, } from '@aztec/pxe/simulator'; import { FunctionSelector, NoteSelector } from '@aztec/stdlib/abi'; import { BlockHash } from '@aztec/stdlib/block'; import { AppTaggingSecretKind } from '@aztec/stdlib/logs'; -import { CONTRACT_INSTANCE_MEMBER } from '../txe_oracle_registry.js'; import type { OracleTestScenario } from './resolver.js'; /** * Synthesizes fixture scenarios for every oracle in the registry whose parameters and return are built from scalars, - * arrays/bounded vecs of them, or `Option`s of them. The matching Noir serialization tests are auto-generated by - * `#[generate_oracle_tests]` from the same seed/scenario convention. + * arrays/bounded vecs of them, `Option`s of them, or `STRUCT`s over them. The matching Noir serialization tests are + * auto-generated by `#[generate_oracle_tests]` from the same seed/scenario convention. */ export function synthesizeDefaultFixtures( registry: Record, @@ -48,8 +50,8 @@ export function synthesizeDefaultFixtures( } /** - * The test-value implementations, one per oracle type, mirroring the Noir `OracleTestValue` trait exactly - * (`noir-projects/aztec-nr/.../macros/oracle_testing.nr`). + * The test-value implementations, one per oracle type, mirroring the Noir synthesizer tables (`scalars` and + * `synthesizers` in `noir-projects/aztec-nr/.../macros/oracle_testing.nr`) exactly. */ const TEST_VALUE_IMPLS: TestValueImpl[] = [ scalar(FIELD, seed => new Fr(seed)), @@ -63,11 +65,11 @@ const TEST_VALUE_IMPLS: TestValueImpl[] = [ scalar(FUNCTION_SELECTOR, seed => FunctionSelector.fromField(new Fr(seed))), scalar(NOTE_SELECTOR, seed => NoteSelector.fromField(new Fr(seed))), scalar(BLOCK_HASH, seed => new BlockHash(new Fr(seed))), + scalar(SLOT_NUMBER, seed => SlotNumber(seed)), // Only two delivery modes are valid for tagging, so the seed alternates between them, matching the Noir impl. scalar(DELIVERY_MODE, seed => seed % 2 === 0 ? AppTaggingSecretKind.UNCONSTRAINED : AppTaggingSecretKind.CONSTRAINED, ), - scalar(CONTRACT_INSTANCE_MEMBER, seed => [{ exists: seed % 2 !== 0, member: new Fr(seed + 1) }]), composite(isOption, (type, seed) => [ named(Option.some(firstValue(type.inner, seed)), 'some'), named(Option.none(firstValue(type.inner, seed)), 'none'), @@ -81,6 +83,15 @@ const TEST_VALUE_IMPLS: TestValueImpl[] = [ }), ), ]), + composite(isStruct, (type, seed) => [unnamed(structValue(type, seed))]), + // A fixed-length array uses its real (signature) length, unlike the generic-length ARRAY which is pinned to + // DEFAULT_ARRAY_LENGTH. + composite(isFixedArray, (type, seed) => [unnamed(collectionData(type.inner, seed, type.length))]), + // Same for a fixed-capacity bounded vec: real capacity, DEFAULT_ARRAY_LENGTH elements (its value type is a plain + // element array, unlike the two-slot BOUNDED_VEC), clamped to the capacity so small vecs stay valid. + composite(isFixedBoundedVec, (type, seed) => [ + unnamed(collectionData(type.inner, seed, Math.min(DEFAULT_ARRAY_LENGTH, type.maxLength))), + ]), ]; /** @@ -115,9 +126,9 @@ function named(value: unknown, name: string): Scenario { } /** - * One type's test-value implementation, mirroring a Noir `impl OracleTestValue for T`: `match` selects the type and - * `scenarios` yields its [`Scenario`]s for `seed`, one per serialization shape, like Noir's - * `oracle_test_value(seed) -> [Scenario; N]`. + * One type's test-value implementation, mirroring a Noir `TestValueSynthesizer` entry: `match` selects the type and + * `scenarios` yields its [`Scenario`]s for `seed`, one per serialization shape, like the Noir entry's `scenarios` + * function. */ interface TestValueImpl { match: (type: TypeMapping) => boolean; @@ -143,8 +154,27 @@ function collectionData(element: TypeMapping, seed: number, length: number) } /** - * The [`Scenario`]s for `type` at `seed`, one per serialization *shape*, mirroring the Noir - * `OracleTestValue::oracle_test_value(seed) -> [Scenario; N]`. Throws if the type has no matching impl. + * The synthesized prop bag for a struct: each field's first scenario, seeded at the field's flat wire offset so the + * values depend on the wire layout rather than on how each side groups its fields. Mirrors the Noir reflected impls. + */ +function structValue(type: StructMapping, seed: number): Record { + const value: Record = {}; + let offset = 0; + for (const field of type.fields) { + value[field.name] = firstValue(field.type, seed + offset); + const width = tryFieldWidth(field.type.shape); + if (width === undefined) { + // A variable-width field has no flat offset to seed the next field at, so only this oracle is unsynthesizable. + throw new UnsynthesizableTypeError(field.type, `struct field '${field.name}' has a variable-width shape`); + } + offset += width; + } + return value; +} + +/** + * The [`Scenario`]s for `type` at `seed`, one per serialization *shape*, mirroring the Noir `scenarios_for` + * dispatcher. Throws if the type has no matching impl. */ function scenariosForType(type: TypeMapping, seed: number): Scenario[] { const impl = TEST_VALUE_IMPLS.find(i => i.match(type)); @@ -160,8 +190,8 @@ function firstValue(type: TypeMapping, seed: number): unknown { } class UnsynthesizableTypeError extends Error { - constructor(type: TypeMapping) { - super(`No test-value impl for type: ${JSON.stringify(Object.keys(type))}`); + constructor(type: TypeMapping, detail?: string) { + super(detail ?? `No test-value impl for type: ${JSON.stringify(Object.keys(type))}`); this.name = 'UnsynthesizableTypeError'; } } @@ -169,7 +199,7 @@ class UnsynthesizableTypeError extends Error { /** * Synthesizes the scenarios for one oracle by zipping every position's [`Scenario`] list to one case per test (a * shorter list wraps around via mod), mirroring the Noir macro. Returns `undefined` if any param or return type is - * unsynthesizable (structs, ephemeral arrays). + * unsynthesizable (ephemeral arrays, structs with a variable-width field). */ function synthesizeScenarios(entry: OracleRegistryEntry): OracleTestScenario[] | undefined { try { @@ -214,14 +244,23 @@ function synthesizeScenariosOrThrow(entry: OracleRegistryEntry): OracleTestScena } /** - * The composite `TypeMapping`s (`ARRAY`/`BOUNDED_VEC`/`OPTION`), discriminated by `kind`. The combinators attach `kind` - * plus the inner mapping at construction; the registry erases params to the base `TypeMapping`, so the guards below - * recover the structure for recursion. + * The composite `TypeMapping`s (`ARRAY`/`BOUNDED_VEC`/`OPTION`/`STRUCT`/`FIXED_ARRAY`/`FIXED_BOUNDED_VEC`), + * discriminated by `kind`. The combinators attach `kind` plus the inner mapping(s) at construction; the registry + * erases params to the base `TypeMapping`, so the guards below recover the structure for recursion. */ type ArrayMapping = TypeMapping & { kind: 'array'; inner: TypeMapping }; type BoundedVecMapping = TypeMapping & { kind: 'bounded-vec'; inner: TypeMapping }; type OptionMapping = TypeMapping & { kind: 'option'; inner: TypeMapping }; -type CompositeMapping = ArrayMapping | BoundedVecMapping | OptionMapping; +type StructMapping = TypeMapping & { kind: 'struct'; fields: readonly StructField[] }; +type FixedArrayMapping = TypeMapping & { kind: 'fixed-array'; inner: TypeMapping; length: number }; +type FixedBoundedVecMapping = TypeMapping & { kind: 'fixed-bounded-vec'; inner: TypeMapping; maxLength: number }; +type CompositeMapping = + | ArrayMapping + | BoundedVecMapping + | OptionMapping + | StructMapping + | FixedArrayMapping + | FixedBoundedVecMapping; function isArray(type: TypeMapping): type is ArrayMapping { return 'kind' in type && type.kind === 'array'; @@ -232,3 +271,12 @@ function isBoundedVec(type: TypeMapping): type is BoundedVecMapping { function isOption(type: TypeMapping): type is OptionMapping { return 'kind' in type && type.kind === 'option'; } +function isStruct(type: TypeMapping): type is StructMapping { + return 'kind' in type && type.kind === 'struct'; +} +function isFixedArray(type: TypeMapping): type is FixedArrayMapping { + return 'kind' in type && type.kind === 'fixed-array'; +} +function isFixedBoundedVec(type: TypeMapping): type is FixedBoundedVecMapping { + return 'kind' in type && type.kind === 'fixed-bounded-vec'; +} diff --git a/yarn-project/txe/src/oracle/test-resolver/resolver.ts b/yarn-project/txe/src/oracle/test-resolver/resolver.ts index acc959004a5d..a8d1c2393252 100644 --- a/yarn-project/txe/src/oracle/test-resolver/resolver.ts +++ b/yarn-project/txe/src/oracle/test-resolver/resolver.ts @@ -179,6 +179,11 @@ function decodeScenarioName(inputs: ForeignCallArgs): string { } function valuesEqual(actual: unknown, expected: unknown): boolean { + if (!isNonNullObject(actual) || !isNonNullObject(expected)) { + // Primitive scalars; String() bridges bigint vs number representations of the same seed. A primitive compared + // against an object is never equal. + return isNonNullObject(actual) === isNonNullObject(expected) && String(actual) === String(expected); + } if (actual instanceof Option && expected instanceof Option) { return actual.equals(expected, valuesEqual); } @@ -188,7 +193,49 @@ function valuesEqual(actual: unknown, expected: unknown): boolean { if (Array.isArray(actual) && Array.isArray(expected)) { return actual.length === expected.length && actual.every((v, i) => valuesEqual(v, expected[i])); } - return String(actual) === String(expected); + if (isPlainObject(actual) && isPlainObject(expected)) { + const expectedKeys = Object.keys(expected); + return ( + Object.keys(actual).length === expectedKeys.length && + expectedKeys.every(key => valuesEqual(actual[key], expected[key])) + ); + } + if (actual.constructor !== expected.constructor) { + return false; + } + if (hasEquals(actual)) { + return actual.equals(expected); + } + const actualStr = customStringForm(actual); + const expectedStr = customStringForm(expected); + if (actualStr !== undefined && expectedStr !== undefined) { + return actualStr === expectedStr; + } + // Comparing blindly here could pass vacuously (e.g. two '[object Object]' strings), so refuse instead. + throw new Error( + `valuesEqual cannot compare a '${actual.constructor?.name}' against a '${expected.constructor?.name}'. Add a branch for the type.`, + ); +} + +function isNonNullObject(value: unknown): value is object { + return typeof value === 'object' && value !== null; +} + +function isPlainObject(value: unknown): value is Record { + return isNonNullObject(value) && value.constructor === Object; +} + +function hasEquals(value: object): value is { equals(other: unknown): boolean } { + return typeof (value as { equals?: unknown }).equals === 'function'; +} + +/** The value's string form when its class overrides `toString` (a meaningful representation), undefined otherwise. */ +function customStringForm(value: object): string | undefined { + const { toString } = value; + if (typeof toString !== 'function' || toString === Object.prototype.toString) { + return undefined; + } + return toString.call(value); } function versionBumpHint(oracle: string): string { diff --git a/yarn-project/txe/src/oracle/txe_oracle_registry.ts b/yarn-project/txe/src/oracle/txe_oracle_registry.ts index f6a82f6e019b..63728243cbd9 100644 --- a/yarn-project/txe/src/oracle/txe_oracle_registry.ts +++ b/yarn-project/txe/src/oracle/txe_oracle_registry.ts @@ -72,6 +72,7 @@ const GAS_SETTINGS: TypeMapping = { const STRATEGY_NON_INTERACTIVE_HANDSHAKE = 1; const STRATEGY_ARBITRARY_SECRET = 2; const STRATEGY_ADDRESS_DERIVED = 3; +const STRATEGY_INTERACTIVE_HANDSHAKE = 4; const TAGGING_SECRET_STRATEGY: TypeMapping = { serialization: { @@ -79,6 +80,8 @@ const TAGGING_SECRET_STRATEGY: TypeMapping = { switch (strategy.type) { case 'non-interactive-handshake': return [new Fr(STRATEGY_NON_INTERACTIVE_HANDSHAKE), Fr.ZERO, Fr.ZERO]; + case 'interactive-handshake': + return [new Fr(STRATEGY_INTERACTIVE_HANDSHAKE), Fr.ZERO, Fr.ZERO]; case 'address-derived': return [new Fr(STRATEGY_ADDRESS_DERIVED), Fr.ZERO, Fr.ZERO]; case 'arbitrary-secret': @@ -93,6 +96,8 @@ const TAGGING_SECRET_STRATEGY: TypeMapping = { switch (kind) { case STRATEGY_NON_INTERACTIVE_HANDSHAKE: return { type: 'non-interactive-handshake' }; + case STRATEGY_INTERACTIVE_HANDSHAKE: + return { type: 'interactive-handshake' }; case STRATEGY_ADDRESS_DERIVED: return { type: 'address-derived' }; case STRATEGY_ARBITRARY_SECRET: @@ -195,7 +200,7 @@ const TXE_CALL_CONTEXT: TypeMapping<{ txHash: Fr; anchorBlockTimestamp: bigint } shape: ['scalar', 'scalar', 'scalar'], // discriminant, txHash, anchor block timestamp }; -export const CONTRACT_INSTANCE_MEMBER: TypeMapping<{ exists: boolean; member: Fr }[]> = FIXED_ARRAY( +const CONTRACT_INSTANCE_MEMBER: TypeMapping<{ exists: boolean; member: Fr }[]> = FIXED_ARRAY( STRUCT([ { name: 'exists', type: BOOL }, { name: 'member', type: FIELD }, diff --git a/yarn-project/txe/src/oracle/txe_oracle_version.ts b/yarn-project/txe/src/oracle/txe_oracle_version.ts index 081232d389e3..6a998bbdf15b 100644 --- a/yarn-project/txe/src/oracle/txe_oracle_version.ts +++ b/yarn-project/txe/src/oracle/txe_oracle_version.ts @@ -6,7 +6,7 @@ * The Noir counterparts are in `noir-projects/aztec-nr/aztec/src/test/helpers/txe_oracles.nr`. */ export const TXE_ORACLE_VERSION_MAJOR = 2; -export const TXE_ORACLE_VERSION_MINOR = 2; +export const TXE_ORACLE_VERSION_MINOR = 3; /** * This hash is computed from the TXE oracle interfaces (IAvmExecutionOracle and ITxeExecutionOracle) and is used to