From 2734da1e6b3036b2d1a64c6b8a9553a7a52e6017 Mon Sep 17 00:00:00 2001 From: Martin Verzilli Date: Wed, 8 Jul 2026 14:23:43 +0200 Subject: [PATCH 1/3] fix: tweak depositToAztec gas config (#24607) While trying to deploy the latest nightly standard contracts to testnet I experienced gas estimation issues. According to Claude these issues are due to variability in Merkle tree insertions: there's around 40% of gas cost spread between the happiest case (no tree "climbing" needed) and the worst case (10 level climb). If eth_estimateGas occurs during the happiest case, and actual TX execution during the worst case, the default 20% buffer we use in Aztec.js turns out not to be sufficient. Closes F-794 --- .../aztec.js/src/ethereum/portal_manager.ts | 99 ++++++++++++++----- 1 file changed, 73 insertions(+), 26 deletions(-) diff --git a/yarn-project/aztec.js/src/ethereum/portal_manager.ts b/yarn-project/aztec.js/src/ethereum/portal_manager.ts index 5b913a6d2b4e..5f2113902cf0 100644 --- a/yarn-project/aztec.js/src/ethereum/portal_manager.ts +++ b/yarn-project/aztec.js/src/ethereum/portal_manager.ts @@ -1,3 +1,9 @@ +import { + type L1TxConfig, + type L1TxUtils, + createL1TxUtils, + getL1TxUtilsConfigEnvVars, +} from '@aztec/ethereum/l1-tx-utils'; import type { ExtendedViemWalletClient, ViemContract } from '@aztec/ethereum/types'; import { extractEvent } from '@aztec/ethereum/utils'; import type { EpochNumber } from '@aztec/foundation/branded-types'; @@ -16,7 +22,7 @@ import { computeL2ToL1MessageHash, computeSecretHash } from '@aztec/stdlib/hash' import type { AztecNode } from '@aztec/stdlib/interfaces/client'; import { getL2ToL1MessageLeafId } from '@aztec/stdlib/messaging'; -import { type Hex, getContract, toFunctionSelector } from 'viem'; +import { type Hex, encodeFunctionData, getContract, toFunctionSelector } from 'viem'; /** L1 to L2 message info to claim it on L2. */ export type L2Claim = { @@ -51,10 +57,26 @@ export async function generateClaimSecret(logger?: Logger): Promise<[Fr, Fr]> { return [secret, secretHash]; } +// `Inbox.sendL2Message` (reached by every `depositToAztec*` call) inserts into a height-10 +// (`L1_TO_L2_MSG_SUBTREE_HEIGHT`) incremental frontier tree. The per-insert cost swings by up to ~10 hash levels +// (which translates to ~40k gas) depending on where the global message index lands when the tx is mined: inserts that +// complete a subtree cascade through cold SLOADs + an SSTORE vs cheap inserts that touch one slot. A point-in-time +// `eth_estimateGas` taken at a cheap index therefore under-sizes a deposit that mines at a subtree boundary. That ~40k +// swing exceeds the default 20% buffer on a ~100k deposit. That is why we raise the gas-limit buffer to 2x here. +// Note: a larger operator override via L1_GAS_LIMIT_BUFFER_PERCENTAGE still wins. +const INBOX_DEPOSIT_GAS_LIMIT_BUFFER_PERCENTAGE = 100; + +/** Per-call gas config for `depositToAztec*`: the Inbox-deposit buffer floor, or a larger operator override. */ +function inboxDepositGasConfig(): L1TxConfig { + const configuredBuffer = getL1TxUtilsConfigEnvVars().gasLimitBufferPercentage ?? 0; + return { gasLimitBufferPercentage: Math.max(configuredBuffer, INBOX_DEPOSIT_GAS_LIMIT_BUFFER_PERCENTAGE) }; +} + /** Helper for managing an ERC20 on L1. */ export class L1TokenManager { private contract: ViemContract; private handler: ViemContract | undefined; + private readonly l1TxUtils: L1TxUtils; public constructor( /** Address of the ERC20 contract. */ @@ -76,6 +98,7 @@ export class L1TokenManager { client: this.extendedClient, }); } + this.l1TxUtils = createL1TxUtils(this.extendedClient, { logger }, getL1TxUtilsConfigEnvVars()); } /** Returns the amount of tokens available to mint via the handler. @@ -108,8 +131,10 @@ export class L1TokenManager { const mintAmount = await this.getMintAmount(); this.logger.info(`Minting ${mintAmount} tokens for ${stringifyEthAddress(address, addressName)}`); // NOTE: the handler mints a fixed amount. - await this.extendedClient.waitForTransactionReceipt({ - hash: await this.handler.write.mint([address]), + await this.l1TxUtils.sendAndMonitorTransaction({ + to: this.handler.address, + abi: FeeAssetHandlerAbi, + data: encodeFunctionData({ abi: FeeAssetHandlerAbi, functionName: 'mint', args: [address] }), }); } @@ -121,8 +146,10 @@ export class L1TokenManager { */ public async approve(amount: bigint, address: Hex, addressName = '') { this.logger.info(`Approving ${amount} tokens for ${stringifyEthAddress(address, addressName)}`); - await this.extendedClient.waitForTransactionReceipt({ - hash: await this.contract.write.approve([address, amount]), + await this.l1TxUtils.sendAndMonitorTransaction({ + to: this.contract.address, + abi: TestERC20Abi, + data: encodeFunctionData({ abi: TestERC20Abi, functionName: 'approve', args: [address, amount] }), }); } } @@ -131,6 +158,7 @@ export class L1TokenManager { export class L1FeeJuicePortalManager { private readonly tokenManager: L1TokenManager; private readonly contract: ViemContract; + private readonly l1TxUtils: L1TxUtils; constructor( portalAddress: EthAddress, @@ -145,6 +173,7 @@ export class L1FeeJuicePortalManager { abi: FeeJuicePortalAbi, client: extendedClient, }); + this.l1TxUtils = createL1TxUtils(extendedClient, { logger }, getL1TxUtilsConfigEnvVars()); } /** Returns the associated token manager for the L1 ERC20. */ @@ -176,9 +205,14 @@ export class L1FeeJuicePortalManager { await this.contract.simulate.depositToAztecPublic(args); - const txReceipt = await this.extendedClient.waitForTransactionReceipt({ - hash: await this.contract.write.depositToAztecPublic(args), - }); + const { receipt: txReceipt } = await this.l1TxUtils.sendAndMonitorTransaction( + { + to: this.contract.address, + abi: FeeJuicePortalAbi, + data: encodeFunctionData({ abi: FeeJuicePortalAbi, functionName: 'depositToAztecPublic', args }), + }, + inboxDepositGasConfig(), + ); this.logger.info('Deposited to Aztec public successfully', { txReceipt }); @@ -249,6 +283,7 @@ export class L1FeeJuicePortalManager { export class L1ToL2TokenPortalManager { protected readonly portal: ViemContract; protected readonly tokenManager: L1TokenManager; + protected readonly l1TxUtils: L1TxUtils; constructor( portalAddress: EthAddress, @@ -263,6 +298,7 @@ export class L1ToL2TokenPortalManager { abi: TokenPortalAbi, client: extendedClient, }); + this.l1TxUtils = createL1TxUtils(extendedClient, { logger }, getL1TxUtilsConfigEnvVars()); } /** Returns the token manager for the underlying L1 token. */ @@ -280,15 +316,17 @@ export class L1ToL2TokenPortalManager { const [claimSecret, claimSecretHash] = await this.bridgeSetup(amount, mint); this.logger.info('Sending L1 tokens to L2 to be claimed publicly'); - const { request } = await this.portal.simulate.depositToAztecPublic([ - to.toString(), - amount, - claimSecretHash.toString(), - ]); - - const txReceipt = await this.extendedClient.waitForTransactionReceipt({ - hash: await this.extendedClient.writeContract(request), - }); + const args = [to.toString(), amount, claimSecretHash.toString()] as const; + await this.portal.simulate.depositToAztecPublic(args); + + const { receipt: txReceipt } = await this.l1TxUtils.sendAndMonitorTransaction( + { + to: this.portal.address, + abi: TokenPortalAbi, + data: encodeFunctionData({ abi: TokenPortalAbi, functionName: 'depositToAztecPublic', args }), + }, + inboxDepositGasConfig(), + ); const log = extractEvent( txReceipt.logs, @@ -334,11 +372,17 @@ export class L1ToL2TokenPortalManager { const [claimSecret, claimSecretHash] = await this.bridgeSetup(amount, mint); this.logger.info('Sending L1 tokens to L2 to be claimed privately'); - const { request } = await this.portal.simulate.depositToAztecPrivate([amount, claimSecretHash.toString()]); - - const txReceipt = await this.extendedClient.waitForTransactionReceipt({ - hash: await this.extendedClient.writeContract(request), - }); + const args = [amount, claimSecretHash.toString()] as const; + await this.portal.simulate.depositToAztecPrivate(args); + + const { receipt: txReceipt } = await this.l1TxUtils.sendAndMonitorTransaction( + { + to: this.portal.address, + abi: TokenPortalAbi, + data: encodeFunctionData({ abi: TokenPortalAbi, functionName: 'depositToAztecPrivate', args }), + }, + inboxDepositGasConfig(), + ); const log = extractEvent( txReceipt.logs, @@ -437,7 +481,7 @@ export class L1TokenPortalManager extends L1ToL2TokenPortalManager { } // Call function on L1 contract to consume the message - const { request: withdrawRequest } = await this.portal.simulate.withdraw([ + const withdrawArgs = [ recipient.toString(), amount, false, @@ -445,10 +489,13 @@ export class L1TokenPortalManager extends L1ToL2TokenPortalManager { BigInt(numCheckpointsInEpoch), messageIndex, siblingPath.toBufferArray().map((buf: Buffer): Hex => `0x${buf.toString('hex')}`), - ]); + ] as const; + await this.portal.simulate.withdraw(withdrawArgs); - await this.extendedClient.waitForTransactionReceipt({ - hash: await this.extendedClient.writeContract(withdrawRequest), + await this.l1TxUtils.sendAndMonitorTransaction({ + to: this.portal.address, + abi: TokenPortalAbi, + data: encodeFunctionData({ abi: TokenPortalAbi, functionName: 'withdraw', args: withdrawArgs }), }); const isConsumedAfter = await this.outbox.read.hasMessageBeenConsumedAtEpoch([BigInt(epochNumber), messageLeafId]); From 9f1167e6d43f5283acb8c401d196a89c7c7fe907 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Venturo?= Date: Wed, 8 Jul 2026 12:49:03 -0300 Subject: [PATCH 2/3] feat!: make inbox secrets be multiple fields (#24599) With this, an L1->L2 message's `secret` need no longer be a single field but instead an array of fields. I moved the message nullifier computation from PXE into nr so that contracts can have different nullification schemes (e.g. those that don't depend exclusively on the contents of `secret`). Because the fee juice contract uses this oracle, I introduced an adaptor for it. --- .../docs/resources/migration_notes.md | 33 ++++++++++ .../contracts/aave_bridge/src/main.nr | 4 +- .../examples/contracts/nft_bridge/src/main.nr | 2 +- .../aztec/src/context/private_context.nr | 10 ++- .../aztec/src/context/public_context.nr | 10 ++- noir-projects/aztec-nr/aztec/src/hash.nr | 12 ++-- noir-projects/aztec-nr/aztec/src/messaging.nr | 18 ++++-- .../oracle/get_l1_to_l2_membership_witness.nr | 20 ++++-- .../aztec-nr/aztec/src/oracle/version.nr | 2 +- .../src/test/helpers/test_environment.nr | 2 +- .../test/l1_to_l2_messages.nr | 6 +- .../app/token_blacklist_contract/src/main.nr | 2 +- .../app/token_bridge_contract/src/main.nr | 4 +- .../contracts/test/test_contract/src/main.nr | 6 +- .../oracle/acir_callback.ts | 2 +- .../oracle/legacy_oracle_registry.test.ts | 48 +++++++++++++- .../oracle/legacy_oracle_registry.ts | 33 +++++++++- .../oracle/oracle_registry.ts | 15 ++++- .../oracle/utility_execution_oracle.ts | 16 ++--- yarn-project/pxe/src/oracle_version.ts | 4 +- yarn-project/stdlib/src/hash/hash.ts | 8 --- .../stdlib/src/messaging/l1_to_l2_message.ts | 64 +++++++++++++++---- yarn-project/txe/src/rpc_translator.ts | 8 +-- 23 files changed, 249 insertions(+), 80 deletions(-) diff --git a/docs/docs-developers/docs/resources/migration_notes.md b/docs/docs-developers/docs/resources/migration_notes.md index 41b06758fcec..e2a28dc9b975 100644 --- a/docs/docs-developers/docs/resources/migration_notes.md +++ b/docs/docs-developers/docs/resources/migration_notes.md @@ -9,6 +9,39 @@ Aztec is in active development. Each version may introduce breaking changes that ## TBD +### [Aztec.nr] L1-to-L2 message consumption takes the secret as an array + +`PrivateContext::consume_l1_to_l2_message` and `PublicContext::consume_l1_to_l2_message` now take the message secret as an arbitrary-length array `[Field; N]` instead of a single `Field`, so a consumer can derive its secret hash from more than one field. The helpers `compute_secret_hash` and `compute_l1_to_l2_message_nullifier` are likewise now generic over the secret length. A single-field secret behaves exactly as before (the hashes are unchanged for `N = 1`) — just wrap it in an array. + +**Migration:** + +```diff +- context.consume_l1_to_l2_message(content, secret, sender, leaf_index); ++ context.consume_l1_to_l2_message(content, [secret], sender, leaf_index); +``` + +```diff +- let secret_hash = compute_secret_hash(secret); ++ let secret_hash = compute_secret_hash([secret]); +``` + +**Impact**: Contracts that consume L1-to-L2 messages, or that call `compute_secret_hash` / `compute_l1_to_l2_message_nullifier`, must wrap their single-field secret in an array. Already-deployed contracts are unaffected: their unchanged bytecode keeps working, as PXE serves the previous L1-to-L2 membership-witness oracle through a compatibility adaptor. + +### [Aztec.js] `computeL1ToL2MessageNullifier` replaced by `computeFeeJuiceMessageNullifier` + +The `@aztec/stdlib` helper `computeL1ToL2MessageNullifier(contract, messageHash, secret)`, which returned the siloed message nullifier, has been removed. Its replacement `computeFeeJuiceMessageNullifier(messageHash, secret)` returns the **unsiloed** nullifier — siloing now happens at the point where the nullifier is looked up. `getL1ToL2MessageWitness` accordingly takes an optional `{ contractAddress, nullifier }` (unsiloed) and silos internally. `computeSecretHash` is unchanged, and `getNonNullifiedL1ToL2MessageWitness` keeps the same signature. + +**Migration:** + +```diff +- const nullifier = await computeL1ToL2MessageNullifier(contract, messageHash, secret); ++ // computeFeeJuiceMessageNullifier returns the UNSILOED nullifier; silo it before looking it up, or hand the ++ // unsiloed value to getL1ToL2MessageWitness which silos for you. ++ const nullifier = await siloNullifier(contract, await computeFeeJuiceMessageNullifier(messageHash, secret)); +``` + +**Impact**: Only affects code calling these low-level messaging helpers directly - most integrations use `getNonNullifiedL1ToL2MessageWitness`, which is unchanged. + ### [Aztec.js] Account signing keys are no longer derived from the privacy secret Schnorr account signing keys used to be derived from the account's privacy secret (via the now-removed `deriveSigningKey`), which meant the ownership key could be reconstructed from a value the PXE holds. The relationship is now reversed: the signing key is the root, and the privacy secret is derived from it with `deriveSecretKeyFromSigningKey` (exported from `@aztec/accounts/utils`). diff --git a/docs/examples/contracts/aave_bridge/src/main.nr b/docs/examples/contracts/aave_bridge/src/main.nr index 4e9890468d1b..44ac497b2073 100644 --- a/docs/examples/contracts/aave_bridge/src/main.nr +++ b/docs/examples/contracts/aave_bridge/src/main.nr @@ -50,7 +50,7 @@ pub contract AaveBridge { // Consume message and emit nullifier self.context.consume_l1_to_l2_message( content_hash, - secret, + [secret], config.portal, message_leaf_index, ); @@ -76,7 +76,7 @@ pub contract AaveBridge { let content_hash = get_mint_to_private_content_hash(amount); self.context.consume_l1_to_l2_message( content_hash, - secret_for_L1_to_L2_message_consumption, + [secret_for_L1_to_L2_message_consumption], config.portal, message_leaf_index, ); diff --git a/docs/examples/contracts/nft_bridge/src/main.nr b/docs/examples/contracts/nft_bridge/src/main.nr index 8f81f0fa3387..b2ae53de4ca8 100644 --- a/docs/examples/contracts/nft_bridge/src/main.nr +++ b/docs/examples/contracts/nft_bridge/src/main.nr @@ -38,7 +38,7 @@ pub contract NFTBridge { // Consume the L1 -> L2 message self.context.consume_l1_to_l2_message( content_hash, - secret, + [secret], self.storage.portal.read(), message_leaf_index, ); diff --git a/noir-projects/aztec-nr/aztec/src/context/private_context.nr b/noir-projects/aztec-nr/aztec/src/context/private_context.nr index 34fb37bcbc54..d402973e2b6d 100644 --- a/noir-projects/aztec-nr/aztec/src/context/private_context.nr +++ b/noir-projects/aztec-nr/aztec/src/context/private_context.nr @@ -868,14 +868,20 @@ impl PrivateContext { /// /// # Arguments /// * `content` - The message content that was sent from L1 - /// * `secret` - Secret value used for message privacy (if needed) + /// * `secret` - Secret fields used for message privacy (if needed) /// * `sender` - Ethereum address that sent the message /// * `leaf_index` - Index of the message in the L1-to-L2 message tree /// /// # Advanced /// Validates message existence in the L1-to-L2 message tree and nullifies the message to prevent /// double-consumption. - pub fn consume_l1_to_l2_message(&mut self, content: Field, secret: Field, sender: EthAddress, leaf_index: Field) { + pub fn consume_l1_to_l2_message( + &mut self, + content: Field, + secret: [Field; N], + sender: EthAddress, + leaf_index: Field, + ) { let nullifier = process_l1_to_l2_message( self.anchor_block_header.state.l1_to_l2_message_tree.root, self.this_address(), diff --git a/noir-projects/aztec-nr/aztec/src/context/public_context.nr b/noir-projects/aztec-nr/aztec/src/context/public_context.nr index b189a673e0b9..af92b7aef528 100644 --- a/noir-projects/aztec-nr/aztec/src/context/public_context.nr +++ b/noir-projects/aztec-nr/aztec/src/context/public_context.nr @@ -226,7 +226,7 @@ impl PublicContext { /// /// # Arguments /// * `content` - The message content that was sent from L1 - /// * `secret` - Secret value used for message privacy (if needed) + /// * `secret` - Secret fields used for message privacy (if needed) /// * `sender` - Ethereum address that sent the message /// * `leaf_index` - Index of the message in the L1-to-L2 message tree /// @@ -236,7 +236,13 @@ impl PublicContext { /// * Message hash is computed from all parameters + chain context /// * Will revert if message doesn't exist or was already consumed /// - pub fn consume_l1_to_l2_message(self: Self, content: Field, secret: Field, sender: EthAddress, leaf_index: Field) { + pub fn consume_l1_to_l2_message( + self: Self, + content: Field, + secret: [Field; N], + sender: EthAddress, + leaf_index: Field, + ) { let secret_hash = compute_secret_hash(secret); let message_hash = compute_l1_to_l2_message_hash( sender, diff --git a/noir-projects/aztec-nr/aztec/src/hash.nr b/noir-projects/aztec-nr/aztec/src/hash.nr index b3ccc20e8c72..aee5b25b6e44 100644 --- a/noir-projects/aztec-nr/aztec/src/hash.nr +++ b/noir-projects/aztec-nr/aztec/src/hash.nr @@ -12,8 +12,8 @@ use crate::protocol::{ pub use crate::protocol::hash::compute_siloed_nullifier; -pub fn compute_secret_hash(secret: Field) -> Field { - poseidon2_hash_with_separator([secret], DOM_SEP__SECRET_HASH) +pub fn compute_secret_hash(secret: [Field; N]) -> Field { + poseidon2_hash_with_separator(secret, DOM_SEP__SECRET_HASH) } pub fn compute_l1_to_l2_message_hash( @@ -47,9 +47,9 @@ pub fn compute_l1_to_l2_message_hash( sha256_to_field(hash_bytes) } -// The nullifier of a l1 to l2 message is the hash of the message salted with the secret -pub fn compute_l1_to_l2_message_nullifier(message_hash: Field, secret: Field) -> Field { - poseidon2_hash_with_separator([message_hash, secret], DOM_SEP__MESSAGE_NULLIFIER) +// The nullifier of an l1 to l2 message is the hash of the message salted with the secret. +pub fn compute_l1_to_l2_message_nullifier(message_hash: Field, secret: [Field; N]) -> Field { + poseidon2_hash_with_separator([message_hash].concat(secret), DOM_SEP__MESSAGE_NULLIFIER) } // Computes the hash of input arguments or return values for private functions, or for authwit creation. @@ -96,7 +96,7 @@ pub fn compute_public_bytecode_commitment( #[test] unconstrained fn secret_hash_matches_typescript() { let secret = 8; - let hash = compute_secret_hash(secret); + let hash = compute_secret_hash([secret]); // The following value was generated by `yarn-project/stdlib/src/hash/hash.test.ts` let secret_hash_from_ts = 0x1848b066724ab0ffb50ecb0ee3398eb839f162823d262bad959721a9c13d1e96; diff --git a/noir-projects/aztec-nr/aztec/src/messaging.nr b/noir-projects/aztec-nr/aztec/src/messaging.nr index 881c644f3091..0921c6ca4ef1 100644 --- a/noir-projects/aztec-nr/aztec/src/messaging.nr +++ b/noir-projects/aztec-nr/aztec/src/messaging.nr @@ -1,18 +1,18 @@ use crate::{ hash::{compute_l1_to_l2_message_hash, compute_l1_to_l2_message_nullifier, compute_secret_hash}, - oracle::get_l1_to_l2_membership_witness::get_l1_to_l2_membership_witness, + oracle::get_l1_to_l2_membership_witness::{get_l1_to_l2_membership_witness, UnsiloedNullifier}, }; use crate::protocol::{address::{AztecAddress, EthAddress}, merkle_tree::root::root_from_sibling_path}; -pub fn process_l1_to_l2_message( +pub fn process_l1_to_l2_message( l1_to_l2_root: Field, contract_address: AztecAddress, portal_contract_address: EthAddress, chain_id: Field, version: Field, content: Field, - secret: Field, + secret: [Field; N], leaf_index: Field, ) -> Field { let secret_hash = compute_secret_hash(secret); @@ -26,14 +26,20 @@ pub fn process_l1_to_l2_message( leaf_index, ); + let nullifier = compute_l1_to_l2_message_nullifier(message_hash, secret); + // We prove that `message_hash` is in the tree by showing the derivation of the tree root, using a merkle path we // get from an oracle. // Safety: The witness is only used as a "magical value" that makes the merkle proof below pass. Hence it's safe. - let (_leaf_index, sibling_path) = - unsafe { get_l1_to_l2_membership_witness(contract_address, message_hash, secret) }; + let (_leaf_index, sibling_path) = unsafe { + get_l1_to_l2_membership_witness( + message_hash, + Option::some(UnsiloedNullifier { contract_address, nullifier }), + ) + }; let root = root_from_sibling_path(message_hash, leaf_index, sibling_path); assert_eq(root, l1_to_l2_root, "Message not in state"); - compute_l1_to_l2_message_nullifier(message_hash, secret) + nullifier } diff --git a/noir-projects/aztec-nr/aztec/src/oracle/get_l1_to_l2_membership_witness.nr b/noir-projects/aztec-nr/aztec/src/oracle/get_l1_to_l2_membership_witness.nr index 5e76304350a5..9ed034a4ee5b 100644 --- a/noir-projects/aztec-nr/aztec/src/oracle/get_l1_to_l2_membership_witness.nr +++ b/noir-projects/aztec-nr/aztec/src/oracle/get_l1_to_l2_membership_witness.nr @@ -1,19 +1,27 @@ use crate::protocol::{address::AztecAddress, constants::L1_TO_L2_MSG_TREE_HEIGHT}; +/// An unsiloed L1 to L2 message nullifier. +pub struct UnsiloedNullifier { + pub contract_address: AztecAddress, + pub nullifier: Field, +} + /// Returns the leaf index and sibling path of an entry in the L1 to L2 messaging tree, which can then be used to prove /// its existence. +/// +/// When `nullifier` is `Some`, the returned witness is guaranteed to be for a message that has not yet been consumed: +/// PXE checks it is not present in the nullifier tree. Pass `None` to retrieve the witness regardless of whether the +/// message has been consumed. pub unconstrained fn get_l1_to_l2_membership_witness( - contract_address: AztecAddress, message_hash: Field, - secret: Field, + nullifier: Option, ) -> (Field, [Field; L1_TO_L2_MSG_TREE_HEIGHT]) { - get_l1_to_l2_membership_witness_oracle(contract_address, message_hash, secret) + get_l1_to_l2_membership_witness_oracle(message_hash, nullifier) } // Obtains membership witness (index and sibling path) for a message in the L1 to L2 message tree. -#[oracle(aztec_utl_getL1ToL2MembershipWitness)] +#[oracle(aztec_utl_getL1ToL2MembershipWitnessV2)] unconstrained fn get_l1_to_l2_membership_witness_oracle( - _contract_address: AztecAddress, _message_hash: Field, - _secret: Field, + _nullifier: Option, ) -> (Field, [Field; L1_TO_L2_MSG_TREE_HEIGHT]) {} diff --git a/noir-projects/aztec-nr/aztec/src/oracle/version.nr b/noir-projects/aztec-nr/aztec/src/oracle/version.nr index 5e518976c7fe..54924c79d5d3 100644 --- a/noir-projects/aztec-nr/aztec/src/oracle/version.nr +++ b/noir-projects/aztec-nr/aztec/src/oracle/version.nr @@ -11,7 +11,7 @@ /// immediately if AZTEC_NR_MINOR > PXE_MINOR because if a contract is updated to use a newer Aztec.nr dependency /// without actually using any of the new oracles then there is no reason to throw. pub global ORACLE_VERSION_MAJOR: Field = 30; -pub global ORACLE_VERSION_MINOR: Field = 5; +pub global ORACLE_VERSION_MINOR: Field = 6; /// Asserts that the version of the oracle is compatible with the version expected by the contract. pub fn assert_compatible_oracle_version() { diff --git a/noir-projects/aztec-nr/aztec/src/test/helpers/test_environment.nr b/noir-projects/aztec-nr/aztec/src/test/helpers/test_environment.nr index c494af854882..950dae404682 100644 --- a/noir-projects/aztec-nr/aztec/src/test/helpers/test_environment.nr +++ b/noir-projects/aztec-nr/aztec/src/test/helpers/test_environment.nr @@ -887,7 +887,7 @@ impl TestEnvironment { sender: EthAddress, recipient: AztecAddress, ) -> Field { - self.send_l1_to_l2_message_from_secret_hash(content, compute_secret_hash(secret), sender, recipient) + self.send_l1_to_l2_message_from_secret_hash(content, compute_secret_hash([secret]), sender, recipient) } /// Variant of [`send_l1_to_l2_message`](TestEnvironment::send_l1_to_l2_message) that takes the secret hash diff --git a/noir-projects/aztec-nr/aztec/src/test/helpers/test_environment/test/l1_to_l2_messages.nr b/noir-projects/aztec-nr/aztec/src/test/helpers/test_environment/test/l1_to_l2_messages.nr index 41e0ae8dd4ba..fe09fdb17f2d 100644 --- a/noir-projects/aztec-nr/aztec/src/test/helpers/test_environment/test/l1_to_l2_messages.nr +++ b/noir-projects/aztec-nr/aztec/src/test/helpers/test_environment/test/l1_to_l2_messages.nr @@ -15,7 +15,7 @@ unconstrained fn consume_seeded_message_from_secret() { let leaf_index = env.send_l1_to_l2_message(CONTENT, SECRET, sender, recipient); env.private_context_at(recipient, |context| { - context.consume_l1_to_l2_message(CONTENT, SECRET, sender, leaf_index); + context.consume_l1_to_l2_message(CONTENT, [SECRET], sender, leaf_index); }); } @@ -26,10 +26,10 @@ unconstrained fn consume_seeded_message_from_secret_hash() { let recipient = AztecAddress::from_field(17); let leaf_index = - env.send_l1_to_l2_message_from_secret_hash(CONTENT, compute_secret_hash(SECRET), sender, recipient); + env.send_l1_to_l2_message_from_secret_hash(CONTENT, compute_secret_hash([SECRET]), sender, recipient); env.private_context_at(recipient, |context| { - context.consume_l1_to_l2_message(CONTENT, SECRET, sender, leaf_index); + context.consume_l1_to_l2_message(CONTENT, [SECRET], sender, leaf_index); }); } diff --git a/noir-projects/noir-contracts/contracts/app/token_blacklist_contract/src/main.nr b/noir-projects/noir-contracts/contracts/app/token_blacklist_contract/src/main.nr index a3b26bb64264..86fc686d9daf 100644 --- a/noir-projects/noir-contracts/contracts/app/token_blacklist_contract/src/main.nr +++ b/noir-projects/noir-contracts/contracts/app/token_blacklist_contract/src/main.nr @@ -181,7 +181,7 @@ pub contract TokenBlacklist { let to_roles = self.storage.roles.at(to).get_current_value(); assert(!to_roles.is_blacklisted, "Blacklisted: Recipient"); - let secret_hash = compute_secret_hash(secret); + let secret_hash = compute_secret_hash([secret]); // Pop 1 note (set_limit(1)) which has an amount stored in a field with index 0 (select(0, amount)) and // a secret_hash stored in a field with index 1 (select(1, secret_hash)). diff --git a/noir-projects/noir-contracts/contracts/app/token_bridge_contract/src/main.nr b/noir-projects/noir-contracts/contracts/app/token_bridge_contract/src/main.nr index 63b1911ce41e..48fa31b8375b 100644 --- a/noir-projects/noir-contracts/contracts/app/token_bridge_contract/src/main.nr +++ b/noir-projects/noir-contracts/contracts/app/token_bridge_contract/src/main.nr @@ -55,7 +55,7 @@ pub contract TokenBridge { let config = self.storage.config.read(); // Consume message and emit nullifier - self.context.consume_l1_to_l2_message(content_hash, secret, config.portal, message_leaf_index); + self.context.consume_l1_to_l2_message(content_hash, [secret], config.portal, message_leaf_index); // Mint tokens self.call(Token::at(config.token).mint_to_public(to, amount)); @@ -101,7 +101,7 @@ pub contract TokenBridge { let content_hash = get_mint_to_private_content_hash(amount); self.context.consume_l1_to_l2_message( content_hash, - secret_for_L1_to_L2_message_consumption, + [secret_for_L1_to_L2_message_consumption], config.portal, message_leaf_index, ); diff --git a/noir-projects/noir-contracts/contracts/test/test_contract/src/main.nr b/noir-projects/noir-contracts/contracts/test/test_contract/src/main.nr index 1a1c742dc932..716d19d80d2d 100644 --- a/noir-projects/noir-contracts/contracts/test/test_contract/src/main.nr +++ b/noir-projects/noir-contracts/contracts/test/test_contract/src/main.nr @@ -345,7 +345,7 @@ pub contract Test { let content_hash = get_mint_to_private_content_hash(amount); self.context.consume_l1_to_l2_message( content_hash, - secret_for_L1_to_L2_message_consumption, + [secret_for_L1_to_L2_message_consumption], portal_address, message_leaf_index, ); @@ -359,7 +359,7 @@ pub contract Test { message_leaf_index: Field, ) { // Consume message and emit nullifier - self.context.consume_l1_to_l2_message(content, secret, sender, message_leaf_index); + self.context.consume_l1_to_l2_message(content, [secret], sender, message_leaf_index); } #[external("private")] @@ -370,7 +370,7 @@ pub contract Test { message_leaf_index: Field, ) { // Consume message and emit nullifier - self.context.consume_l1_to_l2_message(content, secret, sender, message_leaf_index); + self.context.consume_l1_to_l2_message(content, [secret], sender, message_leaf_index); } #[external("private")] diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/acir_callback.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/acir_callback.ts index c18eaca87462..66b4b78226cb 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/acir_callback.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/acir_callback.ts @@ -55,7 +55,7 @@ export function buildACIRCallback( target[legacyKey] = async (...inputs: ACVMField[][]) => { assertHandlerSupportsScope(handler, scope); const legacyArgs = paramSource.deserializeParams(inputs).map(p => p.value); - const positional = paramOverride ? paramOverride.mapping(legacyArgs) : legacyArgs; + const positional = paramOverride ? await paramOverride.mapping(legacyArgs) : legacyArgs; const result = await (handler as any)[methodName](...positional); return returnSource.serializeReturn(returnOverride ? returnOverride.mapping(result) : result); }; diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/legacy_oracle_registry.test.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/legacy_oracle_registry.test.ts index b8210ddecaf0..41f91dd20f3c 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/legacy_oracle_registry.test.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/legacy_oracle_registry.test.ts @@ -1,9 +1,12 @@ /* eslint-disable camelcase */ import { Fr } from '@aztec/foundation/curves/bn254'; import { toACVMField } from '@aztec/simulator/client'; +import { AztecAddress } from '@aztec/stdlib/aztec-address'; +import { computeFeeJuiceMessageNullifier } from '@aztec/stdlib/messaging'; +import { Option } from '../noir-structs/option.js'; import { buildACIRCallback } from './acir_callback.js'; -import type { LegacyOracleEntry } from './legacy_oracle_registry.js'; +import { LEGACY_ORACLE_REGISTRY, type LegacyOracleEntry } from './legacy_oracle_registry.js'; import { FIELD, U32 } from './oracle_registry.js'; type Handler = Parameters[0]; @@ -61,6 +64,49 @@ describe('legacy oracle dispatch', () => { expect(handlerArgs).toEqual([5, DEFAULTED_MINOR]); }); + it('awaits an async param mapping before invoking the modern handler', async () => { + let handlerArgs: unknown[] | undefined; + const handler = { + isMisc: true, + assertCompatibleOracleVersion: (...args: unknown[]) => { + handlerArgs = args; + }, + } as Handler; + + const legacyRegistry: Record = { + aztec_misc_legacyAsyncParams: { + modernOracle: 'aztec_misc_assertCompatibleOracleVersion', + params: { + legacyType: [{ name: 'major', type: U32 }], + mapping: ([major]: number[]) => Promise.resolve([major + 1, 0]), + }, + }, + }; + + const callback = buildACIRCallback(handler, { legacy: legacyRegistry }); + + await callback['aztec_misc_legacyAsyncParams']([toACVMField(new Fr(5))]); + + expect(handlerArgs).toEqual([6, 0]); + }); + + it('adapts the retired getL1ToL2MembershipWitness wire into the modern (messageHash, nullifier) args', async () => { + // The retired oracle passed (contractAddress, messageHash, secret), the modern one takes the unsiloed nullifier + // plus the address to silo it with. The adapter must derive exactly the fee juice nullifier so already-deployed + // contracts keep working. + const contractAddress = await AztecAddress.random(); + const messageHash = Fr.random(); + const secret = Fr.random(); + + const entry = LEGACY_ORACLE_REGISTRY['aztec_utl_getL1ToL2MembershipWitness']; + const [mappedMessageHash, mappedNullifier] = await entry.params!.mapping([contractAddress, messageHash, secret]); + + expect(mappedMessageHash).toEqual(messageHash); + expect(mappedNullifier).toEqual( + Option.some({ contractAddress, nullifier: await computeFeeJuiceMessageNullifier(messageHash, secret) }), + ); + }); + it('rejects a legacy name that collides with a live oracle', () => { const handler = { isMisc: true, getRandomField: () => Promise.resolve(new Fr(0)) } as Handler; const legacyRegistry: Record = { diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/legacy_oracle_registry.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/legacy_oracle_registry.ts index 4b552f175348..0ff043c259b3 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/legacy_oracle_registry.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/legacy_oracle_registry.ts @@ -1,8 +1,10 @@ /* eslint-disable camelcase */ import { MAX_NOTE_HASHES_PER_TX, PRIVATE_LOG_CIPHERTEXT_LEN, PRIVATE_LOG_SIZE_IN_FIELDS } from '@aztec/constants'; +import { computeFeeJuiceMessageNullifier } from '@aztec/stdlib/messaging'; import type { EphemeralArray } from '../noir-structs/ephemeral_array.js'; import type { LogRetrievalResponse } from '../noir-structs/log_retrieval_response.js'; +import { Option } from '../noir-structs/option.js'; import type { PendingTaggedLog } from '../noir-structs/pending_tagged_log.js'; import type { ResolvedTx } from '../noir-structs/resolved_tx.js'; import { @@ -13,6 +15,7 @@ import { type RegistryParam, } from './oracle_registry.js'; import { + AZTEC_ADDRESS, EPHEMERAL_ARRAY, FIELD, FIXED_BOUNDED_VEC, @@ -50,6 +53,25 @@ const LEGACY_PENDING_TAGGED_LOG: TypeMapping = STRUCT = { + aztec_utl_getL1ToL2MembershipWitness: legacyOracle({ + modernOracle: 'aztec_utl_getL1ToL2MembershipWitnessV2', + // The old wire passed the contract address and secret, the modern oracle takes the unsiloed nullifier (plus the + // address to silo it with) instead. We derive it here so already-deployed contracts that still emit the old call + // keep working. + // + // This is the fee juice message nullifier derivation: only contracts using that scheme call this retired oracle. + params: { + legacyType: [ + { name: 'contractAddress', type: AZTEC_ADDRESS }, + { name: 'messageHash', type: FIELD }, + { name: 'secret', type: FIELD }, + ], + mapping: async ([contractAddress, messageHash, secret]) => [ + messageHash, + Option.some({ contractAddress, nullifier: await computeFeeJuiceMessageNullifier(messageHash, secret) }), + ], + }, + }), aztec_utl_getLogsByTag: legacyOracle({ modernOracle: 'aztec_utl_getLogsByTagV2', returnType: { @@ -98,9 +120,12 @@ export interface LegacyOracleEntry { modernOracle: keyof Registry; /** * Old param wire. `legacyType` deserializes the old wire; `mapping` bridges the deserialized legacy args to the - * modern handler's args. Omit when the param wire is unchanged. + * modern handler's args. The mapping may be async. Omit when the param wire is unchanged. */ - params?: { legacyType: readonly RegistryParam[]; mapping: (legacyArgs: any) => readonly unknown[] }; + params?: { + legacyType: readonly RegistryParam[]; + mapping: (legacyArgs: any) => readonly unknown[] | Promise; + }; /** * Old return wire. `legacyType` serializes the subset the old contract reads; `mapping` bridges the handler's * current result to that subset's value type. Omit when the return wire is unchanged. @@ -112,7 +137,9 @@ function legacyOracle>) => HandlerArgsOf; + mapping: ( + legacyArgs: ParamTypes>, + ) => HandlerArgsOf | Promise>; }; returnType?: { legacyType: TypeMapping; mapping: (result: ReturnValueOf) => W }; }): LegacyOracleEntry { diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_registry.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_registry.ts index d6b2bdafa36c..b9bba4a8efc5 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_registry.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_registry.ts @@ -3,6 +3,7 @@ import { ARCHIVE_HEIGHT, NOTE_HASH_TREE_HEIGHT } from '@aztec/constants'; import { Fr } from '@aztec/foundation/curves/bn254'; import { FieldReader } from '@aztec/foundation/serialize'; import { toACVMField } from '@aztec/simulator/client'; +import type { UnsiloedMessageNullifier } from '@aztec/stdlib/messaging'; import { ARRAY, @@ -45,6 +46,7 @@ import { RESOLVED_TAGGING_STRATEGY, RESOLVED_TX, STR, + STRUCT, TX_EFFECT, TX_HASH, type TypeMapping, @@ -183,11 +185,18 @@ export const ORACLE_REGISTRY = { returnType: BOOL, }), - aztec_utl_getL1ToL2MembershipWitness: makeEntry({ + aztec_utl_getL1ToL2MembershipWitnessV2: makeEntry({ params: [ - { name: 'contractAddress', type: AZTEC_ADDRESS }, { name: 'messageHash', type: FIELD }, - { name: 'secret', type: FIELD }, + { + name: 'nullifier', + type: OPTION( + STRUCT([ + { name: 'contractAddress', type: AZTEC_ADDRESS }, + { name: 'nullifier', type: FIELD }, + ]), + ), + }, ], returnType: MESSAGE_LOAD_ORACLE_INPUTS, }), diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution_oracle.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution_oracle.ts index 79c732bec2c4..9055e2864ea5 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution_oracle.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution_oracle.ts @@ -26,7 +26,7 @@ import type { AztecNode } from '@aztec/stdlib/interfaces/server'; import type { KeyValidationRequest } from '@aztec/stdlib/kernel'; import { PublicKeys, computeAddressSecret, hashPublicKey } from '@aztec/stdlib/keys'; import { AppTaggingSecret, FlatPublicLogs, appSiloEcdhSharedSecret } from '@aztec/stdlib/logs'; -import { getNonNullifiedL1ToL2MessageWitness } from '@aztec/stdlib/messaging'; +import { type UnsiloedMessageNullifier, getL1ToL2MessageWitness } from '@aztec/stdlib/messaging'; import type { NoteStatus } from '@aztec/stdlib/note'; import { MerkleTreeId, type NullifierMembershipWitness, PublicDataWitness } from '@aztec/stdlib/trees'; import { @@ -478,19 +478,17 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra } /** - * Returns the membership witness of an un-nullified L1 to L2 message. - * @param contractAddress - Address of a contract by which the message was emitted. + * Returns the membership witness of an L1 to L2 message. * @param messageHash - Hash of the message. - * @param secret - Secret used to compute a nullifier. - * @dev Contract address and secret are only used to compute the nullifier to get non-nullified messages + * @param nullifier - When present, the unsiloed nullifier of the message and the address to silo it with. The witness + * is only returned if the siloed nullifier is absent from the nullifier tree, i.e. the message has not been consumed. * @returns The l1 to l2 membership witness (index of message in the tree and sibling path). */ - public async getL1ToL2MembershipWitness(contractAddress: AztecAddress, messageHash: Fr, secret: Fr) { - const [messageIndex, siblingPath] = await getNonNullifiedL1ToL2MessageWitness( + public async getL1ToL2MembershipWitnessV2(messageHash: Fr, nullifier: Option) { + const [messageIndex, siblingPath] = await getL1ToL2MessageWitness( this.aztecNode, - contractAddress, messageHash, - secret, + nullifier.value, await this.anchorBlockHeader.hash(), ); diff --git a/yarn-project/pxe/src/oracle_version.ts b/yarn-project/pxe/src/oracle_version.ts index d462ce6132db..2072d50d221d 100644 --- a/yarn-project/pxe/src/oracle_version.ts +++ b/yarn-project/pxe/src/oracle_version.ts @@ -11,7 +11,7 @@ /// if AZTEC_NR_MINOR > PXE_MINOR because if a contract is updated to use a newer Aztec.nr dependency without actually /// using any of the new oracles then there is no reason to throw. export const ORACLE_VERSION_MAJOR = 30; -export const ORACLE_VERSION_MINOR = 5; +export const ORACLE_VERSION_MINOR = 6; /// This hash is computed from the `ORACLE_REGISTRY` declaration (each oracle's name, ordered parameter names and /// types, and return type) and is used to detect when the oracle interface changes. When it does, you need to either: @@ -19,4 +19,4 @@ export const ORACLE_VERSION_MINOR = 5; /// - increment only `ORACLE_VERSION_MINOR` if the change is additive (a new oracle was added). /// /// These constants must be kept in sync between this file and `noir-projects/aztec-nr/aztec/src/oracle/version.nr`. -export const ORACLE_INTERFACE_HASH = 'f71b9662ec851e74593764a87792b0a91c181e1410aac49a4507f0bba949f6be'; +export const ORACLE_INTERFACE_HASH = '6094994f539407001d2adce5b6a8792c08ef645ee39813e32390f6208e78bc16'; diff --git a/yarn-project/stdlib/src/hash/hash.ts b/yarn-project/stdlib/src/hash/hash.ts index dd7e6d911450..0638b491a9c0 100644 --- a/yarn-project/stdlib/src/hash/hash.ts +++ b/yarn-project/stdlib/src/hash/hash.ts @@ -188,14 +188,6 @@ export function computeSecretHash(secret: Fr): Promise { return poseidon2HashWithSeparator([secret], DomainSeparator.SECRET_HASH); } -export async function computeL1ToL2MessageNullifier(contract: AztecAddress, messageHash: Fr, secret: Fr) { - const innerMessageNullifier = await poseidon2HashWithSeparator( - [messageHash, secret], - DomainSeparator.MESSAGE_NULLIFIER, - ); - return siloNullifier(contract, innerMessageNullifier); -} - /** * Calculates a siloed hash of a scoped l2 to l1 message. * @returns Fr containing 248 bits of information of sha256 hash. diff --git a/yarn-project/stdlib/src/messaging/l1_to_l2_message.ts b/yarn-project/stdlib/src/messaging/l1_to_l2_message.ts index 67590af73d66..098a50ea2ecd 100644 --- a/yarn-project/stdlib/src/messaging/l1_to_l2_message.ts +++ b/yarn-project/stdlib/src/messaging/l1_to_l2_message.ts @@ -1,4 +1,5 @@ -import type { L1_TO_L2_MSG_TREE_HEIGHT } from '@aztec/constants'; +import { DomainSeparator, type L1_TO_L2_MSG_TREE_HEIGHT } from '@aztec/constants'; +import { poseidon2HashWithSeparator } from '@aztec/foundation/crypto/poseidon'; import { sha256ToField } from '@aztec/foundation/crypto/sha256'; import { Fr } from '@aztec/foundation/curves/bn254'; import { BufferReader, serializeToBuffer } from '@aztec/foundation/serialize'; @@ -7,7 +8,7 @@ import { SiblingPath } from '@aztec/foundation/trees'; import type { AztecAddress } from '../aztec-address/index.js'; import type { BlockParameter } from '../block/block_parameter.js'; -import { computeL1ToL2MessageNullifier } from '../hash/hash.js'; +import { siloNullifier } from '../hash/hash.js'; import type { AztecNode } from '../interfaces/aztec-node.js'; import { MerkleTreeId } from '../trees/merkle_tree_id.js'; import { L1Actor } from './l1_actor.js'; @@ -74,28 +75,65 @@ export class L1ToL2Message { } } -// This functionality is not on the node because we do not want to pass the node the secret, and give the node the ability to derive a valid nullifer for an L1 to L2 message. -export async function getNonNullifiedL1ToL2MessageWitness( +/** + * The unsiloed nullifier a message consumer derives, together with the contract address needed to silo it. + */ +export interface UnsiloedMessageNullifier { + contractAddress: AztecAddress; + nullifier: Fr; +} + +/** + * Computes the unsiloed nullifier that the fee juice contract (and any consumer following the same scheme) derives when + * consuming an L1 to L2 message. + * + * This is not a general-purpose utility: other contracts may derive the secret hash and hence the nullifier + * differently. The result is unsiloed — callers silo it (with the consuming contract's address) before looking it up + * in the nullifier tree. + */ +export function computeFeeJuiceMessageNullifier(messageHash: Fr, secret: Fr): Promise { + return poseidon2HashWithSeparator([messageHash, secret], DomainSeparator.MESSAGE_NULLIFIER); +} + +/** + * Fetches the membership witness of an L1 to L2 message. When `unsiloedNullifier` is provided, the message is + * additionally required to be un-nullified. + */ +export async function getL1ToL2MessageWitness( node: AztecNode, - contractAddress: AztecAddress, messageHash: Fr, - secret: Fr, + unsiloedNullifier?: UnsiloedMessageNullifier, referenceBlock: BlockParameter = 'latest', ): Promise<[bigint, SiblingPath]> { - const messageNullifier = await computeL1ToL2MessageNullifier(contractAddress, messageHash, secret); - - const [l1ToL2Response, nullifierResponse] = await Promise.all([ - node.getL1ToL2MessageMembershipWitness(referenceBlock, messageHash), - node.findLeavesIndexes(referenceBlock, MerkleTreeId.NULLIFIER_TREE, [messageNullifier]), - ]); + // Both requests are dispatched before awaiting so they run concurrently. + const l1ToL2ResponsePromise = node.getL1ToL2MessageMembershipWitness(referenceBlock, messageHash); + const nullifierResponsePromise = unsiloedNullifier + ? siloNullifier(unsiloedNullifier.contractAddress, unsiloedNullifier.nullifier).then(siloed => + node.findLeavesIndexes(referenceBlock, MerkleTreeId.NULLIFIER_TREE, [siloed]), + ) + : undefined; + const l1ToL2Response = await l1ToL2ResponsePromise; if (!l1ToL2Response) { throw new Error(`No L1 to L2 message found for message hash ${messageHash.toString()}`); } - if (nullifierResponse[0] !== undefined) { + const nullifierResponse = await nullifierResponsePromise; + if (nullifierResponse?.[0] !== undefined) { throw new Error(`No non-nullified L1 to L2 message found for message hash ${messageHash.toString()}`); } return l1ToL2Response; } + +// This functionality is not on the node because we do not want to pass the node the secret, and give the node the ability to derive a valid nullifer for an L1 to L2 message. +export async function getNonNullifiedL1ToL2MessageWitness( + node: AztecNode, + contractAddress: AztecAddress, + messageHash: Fr, + secret: Fr, + referenceBlock: BlockParameter = 'latest', +): Promise<[bigint, SiblingPath]> { + const nullifier = await computeFeeJuiceMessageNullifier(messageHash, secret); + return getL1ToL2MessageWitness(node, messageHash, { contractAddress, nullifier }, referenceBlock); +} diff --git a/yarn-project/txe/src/rpc_translator.ts b/yarn-project/txe/src/rpc_translator.ts index 0f37f2f32bdf..a83ea031345f 100644 --- a/yarn-project/txe/src/rpc_translator.ts +++ b/yarn-project/txe/src/rpc_translator.ts @@ -488,12 +488,12 @@ export class RPCTranslator { } // eslint-disable-next-line camelcase - aztec_utl_getL1ToL2MembershipWitness(...inputs: ForeignCallArgs) { + aztec_utl_getL1ToL2MembershipWitnessV2(...inputs: ForeignCallArgs) { return callTxeHandler({ - oracle: 'aztec_utl_getL1ToL2MembershipWitness', + oracle: 'aztec_utl_getL1ToL2MembershipWitnessV2', inputs, - handler: ([contractAddress, messageHash, secret]) => - this.handlerAsUtility().getL1ToL2MembershipWitness(contractAddress, messageHash, secret), + handler: ([messageHash, nullifier]) => + this.handlerAsUtility().getL1ToL2MembershipWitnessV2(messageHash, nullifier), }); } From 23e8e1ea00af85bfaa635343abc8cdd14416f52a Mon Sep 17 00:00:00 2001 From: Nicolas Chamo Date: Wed, 8 Jul 2026 14:13:32 -0300 Subject: [PATCH 3/3] feat(e2e): interactive handshake e2e (#24590) --- .../interactive_handshake_responder.ts | 125 ++++++++++++++++++ .../src/automine/delivery/onchain.test.ts | 57 +++++++- .../delivery/onchain_delivery_harness.ts | 56 ++++++-- .../src/handshake-registry/constants.ts | 13 ++ .../src/handshake-registry/index.ts | 1 + 5 files changed, 240 insertions(+), 12 deletions(-) create mode 100644 yarn-project/end-to-end/src/automine/delivery/interactive_handshake_responder.ts diff --git a/yarn-project/end-to-end/src/automine/delivery/interactive_handshake_responder.ts b/yarn-project/end-to-end/src/automine/delivery/interactive_handshake_responder.ts new file mode 100644 index 000000000000..e46d0fa2b4d5 --- /dev/null +++ b/yarn-project/end-to-end/src/automine/delivery/interactive_handshake_responder.ts @@ -0,0 +1,125 @@ +import { AztecAddress, type CompleteAddress } from '@aztec/aztec.js/addresses'; +import { DomainSeparator } from '@aztec/constants'; +import { poseidon2HashWithSeparator } from '@aztec/foundation/crypto/poseidon'; +import { Schnorr, type SchnorrSignature } from '@aztec/foundation/crypto/schnorr'; +import { Fq, Fr } from '@aztec/foundation/curves/bn254'; +import type { GrumpkinScalar } from '@aztec/foundation/curves/grumpkin'; +import type { CustomRequest } from '@aztec/pxe/config'; +import { + INTERACTIVE_HANDSHAKE_REQUEST_KIND, + STANDARD_HANDSHAKE_REGISTRY_ADDRESS, +} from '@aztec/standard-contracts/handshake-registry/constants'; +import { type PublicKeys, derivePublicKeyFromSecretKey } from '@aztec/stdlib/keys'; + +/** + * The decoded payload of the registry's interactive-handshake signature request. Note it never carries the sender, + * so the recipient authorizes the handshake without learning who initiated it. + */ +export type InteractiveHandshakeRequest = { + /** The account whose authorization is being requested. */ + recipient: AztecAddress; + chainId: Fr; + version: Fr; + /** The x-coordinate of the handshake's ephemeral public key (its y-coordinate is positive by construction). */ + ephPkX: Fr; +}; + +/** + * The recipient's signed authorization for an interactive handshake. Mirrors the registry contract's + * `RecipientSignature` struct field for field. + */ +export type RecipientSignature = { + /** The recipient's master public keys, bound in-circuit to the recipient's address via `partialAddress`. */ + publicKeys: PublicKeys; + partialAddress: Fr; + /** The x-coordinate of the recipient's master message-signing public key. */ + mspkX: Fr; + /** Whether that key's y-coordinate is positive, so the circuit can reconstruct the point from `mspkX`. */ + mspkYIsPositive: boolean; + /** The schnorr signature over the handshake message. */ + signature: SchnorrSignature; +}; + +/** + * Parses and validates the registry's interactive-handshake signature request. + * + * @throws If the request kind is not {@link INTERACTIVE_HANDSHAKE_REQUEST_KIND}, the issuing contract is not the + * standard HandshakeRegistry, or the payload does not have the expected shape. + */ +export function parseInteractiveHandshakeRequest(request: CustomRequest): InteractiveHandshakeRequest { + if (!request.kind.equals(INTERACTIVE_HANDSHAKE_REQUEST_KIND)) { + throw new Error(`Not an interactive-handshake signature request: unexpected kind ${request.kind}`); + } + if (!request.contractAddress.equals(STANDARD_HANDSHAKE_REGISTRY_ADDRESS)) { + throw new Error( + `Interactive-handshake signature request issued by ${request.contractAddress}, expected the standard HandshakeRegistry at ${STANDARD_HANDSHAKE_REGISTRY_ADDRESS}`, + ); + } + if (request.payload.length !== 4) { + throw new Error(`Interactive-handshake signature request payload has ${request.payload.length} fields, expected 4`); + } + + const [recipient, chainId, version, ephPkX] = request.payload; + return { recipient: new AztecAddress(recipient), chainId, version, ephPkX }; +} + +/** + * Produces the recipient's signed authorization for an interactive handshake, signing with the master + * message-signing secret key. + */ +export async function signInteractiveHandshake( + request: InteractiveHandshakeRequest, + completeAddress: CompleteAddress, + masterMessageSigningSecretKey: GrumpkinScalar, +): Promise { + const mspk = await derivePublicKeyFromSecretKey(masterMessageSigningSecretKey); + const [mspkX, mspkYIsPositive] = mspk.toXAndSign(); + + const message = await computeInteractiveHandshakeSignatureMessage({ + chainId: request.chainId, + version: request.version, + registry: STANDARD_HANDSHAKE_REGISTRY_ADDRESS, + ephPkX: request.ephPkX, + }); + const signature = await new Schnorr().constructSignature(message, masterMessageSigningSecretKey); + + return { + publicKeys: completeAddress.publicKeys, + partialAddress: completeAddress.partialAddress, + mspkX, + mspkYIsPositive, + signature, + }; +} + +/** Serializes a {@link RecipientSignature} to the field layout the registry's in-circuit deserialization expects. */ +export function recipientSignatureToFields(recipientSignature: RecipientSignature): Fr[] { + const s = Fq.fromBuffer(recipientSignature.signature.s); + const e = Fq.fromBuffer(recipientSignature.signature.e); + return [ + ...recipientSignature.publicKeys.toFields(), + recipientSignature.partialAddress, + recipientSignature.mspkX, + new Fr(recipientSignature.mspkYIsPositive), + s.lo, + s.hi, + e.lo, + e.hi, + ]; +} + +/** + * The message an interactive-handshake authorization signs: the handshake's ephemeral key and chain context under + * `DomainSeparator.INTERACTIVE_HANDSHAKE_SIGNATURE`, exactly as the registry recomputes it in-circuit. + */ +function computeInteractiveHandshakeSignatureMessage(args: { + chainId: Fr; + version: Fr; + registry: AztecAddress; + ephPkX: Fr; +}): Promise { + return poseidon2HashWithSeparator( + [args.chainId, args.version, args.registry, args.ephPkX], + DomainSeparator.INTERACTIVE_HANDSHAKE_SIGNATURE, + ); +} 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 index cdab97abcf31..0e0dfaeb5039 100644 --- a/yarn-project/end-to-end/src/automine/delivery/onchain.test.ts +++ b/yarn-project/end-to-end/src/automine/delivery/onchain.test.ts @@ -1,5 +1,15 @@ +import type { InitialAccountData } from '@aztec/accounts/testing'; +import type { CompleteAddress } from '@aztec/aztec.js/addresses'; import { Point } from '@aztec/foundation/curves/grumpkin'; +import type { ResolveCustomRequest } from '@aztec/pxe/config'; +import { deriveKeys } from '@aztec/stdlib/keys'; +import type { TestWallet } from '../../test-wallet/test_wallet.js'; +import { + parseInteractiveHandshakeRequest, + recipientSignatureToFields, + signInteractiveHandshake, +} from './interactive_handshake_responder.js'; import { buildMessageDeliveryTest } from './onchain_delivery_harness.js'; describe('onchain delivery', () => { @@ -34,8 +44,8 @@ describe('onchain delivery', () => { }, }); - // With the recipient registering the sender, - // the recipient PXE reconstructs the address-derived tag and discovers the delivery. + // With the recipient registering the sender, the recipient PXE reconstructs the address-derived tag + // and discovers the delivery. buildMessageDeliveryTest({ strategy: 'address-derived', mode: 'unconstrained', @@ -44,4 +54,47 @@ describe('onchain delivery', () => { await recipientWallet.registerSender(senderAddress); }, }); + + buildMessageDeliveryTest({ + strategy: 'interactive handshake', + mode: 'constrained', + senderHook: () => Promise.resolve({ type: 'interactive-handshake' }), + customRequestResponder: interactiveHandshakeResponder, + }); + + buildMessageDeliveryTest({ + strategy: 'interactive handshake', + mode: 'unconstrained', + senderHook: () => Promise.resolve({ type: 'interactive-handshake' }), + customRequestResponder: interactiveHandshakeResponder, + }); + + // Serves the registry's interactive-handshake signature request for the recipient: registers the handshake on the + // recipient PXE, then answers with the signed response. + function interactiveHandshakeResponder( + recipientWallet: TestWallet, + recipientAccount: InitialAccountData, + recipientCompleteAddress: CompleteAddress, + ): ResolveCustomRequest { + return async request => { + const parsed = parseInteractiveHandshakeRequest(request); + + // Register before signing. + await recipientWallet.registerTaggingSecretSource({ + kind: 'handshake', + recipient: parsed.recipient, + ephPk: parsed.ephPkX, + }); + + // The master message-signing secret key is deliberately never held by PXE or the key store; the wallet + // derives it client-side from the account secret. + const { masterMessageSigningSecretKey } = await deriveKeys(recipientAccount.secret); + const recipientSignature = await signInteractiveHandshake( + parsed, + recipientCompleteAddress, + masterMessageSigningSecretKey, + ); + return recipientSignatureToFields(recipientSignature); + }; + } }); 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 index d10ae9196aea..6e00fca5969e 100644 --- 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 @@ -1,11 +1,12 @@ 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 { AztecAddress, CompleteAddress } from '@aztec/aztec.js/addresses'; import type { AztecNode } from '@aztec/aztec.js/node'; +import type { AccountManager } from '@aztec/aztec.js/wallet'; 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 { CustomRequest, ResolveCustomRequest, ResolveTaggingSecretStrategy } from '@aztec/pxe/config'; import type { AztecNodeDebug } from '@aztec/stdlib/interfaces/client'; import { jest } from '@jest/globals'; @@ -14,9 +15,13 @@ 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']>; +// Builds the hook serving custom requests issued during the sender's simulations. Installed on the sender PXE at +// creation but built only once the recipient exists, since serving typically needs the recipient's wallet and keys. +export type CustomRequestResponder = ( + recipient: TestWallet, + recipientAccount: InitialAccountData, + recipientCompleteAddress: CompleteAddress, +) => ResolveCustomRequest; export type Mode = 'constrained' | 'unconstrained'; @@ -40,19 +45,22 @@ export function buildMessageDeliveryTest(opts: { strategy: string; mode: DeliveryMode; // Required: every cell states its source explicitly rather than leaning on the PXE default (covered by unit tests). - senderHook: SenderHook; + senderHook: ResolveTaggingSecretStrategy; // 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; + // Serves the custom requests issued during the sender's simulations (e.g. the registry's interactive-handshake + // signature request). + customRequestResponder?: CustomRequestResponder; // 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 { strategy, mode, senderHook, recipientRegistration, customRequestResponder, additionalTests } = opts; const description = `${strategy} x ${formatMode(mode)}`; describe(description, () => { @@ -84,11 +92,14 @@ export function buildMessageDeliveryTest(opts: { ? contractSender.methods.emit_note(recipient, value) : contractSender.methods.emit_note_unconstrained(recipient, value); + let additionallyFundedAccounts: InitialAccountData[]; + let recipientAccount: AccountManager | undefined; + let customRequestCount = 0; + 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, @@ -98,7 +109,26 @@ export function buildMessageDeliveryTest(opts: { } = await setup(1, { ...AUTOMINE_E2E_OPTS, additionallyFundedAccounts: await generateSchnorrAccounts(1, 'schnorr'), - pxeCreationOptions: { hooks: { resolveTaggingSecretStrategy: senderHook } }, + pxeCreationOptions: { + hooks: { + resolveTaggingSecretStrategy: senderHook, + resolveCustomRequest: async (request: CustomRequest) => { + if (!customRequestResponder) { + throw new Error('A custom request arrived but this test cell has no customRequestResponder configured'); + } + if (!recipientAccount) { + throw new Error('A custom request arrived before the recipient wallet was created'); + } + customRequestCount++; + const respond = customRequestResponder( + walletRecipient, + additionallyFundedAccounts[0], + await recipientAccount.getCompleteAddress(), + ); + return respond(request); + }, + }, + }, })); ({ wallet: walletRecipient, teardown: teardownRecipient } = await setupPXEAndGetWallet( @@ -108,7 +138,7 @@ export function buildMessageDeliveryTest(opts: { undefined, 'pxe-recipient', )); - const recipientAccount = await walletRecipient.createSchnorrAccount( + recipientAccount = await walletRecipient.createSchnorrAccount( additionallyFundedAccounts[0].secret, additionallyFundedAccounts[0].salt, additionallyFundedAccounts[0].signingKey, @@ -172,6 +202,12 @@ export function buildMessageDeliveryTest(opts: { expect(readNotes).toEqual(noteValues); }); + if (customRequestResponder) { + it('the custom request hook fires exactly once, on the send that bootstraps the tagging secret', () => { + expect(customRequestCount).toBe(1); + }); + } + additionalTests?.(); }); } diff --git a/yarn-project/standard-contracts/src/handshake-registry/constants.ts b/yarn-project/standard-contracts/src/handshake-registry/constants.ts index 48b4cd153728..0573b2b125d5 100644 --- a/yarn-project/standard-contracts/src/handshake-registry/constants.ts +++ b/yarn-project/standard-contracts/src/handshake-registry/constants.ts @@ -1,6 +1,8 @@ // Lightweight metadata leaf export for browser bundles: importing from // `@aztec/standard-contracts/handshake-registry/constants` avoids dragging in the // `HandshakeRegistry.json` static import. +import { sha256ToField } from '@aztec/foundation/crypto/sha256'; +import type { Fr } from '@aztec/foundation/curves/bn254'; import type { AztecAddress } from '@aztec/stdlib/aztec-address'; import { StandardContractAddress, StandardContractClassId, StandardContractSalt } from '../standard_contract_data.js'; @@ -8,3 +10,14 @@ import { StandardContractAddress, StandardContractClassId, StandardContractSalt export const STANDARD_HANDSHAKE_REGISTRY_ADDRESS: AztecAddress = StandardContractAddress.HandshakeRegistry; export const STANDARD_HANDSHAKE_REGISTRY_CLASS_ID = StandardContractClassId.HandshakeRegistry; export const STANDARD_HANDSHAKE_REGISTRY_SALT = StandardContractSalt.HandshakeRegistry; + +/** + * Request kind under which the HandshakeRegistry asks for a recipient's interactive-handshake signature through the + * `resolveCustomRequest` hook. Mirrors `INTERACTIVE_HANDSHAKE_REQUEST_KIND` in the registry contract. + */ +// TODO: remove this mirrored constant and read the value from the HandshakeRegistry artifact once the contract +// global can be `#[abi]`-exported. Fixed upstream but not yet released: +// https://github.com/noir-lang/noir/pull/12714 and https://github.com/noir-lang/noir/issues/12620. +export const INTERACTIVE_HANDSHAKE_REQUEST_KIND: Fr = sha256ToField([ + Buffer.from('HANDSHAKE_REGISTRY::INTERACTIVE_HANDSHAKE_REQUEST'), +]); diff --git a/yarn-project/standard-contracts/src/handshake-registry/index.ts b/yarn-project/standard-contracts/src/handshake-registry/index.ts index f10e7186a45f..2cc367647d2e 100644 --- a/yarn-project/standard-contracts/src/handshake-registry/index.ts +++ b/yarn-project/standard-contracts/src/handshake-registry/index.ts @@ -6,6 +6,7 @@ import { makeStandardContract } from '../make_standard_contract.js'; import type { StandardContract } from '../standard_contract.js'; export { + INTERACTIVE_HANDSHAKE_REQUEST_KIND, STANDARD_HANDSHAKE_REGISTRY_ADDRESS, STANDARD_HANDSHAKE_REGISTRY_CLASS_ID, STANDARD_HANDSHAKE_REGISTRY_SALT,