From 6158019947232fee6201a71ef8748e2620d5cd60 Mon Sep 17 00:00:00 2001 From: Martin Verzilli Date: Wed, 8 Jul 2026 14:23:43 +0200 Subject: [PATCH 01/13] 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 (cherry picked from commit 2734da1e6b3036b2d1a64c6b8a9553a7a52e6017) --- .../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 679dae2d7158fc842263c00508384d110041ff5d Mon Sep 17 00:00:00 2001 From: Martin Verzilli Date: Thu, 9 Jul 2026 14:21:44 +0200 Subject: [PATCH 02/13] refactor: cache Aztec node reads per execution (#24630) First part of integrating the enhancements explored at https://github.com/aztec-labs-eng/oxide/pull/257 Closes F-808 (cherry picked from commit 8a3f7e6b63ccce028fef95faa7a2a25423a4b158) --- .../aztec_node_read_cache.test.ts | 109 ++++++++++++++++++ .../aztec_node_read_cache.ts | 89 ++++++++++++++ .../oracle/utility_execution.test.ts | 102 +++++++++++++++- .../oracle/utility_execution_oracle.ts | 29 ++--- 4 files changed, 313 insertions(+), 16 deletions(-) create mode 100644 yarn-project/pxe/src/contract_function_simulator/aztec_node_read_cache.test.ts create mode 100644 yarn-project/pxe/src/contract_function_simulator/aztec_node_read_cache.ts diff --git a/yarn-project/pxe/src/contract_function_simulator/aztec_node_read_cache.test.ts b/yarn-project/pxe/src/contract_function_simulator/aztec_node_read_cache.test.ts new file mode 100644 index 000000000000..133602298e3c --- /dev/null +++ b/yarn-project/pxe/src/contract_function_simulator/aztec_node_read_cache.test.ts @@ -0,0 +1,109 @@ +import { ARCHIVE_HEIGHT } from '@aztec/constants'; +import { BlockNumber, EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; +import { Fr } from '@aztec/foundation/curves/bn254'; +import { promiseWithResolvers } from '@aztec/foundation/promise'; +import { MembershipWitness } from '@aztec/foundation/trees'; +import { AztecAddress } from '@aztec/stdlib/aztec-address'; +import { BlockHash } from '@aztec/stdlib/block'; +import type { AztecNode } from '@aztec/stdlib/interfaces/server'; +import { PublicDataWitness } from '@aztec/stdlib/trees'; +import { MinedTxReceipt, TxEffect, TxExecutionResult, TxHash, TxStatus } from '@aztec/stdlib/tx'; + +import { mock } from 'jest-mock-extended'; + +import { AztecNodeReadCache } from './aztec_node_read_cache.js'; + +describe('AztecNodeReadCache', () => { + let aztecNode: ReturnType>; + let cache: AztecNodeReadCache; + + const makeMinedReceipt = (txHash: TxHash) => + new MinedTxReceipt( + txHash, + TxStatus.FINALIZED, + TxExecutionResult.SUCCESS, + 0n, + BlockHash.random(), + BlockNumber(1), + SlotNumber(1), + 0, + EpochNumber(1), + TxEffect.empty(), + ); + + beforeEach(() => { + aztecNode = mock(); + cache = new AztecNodeReadCache(aztecNode); + }); + + it('shares concurrent tx receipt reads', async () => { + const txHash = TxHash.random(); + const deferred = promiseWithResolvers>(); + aztecNode.getTxReceipt.mockReturnValue(deferred.promise); + + const first = cache.getTxReceiptWithEffect(txHash); + const second = cache.getTxReceiptWithEffect(txHash); + const receipt = makeMinedReceipt(txHash); + deferred.resolve(receipt); + + await expect(Promise.all([first, second])).resolves.toEqual([receipt, receipt]); + expect(aztecNode.getTxReceipt).toHaveBeenCalledTimes(1); + }); + + it('evicts rejected reads so callers can retry', async () => { + const txHash = TxHash.random(); + const receipt = makeMinedReceipt(txHash); + aztecNode.getTxReceipt.mockRejectedValueOnce(new Error('temporary failure')); + aztecNode.getTxReceipt.mockResolvedValueOnce(receipt); + + await expect(cache.getTxReceiptWithEffect(txHash)).rejects.toThrow('temporary failure'); + await expect(cache.getTxReceiptWithEffect(txHash)).resolves.toBe(receipt); + expect(aztecNode.getTxReceipt).toHaveBeenCalledTimes(2); + }); + + it('keeps cache entries separate by method and arguments', async () => { + const blockHash = BlockHash.random(); + const leafSlot = Fr.random(); + const blockWitness = MembershipWitness.empty(ARCHIVE_HEIGHT); + const publicDataWitness = PublicDataWitness.random(); + aztecNode.getBlockHashMembershipWitness.mockResolvedValue(blockWitness); + aztecNode.getPublicDataWitness.mockResolvedValue(publicDataWitness); + + await expect(cache.getBlockHashMembershipWitness(blockHash, blockHash)).resolves.toBe(blockWitness); + await expect(cache.getPublicDataWitness(blockHash, leafSlot)).resolves.toBe(publicDataWitness); + + expect(aztecNode.getBlockHashMembershipWitness).toHaveBeenCalledTimes(1); + expect(aztecNode.getPublicDataWitness).toHaveBeenCalledTimes(1); + }); + + it('caches successful undefined results', async () => { + const referenceBlockHash = BlockHash.random(); + const blockHash = BlockHash.random(); + aztecNode.getBlockHashMembershipWitness.mockResolvedValue(undefined); + + await expect(cache.getBlockHashMembershipWitness(referenceBlockHash, blockHash)).resolves.toBeUndefined(); + await expect(cache.getBlockHashMembershipWitness(referenceBlockHash, blockHash)).resolves.toBeUndefined(); + + expect(aztecNode.getBlockHashMembershipWitness).toHaveBeenCalledTimes(1); + }); + + it('reuses cached slots across overlapping public storage ranges', async () => { + const blockHash = BlockHash.random(); + const contractAddress = await AztecAddress.random(); + const startStorageSlot = new Fr(100); + aztecNode.getPublicStorageAt.mockImplementation((_block, _contract, slot) => + Promise.resolve(new Fr(slot.value + 1n)), + ); + + await expect(cache.getPublicStorageRange(blockHash, contractAddress, startStorageSlot, 2)).resolves.toEqual([ + new Fr(101), + new Fr(102), + ]); + await expect(cache.getPublicStorageRange(blockHash, contractAddress, new Fr(101), 2)).resolves.toEqual([ + new Fr(102), + new Fr(103), + ]); + + expect(aztecNode.getPublicStorageAt).toHaveBeenCalledTimes(3); + }); +}); diff --git a/yarn-project/pxe/src/contract_function_simulator/aztec_node_read_cache.ts b/yarn-project/pxe/src/contract_function_simulator/aztec_node_read_cache.ts new file mode 100644 index 000000000000..ce7ff940fe61 --- /dev/null +++ b/yarn-project/pxe/src/contract_function_simulator/aztec_node_read_cache.ts @@ -0,0 +1,89 @@ +import { Fr } from '@aztec/foundation/curves/bn254'; +import type { AztecAddress } from '@aztec/stdlib/aztec-address'; +import type { BlockHash, BlockParameter } from '@aztec/stdlib/block'; +import type { AztecNode } from '@aztec/stdlib/interfaces/server'; +import type { TxHash } from '@aztec/stdlib/tx'; + +/** + * Per-execution cache for immutable Aztec node reads. + */ +export class AztecNodeReadCache { + private readonly cache = new Map>(); + + constructor(private readonly aztecNode: AztecNode) {} + + /** Fetches a block without reissuing the same node request. */ + public getBlock(block: BlockParameter) { + return this.#cachedRead(`block:${this.#keyPart(block)}`, () => this.aztecNode.getBlock(block)); + } + + /** Fetches a transaction receipt with its effect attached. */ + public getTxReceiptWithEffect(txHash: TxHash) { + return this.#cachedRead(`tx-receipt-with-effect:${txHash.toString()}`, () => + this.aztecNode.getTxReceipt(txHash, { includeTxEffect: true } as const), + ); + } + + /** Fetches an archive-tree witness for a block hash. */ + public getBlockHashMembershipWitness(referenceBlock: BlockParameter, blockHash: BlockHash) { + return this.#cachedRead( + `block-hash-membership-witness:${this.#keyPart(referenceBlock)}:${blockHash.toString()}`, + () => this.aztecNode.getBlockHashMembershipWitness(referenceBlock, blockHash), + ); + } + + /** Fetches a public-data-tree witness for a leaf slot. */ + public getPublicDataWitness(referenceBlock: BlockParameter, leafSlot: Fr) { + return this.#cachedRead(`public-data-witness:${this.#keyPart(referenceBlock)}:${leafSlot.toString()}`, () => + this.aztecNode.getPublicDataWitness(referenceBlock, leafSlot), + ); + } + + /** Fetches public storage for a single slot. */ + public getPublicStorageAt(referenceBlock: BlockParameter, contractAddress: AztecAddress, storageSlot: Fr) { + return this.#cachedRead( + `public-storage:${this.#keyPart(referenceBlock)}:${contractAddress.toString()}:${storageSlot.toString()}`, + () => this.aztecNode.getPublicStorageAt(referenceBlock, contractAddress, storageSlot), + ); + } + + /** Fetches a contiguous public storage range, reusing cached reads for overlapping slots. */ + public getPublicStorageRange( + referenceBlock: BlockParameter, + contractAddress: AztecAddress, + startStorageSlot: Fr, + numberOfElements: number, + ) { + const slots = Array(numberOfElements) + .fill(0) + .map((_, i) => new Fr(startStorageSlot.value + BigInt(i))); + + return Promise.all(slots.map(storageSlot => this.getPublicStorageAt(referenceBlock, contractAddress, storageSlot))); + } + + #cachedRead(key: string, fetch: () => Promise): Promise { + const cached = this.cache.get(key); + if (cached) { + return cached as Promise; + } + + const promise = fetch(); + promise.catch(() => this.cache.delete(key)); + this.cache.set(key, promise); + return promise; + } + + #keyPart(value: unknown): string { + if (['string', 'number', 'bigint', 'boolean'].includes(typeof value)) { + return String(value); + } + if (value && typeof value === 'object') { + const toString = (value as { toString?: () => string }).toString; + if (toString && toString !== Object.prototype.toString) { + return toString.call(value); + } + return JSON.stringify(value, (_key, nested) => (typeof nested === 'bigint' ? nested.toString() : nested)); + } + return String(value); + } +} diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution.test.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution.test.ts index 0e0c439e9f89..6e5c956302f8 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution.test.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution.test.ts @@ -1,7 +1,9 @@ -import { BlockNumber } from '@aztec/foundation/branded-types'; +import { ARCHIVE_HEIGHT } from '@aztec/constants'; +import { BlockNumber, EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; import { Grumpkin } from '@aztec/foundation/crypto/grumpkin'; import { Fr } from '@aztec/foundation/curves/bn254'; import { GrumpkinScalar } from '@aztec/foundation/curves/grumpkin'; +import { MembershipWitness } from '@aztec/foundation/trees'; import type { KeyStore } from '@aztec/key-store'; import { openTmpStore } from '@aztec/kv-store/lmdb-v2'; import { StatefulTestContractArtifact } from '@aztec/noir-test-contracts.js/StatefulTest'; @@ -28,7 +30,17 @@ import { PublicKeys, deriveKeys, hashPublicKey } from '@aztec/stdlib/keys'; import { AppTaggingSecret, AppTaggingSecretKind, SiloedTag } from '@aztec/stdlib/logs'; import { Note, NoteDao } from '@aztec/stdlib/note'; import { makeL2Tips, randomContractInstanceWithAddress } from '@aztec/stdlib/testing'; -import { BlockHeader, CallContext, Capsule, GlobalVariables, TxHash } from '@aztec/stdlib/tx'; +import { + BlockHeader, + CallContext, + Capsule, + GlobalVariables, + MinedTxReceipt, + TxEffect, + TxExecutionResult, + TxHash, + TxStatus, +} from '@aztec/stdlib/tx'; import { mock } from 'jest-mock-extended'; import type { _MockProxy } from 'jest-mock-extended/lib/Mock.js'; @@ -657,6 +669,92 @@ describe('Utility Execution test suite', () => { }); }); + describe('node read cache', () => { + const makeMinedReceipt = (txHash: TxHash, txEffect = TxEffect.empty()) => + new MinedTxReceipt( + txHash, + TxStatus.FINALIZED, + TxExecutionResult.SUCCESS, + 0n, + BlockHash.random(), + BlockNumber(syncedBlockNumber), + SlotNumber(1), + 0, + EpochNumber(1), + txEffect, + ); + + it('reuses tx-effect reads within a utility execution', async () => { + const oracle = makeOracle({ scopes: [scope] }); + const txHash = TxHash.random(); + aztecNode.getTxReceipt.mockResolvedValue(makeMinedReceipt(txHash)); + + const first = await oracle.getTxEffect(txHash); + const second = await oracle.getTxEffect(txHash); + + expect(first).toEqual(second); + expect(aztecNode.getTxReceipt).toHaveBeenCalledTimes(1); + }); + + it('does not share cached reads across utility executions', async () => { + const txHash = TxHash.random(); + aztecNode.getTxReceipt.mockResolvedValue(makeMinedReceipt(txHash)); + + const firstOracle = makeOracle({ scopes: [scope] }); + const secondOracle = makeOracle({ scopes: [scope] }); + + // Cache works as usual + await firstOracle.getTxEffect(txHash); + await firstOracle.getTxEffect(txHash); + expect(aztecNode.getTxReceipt).toHaveBeenCalledTimes(1); + + // Same call in new oracle, goes to actual node since cache is clean + await secondOracle.getTxEffect(txHash); + expect(aztecNode.getTxReceipt).toHaveBeenCalledTimes(2); + }); + + it('does not cache failed tx-effect reads', async () => { + const oracle = makeOracle({ scopes: [scope] }); + const txHash = TxHash.random(); + aztecNode.getTxReceipt.mockRejectedValueOnce(new Error('temporary failure')); + aztecNode.getTxReceipt.mockResolvedValueOnce(makeMinedReceipt(txHash)); + + await expect(oracle.getTxEffect(txHash)).rejects.toThrow('temporary failure'); + + const result = await oracle.getTxEffect(txHash); + + expect(result.isSome()).toBe(true); + expect(aztecNode.getTxReceipt).toHaveBeenCalledTimes(2); + }); + + it('reuses archive witness reads within a utility execution', async () => { + const oracle = makeOracle({ scopes: [scope] }); + const referenceBlockHash = await anchorBlockHeader.hash(); + const blockHash = BlockHash.random(); + const witness = MembershipWitness.empty(ARCHIVE_HEIGHT); + aztecNode.getBlockHashMembershipWitness.mockResolvedValue(witness); + + const first = await oracle.getBlockHashMembershipWitness(referenceBlockHash, blockHash); + const second = await oracle.getBlockHashMembershipWitness(referenceBlockHash, blockHash); + + expect(first).toEqual(second); + expect(aztecNode.getBlockHashMembershipWitness).toHaveBeenCalledTimes(1); + }); + + it('reuses public storage reads within a utility execution', async () => { + const oracle = makeOracle({ scopes: [scope] }); + const blockHash = await anchorBlockHeader.hash(); + const startStorageSlot = Fr.random(); + aztecNode.getPublicStorageAt.mockResolvedValue(new Fr(7)); + + const first = await oracle.getFromPublicStorage(blockHash, contractAddress, startStorageSlot, 2); + const second = await oracle.getFromPublicStorage(blockHash, contractAddress, startStorageSlot, 2); + + expect(first).toEqual(second); + expect(aztecNode.getPublicStorageAt).toHaveBeenCalledTimes(2); + }); + }); + describe('fact store', () => { const service = new EphemeralArrayService(); const typeId = new Fr(10); 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..0f318021b75f 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 @@ -56,6 +56,7 @@ import type { PrivateEventStore } from '../../storage/private_event_store/privat import type { RecipientTaggingStore } from '../../storage/tagging_store/recipient_tagging_store.js'; import type { TaggingSecretSourcesStore } from '../../storage/tagging_store/tagging_secret_sources_store.js'; import type { AnchoredContractData } from '../anchored_contract_data.js'; +import { AztecNodeReadCache } from '../aztec_node_read_cache.js'; import { EphemeralArrayService } from '../ephemeral_array_service.js'; import { BoundedVec } from '../noir-structs/bounded_vec.js'; import type { EmbeddedCurvePoint } from '../noir-structs/embedded_curve_point.js'; @@ -119,6 +120,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra private offchainEffects: OffchainEffect[] = []; private readonly ephemeralArrayService = new EphemeralArrayService(); protected readonly transientArrayService: TransientArrayService; + private readonly aztecNodeReadCache: AztecNodeReadCache; // We store oracle version to be able to show a nice error message when an oracle handler is missing. private contractOracleVersion: { major: number; minor: number } | undefined; @@ -172,6 +174,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra this.hooks = args.hooks; this.utilityExecutor = args.utilityExecutor; this.transientArrayService = args.transientArrayService; + this.aztecNodeReadCache = new AztecNodeReadCache(args.aztecNode); } public assertCompatibleOracleVersion(major: number, minor: number): void { @@ -265,7 +268,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra // hash at all. If the block hash did not exist by the reference block hash, then the node will not return the // membership witness as there is none. const witness = await this.#queryWithBlockHashNotAfterAnchor(referenceBlockHash, () => - this.aztecNode.getBlockHashMembershipWitness(referenceBlockHash, blockHash), + this.aztecNodeReadCache.getBlockHashMembershipWitness(referenceBlockHash, blockHash), ); return witness ? Option.some(witness) : Option.none(); } @@ -318,7 +321,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra */ public async getPublicDataWitness(blockHash: BlockHash, leafSlot: Fr): Promise { const witness = await this.#queryWithBlockHashNotAfterAnchor(blockHash, () => - this.aztecNode.getPublicDataWitness(blockHash, leafSlot), + this.aztecNodeReadCache.getPublicDataWitness(blockHash, leafSlot), ); if (!witness) { throw new Error(`Public data witness not found for slot ${leafSlot} at block hash ${blockHash.toString()}.`); @@ -511,16 +514,16 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra numberOfElements: number, ) { return this.#queryWithBlockHashNotAfterAnchor(blockHash, async () => { - const slots = Array(numberOfElements) - .fill(0) - .map((_, i) => new Fr(startStorageSlot.value + BigInt(i))); - - const values = await Promise.all( - slots.map(storageSlot => this.aztecNode.getPublicStorageAt(blockHash, contractAddress, storageSlot)), + const values = await this.aztecNodeReadCache.getPublicStorageRange( + blockHash, + contractAddress, + startStorageSlot, + numberOfElements, ); this.logger.debug( - `Oracle storage read: slots=[${slots.map(slot => slot.toString()).join(', ')}] address=${contractAddress.toString()} values=[${values.join(', ')}]`, + `Oracle storage read: start=${startStorageSlot.toString()} count=${numberOfElements} ` + + `address=${contractAddress.toString()} values=[${values.join(', ')}]`, ); return values; @@ -666,7 +669,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra throw new Error('Invalid tx hash passed into aztec_utl_getTxEffect oracle handler'); } - const receipt = await this.aztecNode.getTxReceipt(txHash, { includeTxEffect: true }); + const receipt = await this.aztecNodeReadCache.getTxReceiptWithEffect(txHash); if (!receipt.isMined() || !receipt.txEffect || receipt.blockNumber > this.anchorBlockHeader.getBlockNumber()) { return Option.none(); } @@ -1079,9 +1082,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra */ async #fetchTxEffects(txHashes: TxHash[]): Promise> { const uniqueTxHashes = uniqueBy(txHashes, h => h.toString()); - const fetched = await Promise.all( - uniqueTxHashes.map(h => this.aztecNode.getTxReceipt(h, { includeTxEffect: true })), - ); + const fetched = await Promise.all(uniqueTxHashes.map(h => this.aztecNodeReadCache.getTxReceiptWithEffect(h))); return new Map( uniqueTxHashes .map((h, i): [string, IndexedTxEffect | undefined] => { @@ -1115,7 +1116,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra const [response] = await Promise.all([ query(), (async () => { - const block = await this.aztecNode.getBlock(blockHash); + const block = await this.aztecNodeReadCache.getBlock(blockHash); const header = block?.header; if (!header) { throw new Error(`Could not find block header for block hash ${blockHash}`); From 88e150b716c6cb26f93db601f2c1308488f45bf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Venturo?= Date: Mon, 13 Jul 2026 11:11:04 -0300 Subject: [PATCH 03/13] fix: prevent access to secrets not in scope (#24616) Some stores were being accessed without checking if the associated accounts were in scope or not. For logs I added this to the service, but we're not yet consistent in how we use services (call-lived, tx-lived?) so in other cases it is just inlined in the oracle. (cherry picked from commit ce12e293c4d3aa8eca8a3336453a9c39967df8fc) --- .../oracle/utility_execution.test.ts | 29 ++++++++++++------- .../oracle/utility_execution_oracle.ts | 10 ++++++- yarn-project/pxe/src/logs/log_service.test.ts | 25 +++++++++++++--- yarn-project/pxe/src/logs/log_service.ts | 4 +++ yarn-project/pxe/src/notes/note_service.ts | 2 +- 5 files changed, 53 insertions(+), 17 deletions(-) diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution.test.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution.test.ts index 6e5c956302f8..48beacfb3d23 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution.test.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution.test.ts @@ -564,8 +564,8 @@ describe('Utility Execution test suite', () => { const contractAddressA = await AztecAddress.random(); const contractAddressB = await AztecAddress.random(); - const oracleA = makeOracle({ contractAddress: contractAddressA }); - const oracleB = makeOracle({ contractAddress: contractAddressB }); + const oracleA = makeOracle({ contractAddress: contractAddressA, scopes: [owner] }); + const oracleB = makeOracle({ contractAddress: contractAddressB, scopes: [owner] }); const ephPksArray = EphemeralArray.fromValues(service, [ephPk]); const responseA = await oracleA.getSharedSecrets(owner, ephPksArray, contractAddressA); @@ -593,15 +593,14 @@ describe('Utility Execution test suite', () => { ); }); - it('returns no secrets when the PXE does not hold the keys for the address', async () => { + it('rejects a recipient outside the allowed scopes', async () => { const ephSk = GrumpkinScalar.random(); const ephPk = await Grumpkin.mul(Grumpkin.generator, ephSk); - const foreignAddress = await AztecAddress.random(); const ephPksArray = EphemeralArray.fromValues(service, [ephPk]); - const response = await utilityExecutionOracle.getSharedSecrets(foreignAddress, ephPksArray, contractAddress); - - expect(response.readAll(service)).toEqual([]); + await expect(utilityExecutionOracle.getSharedSecrets(owner, ephPksArray, contractAddress)).rejects.toThrow( + /not in the allowed scopes/, + ); }); }); @@ -643,10 +642,8 @@ describe('Utility Execution test suite', () => { ); }); - const result = await utilityExecutionOracle.getPendingTaggedLogsV2( - owner, - EphemeralArray.fromValues(service, providedSecrets), - ); + const oracle = makeOracle({ scopes: [owner] }); + const result = await oracle.getPendingTaggedLogsV2(owner, EphemeralArray.fromValues(service, providedSecrets)); const queried = aztecNode.getPrivateLogsByTags.mock.calls.flatMap(([query]) => query.tags.map(entry => ('tag' in entry ? entry.tag.value.toString() : entry.value.toString())), @@ -667,6 +664,16 @@ describe('Utility Execution test suite', () => { }, ]); }); + + it('rejects a scope outside the allowed scopes', async () => { + const outOfScope = await AztecAddress.random(); + await expect( + utilityExecutionOracle.getPendingTaggedLogsV2( + outOfScope, + EphemeralArray.fromValues(service, []), + ), + ).rejects.toThrow(/not in the allowed scopes/); + }); }); describe('node read cache', () => { 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 0f318021b75f..ca1849da99a5 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 @@ -48,6 +48,7 @@ import { TxResolverService } from '../../messages/tx_resolver_service.js'; import { NoteService } from '../../notes/note_service.js'; import { ORACLE_VERSION_MAJOR } from '../../oracle_version.js'; import type { AddressStore } from '../../storage/address_store/address_store.js'; +import { assertAllowedScope } from '../../storage/allowed_scopes.js'; import type { CapsuleService } from '../../storage/capsule_store/capsule_service.js'; import { FactCollectionKey, FactCollectionTypeKey, anchoredTipBlockNumbers } from '../../storage/fact_store/index.js'; import type { FactService, OriginBlock } from '../../storage/fact_store/index.js'; @@ -598,6 +599,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra this.recipientTaggingStore, this.taggingSecretSourcesStore, this.addressStore, + this.scopes, this.jobId, this.logger.getBindings(), ); @@ -857,7 +859,8 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra * @param ephPks - Ephemeral array containing the serialized Points. * @param contractAddress - The contract address for app-siloing (validated against execution context). * @returns A new ephemeral array containing the computed shared secrets, or an empty array when the PXE does not - * hold the keys for `address`, signaling that no secrets can be derived for it. + * hold the keys for `address`. + * @throws If `address` is not in the execution's allowed scopes. */ public async getSharedSecrets( address: AztecAddress, @@ -869,6 +872,11 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra `getSharedSecrets called with contract address ${contractAddress}, expected ${this.contractAddress}`, ); } + + assertAllowedScope(address, this.scopes); + + // An address can be in scope without the PXE holding its keys (e.g. syncing a registered but non-owned account), + // in which case no secrets can be derived and we return an empty array rather than failing. const recipientCompleteAddress = await this.addressStore.getCompleteAddress(address); if (!recipientCompleteAddress) { this.logger.warn( diff --git a/yarn-project/pxe/src/logs/log_service.test.ts b/yarn-project/pxe/src/logs/log_service.test.ts index 39b5ff298001..b3dc03c6005b 100644 --- a/yarn-project/pxe/src/logs/log_service.test.ts +++ b/yarn-project/pxe/src/logs/log_service.test.ts @@ -321,13 +321,17 @@ describe('LogService', () => { contractAddress = await AztecAddress.random(); const l2TipsProvider = mock(); - ({ aztecNode, keyStore, taggingSecretSourcesStore, addressStore, logService } = - await createTestLogService(l2TipsProvider)); + const scopes: AztecAddress[] = []; + ({ aztecNode, keyStore, taggingSecretSourcesStore, addressStore, logService } = await createTestLogService( + l2TipsProvider, + scopes, + )); l2TipsProvider.getL2Tips.mockResolvedValue(makeL2Tips(0)); const completeAddress = await keyStore.addAccount(await deriveKeys(Fr.random()), Fr.random()); await addressStore.addCompleteAddress(completeAddress); recipient = completeAddress.address; + scopes.push(recipient); sharedSecret = await Point.random(); }); @@ -396,6 +400,13 @@ describe('LogService', () => { expect(txHashes).not.toContainEqual(directionalLog.txHash); }); + it('rejects a recipient outside the allowed scopes', async () => { + const outOfScope = await AztecAddress.random(); + await expect(logService.fetchTaggedLogs(contractAddress, outOfScope, [])).rejects.toThrow( + /not in the allowed scopes/, + ); + }); + function handshakeTags(secret: Point, app: AztecAddress): Promise { return Promise.all( [AppTaggingSecretKind.UNCONSTRAINED, AppTaggingSecretKind.CONSTRAINED].map(async kind => @@ -421,7 +432,8 @@ describe('LogService', () => { contractAddress = await AztecAddress.random(); const l2TipsProvider = mock(); - const testContext = await createTestLogService(l2TipsProvider); + const scopes: AztecAddress[] = []; + const testContext = await createTestLogService(l2TipsProvider, scopes); ({ aztecNode, keyStore, taggingSecretSourcesStore, addressStore, logService } = testContext); l2TipsProvider.getL2Tips.mockResolvedValue(makeL2Tips(testContext.anchorBlockHeader.globalVariables.blockNumber)); @@ -429,6 +441,7 @@ describe('LogService', () => { recipientCompleteAddress = await keyStore.addAccount(await deriveKeys(new Fr(1)), Fr.random()); recipient = recipientCompleteAddress.address; await addressStore.addCompleteAddress(recipientCompleteAddress); + scopes.push(recipient); sender = await AztecAddress.random(); @@ -469,7 +482,10 @@ describe('LogService', () => { }); }); -async function createTestLogService(l2TipsProvider: MockProxy = mock()) { +async function createTestLogService( + l2TipsProvider: MockProxy = mock(), + scopes: AztecAddress[] = [], +) { const keyStore = new KeyStore(await openTmpStore('test')); const recipientTaggingStore = new RecipientTaggingStore(await openTmpStore('test')); const taggingSecretSourcesStore = new TaggingSecretSourcesStore(await openTmpStore('test')); @@ -486,6 +502,7 @@ async function createTestLogService(l2TipsProvider: MockProxy = recipientTaggingStore, taggingSecretSourcesStore, addressStore, + scopes, 'test', ); diff --git a/yarn-project/pxe/src/logs/log_service.ts b/yarn-project/pxe/src/logs/log_service.ts index 18dd7d2d2a55..2654f928fa3e 100644 --- a/yarn-project/pxe/src/logs/log_service.ts +++ b/yarn-project/pxe/src/logs/log_service.ts @@ -24,6 +24,7 @@ import type { LogRetrievalResponse } from '../contract_function_simulator/noir-s import type { PendingTaggedLog } from '../contract_function_simulator/noir-structs/pending_tagged_log.js'; import { ResolvedTx } from '../contract_function_simulator/noir-structs/resolved_tx.js'; import { AddressStore } from '../storage/address_store/address_store.js'; +import { assertAllowedScope } from '../storage/allowed_scopes.js'; import type { RecipientTaggingStore } from '../storage/tagging_store/recipient_tagging_store.js'; import type { TaggingSecretSourcesStore } from '../storage/tagging_store/tagging_secret_sources_store.js'; import { @@ -47,6 +48,7 @@ export class LogService { private readonly recipientTaggingStore: RecipientTaggingStore, private readonly taggingSecretSourcesStore: TaggingSecretSourcesStore, private readonly addressStore: AddressStore, + private readonly scopes: AztecAddress[], private readonly jobId: string, bindings?: LoggerBindings, ) { @@ -189,6 +191,8 @@ export class LogService { recipient: AztecAddress, providedSecrets: AppTaggingSecret[], ): Promise { + assertAllowedScope(recipient, this.scopes); + this.log.verbose( `Fetching tagged logs for contract ${contractAddress.toString()} and recipient ${recipient.toString()}`, ); diff --git a/yarn-project/pxe/src/notes/note_service.ts b/yarn-project/pxe/src/notes/note_service.ts index cbe1ff82624b..b84ef27739f2 100644 --- a/yarn-project/pxe/src/notes/note_service.ts +++ b/yarn-project/pxe/src/notes/note_service.ts @@ -26,7 +26,7 @@ export class NoteService { * * @param owner - The owner of the notes. If undefined, returns notes for all known owners. * @param status - The status of notes to fetch. - * @param scopes - The accounts whose notes we can access in this call. Currently optional and will default to all. + * @param scopes - The accounts whose notes we can access in this call. An empty list matches no notes. */ public async getNotes( contractAddress: AztecAddress, From cec8b9ce3aad90fda1eaafc12be1a1d7eb6feb01 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Mon, 13 Jul 2026 18:52:00 -0300 Subject: [PATCH 04/13] docs: fee readme improvements (#24666) Additions to the README on gas and fees (cherry picked from commit 4d53a9f48b9d335426f841f9343b0be38372c7ed) --- yarn-project/stdlib/src/gas/README.md | 144 +++++++++++++++++++------- 1 file changed, 108 insertions(+), 36 deletions(-) diff --git a/yarn-project/stdlib/src/gas/README.md b/yarn-project/stdlib/src/gas/README.md index 982252ce703b..1bce97106cdb 100644 --- a/yarn-project/stdlib/src/gas/README.md +++ b/yarn-project/stdlib/src/gas/README.md @@ -1,47 +1,69 @@ # Aztec Gas and Fee Model The minimum fee per mana and its components are computed on L1 in -`l1-contracts/src/core/libraries/rollup/FeeLib.sol`. This document describes the -formulas, the oracle lag/lifetime mechanism, and the TypeScript types in this directory. +`l1-contracts/src/core/libraries/rollup/FeeLib.sol` (`fee_math.ts` in this directory is a +TypeScript port of those formulas, used to predict fees a few slots ahead). This document +describes the formulas, the oracle lag/lifetime mechanism, how a transaction's fee is +derived from them, the gas and data limits, and the TypeScript types in this directory. ## Mana -Aztec uses **mana** as its unit of work (analogous to Ethereum gas). Transactions consume -mana in two dimensions: **DA** (data availability) and **L2** (execution). The total fee -is `gasUsed * feePerMana` summed across both dimensions. +Aztec meters work as gas in two dimensions: **DA** (data availability, i.e. blob data +published to L1) and **L2** (execution). The L2 dimension is called **mana** (analogous to +Ethereum gas): block headers track `totalManaUsed`, and the fee model below prices one unit +of mana. The total fee is `gasUsed * feePerGas` summed across both dimensions. + +### The DA dimension is priced at zero + +Only the L2 dimension currently carries a price. When building checkpoint global variables, +the sequencer sets `feePerDaGas = 0` and sets `feePerL2Gas` to the L1-computed minimum fee +per mana (`sequencer-client/src/global_variable_builder/global_builder.ts` and +`fee_provider.ts` in the same directory). The cost of publishing data is still recovered: +the blob gas a checkpoint pays on L1 is part of the *sequencer cost* component of the mana +fee below. DA gas remains fully metered and limited (see +[Gas and Data Limits](#gas-and-data-limits)) — it bounds how much data a tx or checkpoint +may publish — it just contributes nothing to the fee today. ## Fee Components -The minimum fee per mana has four components: +The minimum fee per mana has four components. All are computed in ETH (wei) per mana and +converted to the fee asset at the end (see [Fee Asset Price](#fee-asset-price)). ### Sequencer Cost L1 cost to propose a checkpoint (calldata gas + blob data), amortized over `manaTarget`: ``` -sequencerCost = ((L1_GAS_PER_CHECKPOINT_PROPOSED * baseFee) +sequencerCost = ceil(((L1_GAS_PER_CHECKPOINT_PROPOSED * baseFee) + (BLOBS_PER_CHECKPOINT * BLOB_GAS_PER_BLOB * blobFee)) - / manaTarget + / manaTarget) ``` +Note that `BLOBS_PER_CHECKPOINT` here is FeeLib's own constant (3), not the protocol blob +capacity of the same name (6) — see the note under [Key Constants](#key-constants). + ### Prover Cost L1 cost to verify an epoch proof, amortized over epoch duration and `manaTarget`, plus a governance-set proving cost that compensates for off-chain proof generation: ``` -proverCost = (L1_GAS_PER_EPOCH_VERIFIED * baseFee / epochDuration) / manaTarget +proverCost = ceil(ceil((L1_GAS_PER_EPOCH_VERIFIED * baseFee) / epochDuration) / manaTarget) + provingCostPerMana ``` +Updates to `provingCostPerMana` are rate-limited on L1 (`FeeLib.updateProvingCostPerMana`): +at most one update every 30 days, each moving the value by at most ×1.5 (or ÷1.5), with a +floor of 2 wei per mana. + ### Congestion Cost An exponential surcharge when the network is congested (inspired by EIP-1559; the implementation uses the `fakeExponential` Taylor series approximation from EIP-4844): ``` -baseCost = sequencerCost + proverCost -congestionCost = baseCost * congestionMultiplier / MINIMUM_CONGESTION_MULTIPLIER - baseCost +baseCost = sequencerCost + proverCost +congestionCost = floor(baseCost * congestionMultiplier / MINIMUM_CONGESTION_MULTIPLIER) - baseCost ``` When there is no congestion the multiplier equals `MINIMUM_CONGESTION_MULTIPLIER` (1e9) @@ -50,11 +72,15 @@ and congestion cost is zero. ### Congestion Multiplier ``` -excessMana = max(0, prevExcessMana + prevManaUsed - manaTarget) -congestionMultiplier = fakeExponential(MINIMUM_CONGESTION_MULTIPLIER, excessMana, denominator) +excessMana = max(0, prevExcessMana + prevManaUsed - manaTarget) +denominator = manaTarget * 854,700,854 / 1e8 ≈ 8.547 * manaTarget +congestionMultiplier = fakeExponential(MINIMUM_CONGESTION_MULTIPLIER, + min(excessMana, 100 * denominator), denominator) ``` -Each additional `manaTarget` of excess mana increases the multiplier by ~12.5%. +Each additional `manaTarget` of excess mana multiplies the fee by `e^(1/8.547) ≈ 1.124`, +i.e. ~12.5%. The exponent is capped at 100 (multiplier ≤ ~2.7e43 × the minimum) to keep +the Taylor series from overflowing. ### Total @@ -62,6 +88,11 @@ Each additional `manaTarget` of excess mana increases the multiplier by ~12.5%. minFeePerMana = sequencerCost + proverCost + congestionCost ``` +Each component is converted from ETH to the fee asset individually (rounding up) before +summing. The sum is capped at `type(uint128).max` (`FeeLib.summedMinFee`) so it always fits +the proposal header's `feePerL2Gas` field — without the cap, extreme congestion could +produce a fee no valid header can represent, halting the chain. + ## L1 Gas Oracle: Lag and Lifetime The oracle feeds Ethereum's `baseFee` and `blobFee` into the fee model using a two-phase @@ -70,9 +101,9 @@ The oracle feeds Ethereum's `baseFee` and `blobFee` into the fee model using a t - **LAG = 2 slots** — when new L1 fees are observed, they activate `LAG` slots later (`slotOfChange = currentSlot + LAG`). This gives mempool transactions time to land before fees change. -- **LIFETIME = 5 slots** — after an oracle update, the next update is rejected until - `slotOfChange + (LIFETIME - LAG)` = 3 more slots have passed. This rate-limits how - frequently L1 fee data can change. +- **LIFETIME = 5 slots** — after an oracle update, further updates are ignored + (`updateL1GasFeeOracle` returns without effect) until `slotOfChange + (LIFETIME - LAG)` + = 3 more slots have passed. This rate-limits how frequently L1 fee data can change. Fee resolution at a given timestamp: @@ -84,6 +115,12 @@ else → use post (new fees) **Net effect**: L1 fee changes reach L2 with a 2-slot delay and can update at most once every 5 slots. +Because queued values only activate `LAG` slots later, the min fee for the next `LAG` +slots is fully determined by current on-chain state. `fee_math.ts` ports the FeeLib +formulas so the sequencer can predict min fees over that window (`FeePredictor` in +`sequencer-client/src/global_variable_builder`), under a configurable mana-usage +assumption (`ManaUsageEstimate`: none / target / limit). + ### Worked Example Suppose the oracle is updated at slot 10 with new L1 fees. Here is the timeline: @@ -117,34 +154,68 @@ Key observations: ## Fee Asset Price -Fees are computed in ETH internally but converted to the fee asset (Fee Juice) via -`ethPerFeeAsset` (1e12 precision). The price updates at most ±1% (±100 bps) per -checkpoint: +Fees are computed in ETH (wei) internally and converted to the fee asset (Fee Juice) via +`ethPerFeeAsset` (1e12 precision), rounding up (`PriceLib.toFeeAsset` in +`l1-contracts/src/core/libraries/compressed-data/fees/FeeConfig.sol`). + +Each checkpoint proposal carries a fee-asset price modifier (`OracleInput`) chosen by the +proposer, bounded to ±1% (±100 bps) per checkpoint: ``` newPrice = currentPrice * (10000 + modifierBps) / 10000 ``` +The result is clamped to [`MIN_ETH_PER_FEE_ASSET` = 100, `MAX_ETH_PER_FEE_ASSET` = 1e14], +i.e. 1e-10 to 100 ETH per fee asset. The floor of 100 guarantees a ±1% step always moves +the integer price by at least 1. + +## Transaction Fees + +The checkpoint's `gasFees` (the L1-computed min fee per mana, with DA priced at zero) act +as a base fee. Senders declare `GasSettings`: gas limits, teardown gas limits, +`maxFeesPerGas`, and `maxPriorityFeesPerGas`. A tx is only includable if `maxFeesPerGas` +covers the checkpoint's `gasFees` in both dimensions. The effective fee adds an +EIP-1559-style priority fee on top of the base, capped by the max +(`computeEffectiveGasFees` in `stdlib/src/fees/transaction_fee.ts`): + +``` +effectiveFeePerGas = gasFees + min(maxPriorityFeePerGas, maxFeesPerGas - gasFees) (per dimension) +transactionFee = billedGas.daGas * effectiveFeePerDaGas + billedGas.l2Gas * effectiveFeePerL2Gas +``` + +`billedGas` is actual consumption except for the teardown phase, which is billed at the +declared `teardownGasLimits` rather than actual usage — the fee is charged during teardown +itself, before actual teardown consumption is known. + ## Maximum Fee Change Rate -| Component | Bound | -| ---------------------- | ------------------------------------------------------- | -| L1 base fee / blob fee | At most once every 5 slots (oracle LIFETIME) | -| Fee asset price | ±1% per checkpoint | +| Component | Bound | +| ---------------------- | -------------------------------------------------------- | +| L1 base fee / blob fee | At most once every 5 slots (oracle LIFETIME) | +| Fee asset price | ±1% per checkpoint | +| Proving cost per mana | At most ×1.5 (or ÷1.5) per update, one update per 30 days | | Congestion multiplier | Depends on excess mana accumulation/drain per checkpoint | -| Sequencer/prover costs | Scale linearly with L1 fees | +| Sequencer/prover costs | Scale linearly with L1 fees | ## Key Constants -| Constant | Value | -| ------------------------------ | -------------- | -| `L1_GAS_PER_CHECKPOINT_PROPOSED` | 300,000 | -| `L1_GAS_PER_EPOCH_VERIFIED` | 3,600,000 | -| `BLOBS_PER_CHECKPOINT` | 3 | -| `BLOB_GAS_PER_BLOB` | 2^17 | -| `MINIMUM_CONGESTION_MULTIPLIER` | 1e9 | -| `LAG` | 2 slots | -| `LIFETIME` | 5 slots | +| Constant | Value | +| ------------------------------- | -------------- | +| `L1_GAS_PER_CHECKPOINT_PROPOSED` | 300,000 | +| `L1_GAS_PER_EPOCH_VERIFIED` | 3,600,000 | +| `BLOBS_PER_CHECKPOINT` (FeeLib) | 3 | +| `BLOB_GAS_PER_BLOB` | 2^17 | +| `MINIMUM_CONGESTION_MULTIPLIER` | 1e9 | +| `LAG` | 2 slots | +| `LIFETIME` | 5 slots | +| `MIN_ETH_PER_FEE_ASSET` | 100 (1e-10 ETH) | +| `MAX_ETH_PER_FEE_ASSET` | 1e14 (100 ETH) | + +⚠️ Name clash: `FeeLib.sol` (and its port `fee_math.ts`) defines `BLOBS_PER_CHECKPOINT = 3`, +used only to price the sequencer's blob costs. The protocol constant of the same name in +`@aztec/constants` is **6** — the actual blob capacity of a checkpoint, used throughout the +limits section below. The FeeLib value is a holdover from the pre-checkpoint +`BLOBS_PER_BLOCK`, so the fee model prices half of a full checkpoint's blobs. ## Gas and Data Limits @@ -196,7 +267,7 @@ advertises them in `NodeInfo.txsLimits` (a required field); wallets read it and `GasSettings.fallback` as the default gas limits when sending without explicit limits, and they are enforced by `GasLimitsValidator` (clamped to the per-tx protocol maxima) at three points: RPC tx acceptance (`aztec-node/src/aztec-node/server.ts`), gossip validation (`p2p/src/services/libp2p/libp2p_service.ts`), -and pending-pool admission (`p2p/src/client/factory.ts`). They are deliberately *not* enforced at reqresp or +and pending-pool admission (`p2p/src/client/factory.ts`). They are deliberately *not* enforced at req/resp or block-proposal validation — admission is relay policy, not block validity. ### Per-block builder budgets @@ -240,7 +311,8 @@ The outermost limits, enforced as proposal validity in `validateCheckpointLimits ## TypeScript Types -- **`Gas`** — mana quantity in two dimensions (`daGas`, `l2Gas`). +- **`Gas`** — gas quantity in two dimensions (`daGas`, `l2Gas`). - **`GasFees`** — per-unit price in each dimension (`feePerDaGas`, `feePerL2Gas`). - **`GasSettings`** — sender-chosen fee parameters: gas limits, teardown limits, max fees, priority fees. - **`GasUsed`** — actual consumption after execution. Note: `billedGas` uses the teardown gas *limit*, not actual usage. +- **`fee_math.ts`** — TypeScript port of the FeeLib formulas, used for fee prediction over the oracle LAG window. From b4aefe1d83dc449ab10cc1b942caa676a343e3c1 Mon Sep 17 00:00:00 2001 From: Maxim Vezenov Date: Tue, 14 Jul 2026 11:07:46 -0400 Subject: [PATCH 05/13] fix(pxe): widen tracked sender tagging ranges with onchain discovery evidence (#24655) Fixes the sender tag sync conflict from https://gist.github.com/nventuro/0aa690736b1d2865e27197723a814e9d, including the "Window straddle" residual edge. Discovery could re-derive a different index range for an already tracked (secret, txHash) pair and hit the `Conflicting range` throw in `storePendingIndexes`, permanently wedging the secret. Two triggers: - A same-PXE tx partially reverts: the chain only shows the surviving non-revertible sub-range of the prove-time entry, and the throw fired before the finalized receipt step could resolve it. - A tx straddles a sync window boundary: the window loop assembles its range piecewise, so window 2 stores a different range than window 1 (latent regardless of reverts). Fix: discovery passes `mergeExisting` to `storePendingIndexes`, which widens the stored entry to the union of both ranges (grow-only, driven by onchain evidence). The finalized receipt step still resolves partial reverts. The only other writer of pending ranges, `persistSenderTaggingIndexRangesForTx` (records the indexes a tx sent from this PXE used, at prove time), does not pass the flag and still throws on a mismatch: there it indicates a bug, not partial onchain evidence. Red-green (all fail on the base with the `Conflicting range` throw): - partial revert repro: finalizes the surviving index, frees the squashed ones, repeat sync is a no-op - cross-sync widen: an entry tracked from an earlier sync grows when discovery evidences further indexes - window straddle: drives the actual window advance loop, asserts per window queried tags and that the widened entry later finalizes cleanly Store-level tests pin the union semantics: a sub-range keeps the entry, a range beyond it widens it, an untracked tx still stores. (cherry picked from commit fd7515dd75a6198bc7f24b6da14b229e1672033b) --- .../sender_tagging_store.test.ts | 37 +++- .../tagging_store/sender_tagging_store.ts | 77 ++++++-- .../sync_sender_tagging_indexes.test.ts | 176 ++++++++++++++++-- .../sync_sender_tagging_indexes.ts | 2 +- ...load_and_store_new_tagging_indexes.test.ts | 34 ++-- .../load_and_store_new_tagging_indexes.ts | 8 +- 6 files changed, 281 insertions(+), 53 deletions(-) diff --git a/yarn-project/pxe/src/storage/tagging_store/sender_tagging_store.test.ts b/yarn-project/pxe/src/storage/tagging_store/sender_tagging_store.test.ts index 59a456dc1c32..a84403c49995 100644 --- a/yarn-project/pxe/src/storage/tagging_store/sender_tagging_store.test.ts +++ b/yarn-project/pxe/src/storage/tagging_store/sender_tagging_store.test.ts @@ -31,14 +31,17 @@ describe('SenderTaggingStore', () => { }); describe('storePendingIndexes', () => { - it('stores a single pending index range', async () => { + it.each([ + ['', 'storePendingIndexes'], + [' when merging', 'mergePendingIndexes'], + ] as const)('stores a single pending index range for an untracked tx%s', async (_name, method) => { const txHash = TxHash.random(); - await taggingStore.storePendingIndexes([range(secret1, 5)], txHash, 'test'); + await taggingStore[method]([range(secret1, 5)], txHash, 'test'); const txHashes = await taggingStore.getTxHashesOfPendingIndexes(secret1, 0, 10, 'test'); - expect(txHashes).toHaveLength(1); - expect(txHashes[0]).toEqual(txHash); + expect(txHashes).toEqual([txHash]); + expect(await taggingStore.getLastUsedIndex(secret1, 'test')).toBe(5); }); it('stores multiple pending index ranges for different secrets', async () => { @@ -107,6 +110,32 @@ describe('SenderTaggingStore', () => { ); }); + it('keeps the existing range when merging in a sub-range for the same tx', async () => { + const txHash = TxHash.random(); + + // Prove-time entry spanning setup and app-logic phase logs. + await taggingStore.storePendingIndexes([range(secret1, 4, 6)], txHash, 'test'); + + // Discovery of the surviving sub-range of a partially reverted tx must not throw nor shrink the entry. + await taggingStore.mergePendingIndexes([range(secret1, 4)], txHash, 'test'); + + expect(await taggingStore.getLastUsedIndex(secret1, 'test')).toBe(6); + }); + + it('widens the existing range to the union when merging in a range beyond it', async () => { + const txHash = TxHash.random(); + + // A prior window discovered only part of the tx's range. + await taggingStore.storePendingIndexes([range(secret1, 4, 6)], txHash, 'test'); + + // Discovery evidences further onchain indexes for the same tx — the entry must grow to cover them. + await taggingStore.mergePendingIndexes([range(secret1, 7, 8)], txHash, 'test'); + + const txHashes = await taggingStore.getTxHashesOfPendingIndexes(secret1, 0, 10, 'test'); + expect(txHashes).toEqual([txHash]); + expect(await taggingStore.getLastUsedIndex(secret1, 'test')).toBe(8); + }); + it('throws when storing a pending index range lower than the last finalized index', async () => { const txHash1 = TxHash.random(); const txHash2 = TxHash.random(); diff --git a/yarn-project/pxe/src/storage/tagging_store/sender_tagging_store.ts b/yarn-project/pxe/src/storage/tagging_store/sender_tagging_store.ts index f288387d38b2..c0446fef8b74 100644 --- a/yarn-project/pxe/src/storage/tagging_store/sender_tagging_store.ts +++ b/yarn-project/pxe/src/storage/tagging_store/sender_tagging_store.ts @@ -128,10 +128,11 @@ export class SenderTaggingStore implements StagedStore { } /** - * Stores pending index ranges. + * Stores pending index ranges, rejecting any range that disagrees with an already-stored one. * @remarks If the same (secret, txHash) pair already exists in the db with an equal range, it's a no-op. This is * expected to happen because whenever we start sync we start from the last finalized index and we can have pending - * ranges already stored from previous syncs. If the ranges differ, it throws an error as that indicates a bug. + * ranges already stored from previous syncs. If the ranges differ, it throws an error as that indicates a bug in + * callers that record indexes at prove time. Discovery from onchain logs must use `mergePendingIndexes` instead. * @param ranges - The tagging index ranges containing the directional app tagging secrets and the index ranges that are * to be stored in the db. * @param txHash - The tx in which the tagging indexes were used in private logs. @@ -141,6 +142,33 @@ export class SenderTaggingStore implements StagedStore { * @throws If a different range already exists for the same (secret, txHash) pair. */ storePendingIndexes(ranges: TaggingIndexRange[], txHash: TxHash, jobId: string): Promise { + return this.#storePendingIndexes(ranges, txHash, jobId, false); + } + + /** + * Stores pending index ranges, widening an existing entry for the same (secret, txHash) pair to the union of the + * stored and incoming ranges instead of throwing on a mismatch. Discovery from onchain logs needs this: it may see + * only the surviving (non-revertible phase) sub-range of a partially reverted tx recorded at prove time (the + * finalized receipt step of the sync resolves that difference), or indexes beyond a partially discovered entry + * when a tx from another PXE straddles a sync window boundary. Callers that record indexes at prove time must use + * `storePendingIndexes` instead, so that a range disagreement surfaces as a bug rather than being absorbed. + * @param ranges - The tagging index ranges containing the directional app tagging secrets and the index ranges that are + * to be stored in the db. + * @param txHash - The tx in which the tagging indexes were used in private logs. + * @param jobId - job context for staged writes to this store. See `JobCoordinator` for more details. + * @throws If the highestIndex is further than window length from the highest finalized index for the same secret. + * @throws If the lowestIndex is lower than or equal to the last finalized index for the same secret. + */ + mergePendingIndexes(ranges: TaggingIndexRange[], txHash: TxHash, jobId: string): Promise { + return this.#storePendingIndexes(ranges, txHash, jobId, true); + } + + #storePendingIndexes( + ranges: TaggingIndexRange[], + txHash: TxHash, + jobId: string, + mergeExisting: boolean, + ): Promise { if (ranges.length === 0) { return Promise.resolve(); } @@ -187,21 +215,40 @@ export class SenderTaggingStore implements StagedStore { // Check if an entry with the same txHash already exists const existingEntry = pendingData.find(entry => entry.txHash === txHashStr); - if (existingEntry) { - // Assert that the ranges are equal — different ranges for the same (secret, txHash) indicates a bug - if (existingEntry.lowestIndex !== range.lowestIndex || existingEntry.highestIndex !== range.highestIndex) { - throw new Error( - `Conflicting range for secret ${secretStr} and txHash ${txHashStr}: ` + - `existing [${existingEntry.lowestIndex}, ${existingEntry.highestIndex}] vs ` + - `new [${range.lowestIndex}, ${range.highestIndex}]`, - ); - } - // Exact duplicate — skip - } else { - this.#writePendingIndexes(jobId, secretStr, [ + let updatedPending: PendingIndexesEntry[] | undefined; + if (!existingEntry) { + updatedPending = [ ...pendingData, { lowestIndex: range.lowestIndex, highestIndex: range.highestIndex, txHash: txHashStr }, - ]); + ]; + } else if (mergeExisting) { + // Widen the entry to the union of both ranges, never shrink it: replacing would drop prove-time + // indexes the chain doesn't show (partially reverted tx), and skipping would drop onchain indexes + // discovered in a later sync window (tx straddling the window boundary). + const lowestIndex = Math.min(existingEntry.lowestIndex, range.lowestIndex); + const highestIndex = Math.max(existingEntry.highestIndex, range.highestIndex); + if (lowestIndex !== existingEntry.lowestIndex || highestIndex !== existingEntry.highestIndex) { + updatedPending = pendingData.map(entry => + entry === existingEntry ? { lowestIndex, highestIndex, txHash: entry.txHash } : entry, + ); + } + } else if ( + existingEntry.lowestIndex !== range.lowestIndex || + existingEntry.highestIndex !== range.highestIndex + ) { + // Different ranges for the same (secret, txHash) indicate a bug in callers that record indexes at prove + // time. + throw new Error( + `Conflicting range for secret ${secretStr} and txHash ${txHashStr}: ` + + `existing [${existingEntry.lowestIndex}, ${existingEntry.highestIndex}] vs ` + + `new [${range.lowestIndex}, ${range.highestIndex}]`, + ); + } + // Remaining cases (a merge whose union equals the stored range, or an identical range without + // mergeExisting): duplicate evidence, nothing to write. + + if (updatedPending) { + this.#writePendingIndexes(jobId, secretStr, updatedPending); } } }); diff --git a/yarn-project/pxe/src/tagging/sender_sync/sync_sender_tagging_indexes.test.ts b/yarn-project/pxe/src/tagging/sender_sync/sync_sender_tagging_indexes.test.ts index 534c47c7d450..81cf4c8867cb 100644 --- a/yarn-project/pxe/src/tagging/sender_sync/sync_sender_tagging_indexes.test.ts +++ b/yarn-project/pxe/src/tagging/sender_sync/sync_sender_tagging_indexes.test.ts @@ -394,26 +394,34 @@ describe('syncSenderTaggingIndexes', () => { expect(aztecNode.getTxReceipt).toHaveBeenCalledWith(pendingTxHash); }); - it('handles a partially reverted transaction', async () => { + /** + * Same-PXE partial revert: the pending range was recorded at prove time and spans both the non-revertible (setup) + * and revertible (app logic) phases. After the tx mines with reverted app logic, only the setup-phase logs are + * onchain, so discovery re-derives a narrower range for the same (secret, txHash). That narrower range must not + * conflict with the prove-time entry — the finalized receipt step of the sync owns resolving the difference. + */ + it('handles a partially reverted tx whose pending range was recorded at prove time', async () => { await setUp(); const revertedTxHash = TxHash.random(); - // Create logs at indexes 4 and 6 for the same (reverted) tx + // Prove-time persist: logs at indexes 4 (setup phase) through 6 (app logic phase) under the same secret. + await taggingStore.storePendingIndexes( + [{ extendedSecret: secret, lowestIndex: 4, highestIndex: 6 }], + revertedTxHash, + 'test', + ); + + // Only the setup-phase log survived the revert, so the node only knows the tag at index 4. const tag4 = await computeSiloedTagForIndex(4); + // The app-logic tags at indexes 5 and 6 were squashed by the revert and never reached the chain. + const tag5 = await computeSiloedTagForIndex(5); const tag6 = await computeSiloedTagForIndex(6); aztecNode.getPrivateLogsByTags.mockImplementation(query => { const tags = query.tags as SiloedTag[]; return Promise.resolve( - tags.map((tag: SiloedTag) => { - if (tag.equals(tag4)) { - return [makeLog(revertedTxHash, tag4.value)]; - } else if (tag.equals(tag6)) { - return [makeLog(revertedTxHash, tag6.value)]; - } - return []; - }), + tags.map((tag: SiloedTag) => (tag.equals(tag4) ? [makeLog(revertedTxHash, tag4.value)] : [])), ); }); @@ -432,17 +440,157 @@ describe('syncSenderTaggingIndexes', () => { [], // contractClassLogs ); - // Mock getTxReceipt to return a FINALIZED + REVERTED mined receipt carrying the tx effect. The same receipt - // satisfies both the status-classification call and the includeTxEffect follow-up call. aztecNode.getTxReceipt.mockResolvedValue( mined(revertedTxHash, TxStatus.FINALIZED, 14, TxExecutionResult.REVERTED, txEffect), ); await syncSenderTaggingIndexes(secret, aztecNode, taggingStore, MOCK_ANCHOR_BLOCK_HASH, 'test'); - // Index 4 should be finalized (it survived the partial revert) + // The surviving index is finalized and the squashed indexes 5-6 are freed for reuse. expect(await taggingStore.getLastFinalizedIndex(secret, 'test')).toBe(4); - // No pending indexes should remain for this secret expect(await taggingStore.getLastUsedIndex(secret, 'test')).toBe(4); + // Reconciliation must remove the pending entry entirely — a stale entry would keep resurfacing in later syncs. + const pendingAfterSync = await taggingStore.getTxHashesOfPendingIndexes( + secret, + 0, + UNFINALIZED_TAGGING_INDEXES_WINDOW_LEN, + 'test', + ); + expect(pendingAfterSync).toEqual([]); + + // Premise guard: discovery blindly probes every index in the window, so the first sync must have queried the full + // prove-time range [4, 6], with only the setup-phase tag getting an onchain answer. If the sync ever stopped + // probing these indexes, the discovery merge this test exists to exercise would silently stop happening. + const queriedTags = aztecNode.getPrivateLogsByTags.mock.calls.flatMap(([query]) => query.tags as SiloedTag[]); + expect(queriedTags.some(tag => tag.equals(tag4))).toBe(true); + expect(queriedTags.some(tag => tag.equals(tag5))).toBe(true); + expect(queriedTags.some(tag => tag.equals(tag6))).toBe(true); + + // A repeat sync must be a clean no-op: the behavior being pinned here is that the secret will not throw on every + // subsequent sync. + await syncSenderTaggingIndexes(secret, aztecNode, taggingStore, MOCK_ANCHOR_BLOCK_HASH, 'test'); + + expect(await taggingStore.getLastFinalizedIndex(secret, 'test')).toBe(4); + expect(await taggingStore.getLastUsedIndex(secret, 'test')).toBe(4); + }); + + /** + * Cross-device straddle: another PXE sharing this directional secret sent a tx, and an earlier window discovered + * only part of its index range, so the store already tracks a narrower entry for the same (secret, txHash). + * Discovery must widen the entry to cover every index evidenced onchain, so that the next index choice accounts + * for them. + */ + it('widens a tracked pending range when discovery evidences further indexes for the same tx', async () => { + await setUp(); + + const foreignTxHash = TxHash.random(); + + // An earlier window discovered only the first index of the foreign tx. + await taggingStore.storePendingIndexes( + [{ extendedSecret: secret, lowestIndex: 10, highestIndex: 10 }], + foreignTxHash, + 'test', + ); + + // The chain shows the tx actually used indexes 10 and 11. + const tag10 = await computeSiloedTagForIndex(10); + const tag11 = await computeSiloedTagForIndex(11); + + aztecNode.getPrivateLogsByTags.mockImplementation(query => { + const tags = query.tags as SiloedTag[]; + return Promise.resolve( + tags.map((tag: SiloedTag) => { + if (tag.equals(tag10)) { + return [makeLog(foreignTxHash, tag10.value)]; + } else if (tag.equals(tag11)) { + return [makeLog(foreignTxHash, tag11.value)]; + } + return []; + }), + ); + }); + + // The tx is mined but not yet finalized, so no receipt status change resolves the entry during this sync. + aztecNode.getTxReceipt.mockResolvedValue(mined(foreignTxHash, TxStatus.PROPOSED, 14)); + + await syncSenderTaggingIndexes(secret, aztecNode, taggingStore, MOCK_ANCHOR_BLOCK_HASH, 'test'); + + // The next index choice must account for the onchain tag at index 11. + expect(await taggingStore.getLastUsedIndex(secret, 'test')).toBe(11); + expect(await taggingStore.getLastFinalizedIndex(secret, 'test')).toBeUndefined(); + }); + + /** + * Single-sync window straddle: a foreign tx's tags span the boundary between two consecutive sync windows, so the + * window loop assembles the tx's range piecewise — window 1 stores the lower index, window 2 evidences the higher + * one for the same (secret, txHash). The second write must widen the entry from window 1 rather than conflict with + * it, and the final index choice must cover the full onchain range. + */ + it('assembles a pending range piecewise when a tx straddles the sync window boundary', async () => { + await setUp(); + + // A tx finalized at index 0 makes the finalized index advance during window 1, so the loop proceeds to window 2. + const finalizedTxHash = TxHash.random(); + const finalizedTag = await computeSiloedTagForIndex(0); + + // The straddling tx used the last index of window 1 and the first index of window 2. + const straddlingTxHash = TxHash.random(); + const lowerStraddleIndex = UNFINALIZED_TAGGING_INDEXES_WINDOW_LEN - 1; + const upperStraddleIndex = UNFINALIZED_TAGGING_INDEXES_WINDOW_LEN; + const lowerStraddleTag = await computeSiloedTagForIndex(lowerStraddleIndex); + const upperStraddleTag = await computeSiloedTagForIndex(upperStraddleIndex); + + aztecNode.getPrivateLogsByTags.mockImplementation(query => { + const tags = query.tags as SiloedTag[]; + return Promise.resolve( + tags.map((tag: SiloedTag) => { + if (tag.equals(finalizedTag)) { + return [makeLog(finalizedTxHash, finalizedTag.value)]; + } else if (tag.equals(lowerStraddleTag)) { + return [makeLog(straddlingTxHash, lowerStraddleTag.value)]; + } else if (tag.equals(upperStraddleTag)) { + return [makeLog(straddlingTxHash, upperStraddleTag.value)]; + } + return []; + }), + ); + }); + + aztecNode.getTxReceipt.mockImplementation((hash: TxHash) => { + if (hash.equals(finalizedTxHash)) { + return Promise.resolve(mined(hash, TxStatus.FINALIZED, 14)); + } else if (hash.equals(straddlingTxHash)) { + // Mined but not finalized, so no receipt status change resolves the straddling entry during this sync. + return Promise.resolve(mined(hash, TxStatus.PROPOSED, 16)); + } + throw new Error(`Unexpected tx hash: ${hash.toString()}`); + }); + + await syncSenderTaggingIndexes(secret, aztecNode, taggingStore, MOCK_ANCHOR_BLOCK_HASH, 'test'); + + // The straddled range must have been assembled piecewise: window 1's logs query covers the lower straddle index + // and window 2's the upper. (Each window fits in a single RPC page, so there is one logs call per window.) + expect(aztecNode.getPrivateLogsByTags).toHaveBeenCalledTimes(2); + const queriedTags = aztecNode.getPrivateLogsByTags.mock.calls.map(([query]) => query.tags as SiloedTag[]); + expect(queriedTags[0].some(tag => tag.equals(lowerStraddleTag))).toBe(true); + expect(queriedTags[1].some(tag => tag.equals(upperStraddleTag))).toBe(true); + + expect(await taggingStore.getLastFinalizedIndex(secret, 'test')).toBe(0); + // The next index choice must account for both straddled onchain tags. + expect(await taggingStore.getLastUsedIndex(secret, 'test')).toBe(upperStraddleIndex); + + // The straddling tx later finalizes. A single widened entry finalizes cleanly at the upper index — a duplicate + // entry for the same txHash would instead trip the multiple-pending-entries guard during finalization. + aztecNode.getTxReceipt.mockImplementation((hash: TxHash) => { + if (hash.equals(straddlingTxHash)) { + return Promise.resolve(mined(hash, TxStatus.FINALIZED, 18)); + } + throw new Error(`Unexpected tx hash: ${hash.toString()}`); + }); + + await syncSenderTaggingIndexes(secret, aztecNode, taggingStore, MOCK_ANCHOR_BLOCK_HASH, 'test'); + + expect(await taggingStore.getLastFinalizedIndex(secret, 'test')).toBe(upperStraddleIndex); + expect(await taggingStore.getLastUsedIndex(secret, 'test')).toBe(upperStraddleIndex); }); }); diff --git a/yarn-project/pxe/src/tagging/sender_sync/sync_sender_tagging_indexes.ts b/yarn-project/pxe/src/tagging/sender_sync/sync_sender_tagging_indexes.ts index 2b93d497787f..c3263fcf592e 100644 --- a/yarn-project/pxe/src/tagging/sender_sync/sync_sender_tagging_indexes.ts +++ b/yarn-project/pxe/src/tagging/sender_sync/sync_sender_tagging_indexes.ts @@ -74,7 +74,7 @@ export async function syncSenderTaggingIndexes( } // Receipts for pending tx hashes that the logs query just surfaced still need a sequential follow-up call. - // `storePendingIndexes` is idempotent on (secret, txHash), so a re-discovered hash stays classified as known + // `mergePendingIndexes` is idempotent on (secret, txHash), so a re-discovered hash stays classified as known // and is not re-fetched here. const knownSet = new Set(knownPendingTxHashes.map(h => h.toString())); const newPendingTxHashes = allPendingTxHashes.filter(h => !knownSet.has(h.toString())); diff --git a/yarn-project/pxe/src/tagging/sender_sync/utils/load_and_store_new_tagging_indexes.test.ts b/yarn-project/pxe/src/tagging/sender_sync/utils/load_and_store_new_tagging_indexes.test.ts index accc15e25667..2f64205750c9 100644 --- a/yarn-project/pxe/src/tagging/sender_sync/utils/load_and_store_new_tagging_indexes.test.ts +++ b/yarn-project/pxe/src/tagging/sender_sync/utils/load_and_store_new_tagging_indexes.test.ts @@ -57,7 +57,7 @@ describe('loadAndStoreNewTaggingIndexes', () => { await loadAndStoreNewTaggingIndexes(secret, 0, 10, aztecNode, taggingStore, MOCK_ANCHOR_BLOCK_HASH, 'test'); - expect(taggingStore.storePendingIndexes).not.toHaveBeenCalled(); + expect(taggingStore.mergePendingIndexes).not.toHaveBeenCalled(); }); it('single log found at a specific index', async () => { @@ -72,8 +72,8 @@ describe('loadAndStoreNewTaggingIndexes', () => { await loadAndStoreNewTaggingIndexes(secret, 0, 10, aztecNode, taggingStore, MOCK_ANCHOR_BLOCK_HASH, 'test'); - expect(taggingStore.storePendingIndexes).toHaveBeenCalledTimes(1); - expect(taggingStore.storePendingIndexes).toHaveBeenCalledWith( + expect(taggingStore.mergePendingIndexes).toHaveBeenCalledTimes(1); + expect(taggingStore.mergePendingIndexes).toHaveBeenCalledWith( [{ extendedSecret: secret, lowestIndex: index, highestIndex: index }], txHash, 'test', @@ -103,8 +103,8 @@ describe('loadAndStoreNewTaggingIndexes', () => { await loadAndStoreNewTaggingIndexes(secret, 0, 10, aztecNode, taggingStore, MOCK_ANCHOR_BLOCK_HASH, 'test'); - expect(taggingStore.storePendingIndexes).toHaveBeenCalledTimes(1); - expect(taggingStore.storePendingIndexes).toHaveBeenCalledWith( + expect(taggingStore.mergePendingIndexes).toHaveBeenCalledTimes(1); + expect(taggingStore.mergePendingIndexes).toHaveBeenCalledWith( [{ extendedSecret: secret, lowestIndex: index1, highestIndex: index2 }], txHash, 'test', @@ -135,13 +135,13 @@ describe('loadAndStoreNewTaggingIndexes', () => { await loadAndStoreNewTaggingIndexes(secret, 0, 10, aztecNode, taggingStore, MOCK_ANCHOR_BLOCK_HASH, 'test'); - expect(taggingStore.storePendingIndexes).toHaveBeenCalledTimes(2); - expect(taggingStore.storePendingIndexes).toHaveBeenCalledWith( + expect(taggingStore.mergePendingIndexes).toHaveBeenCalledTimes(2); + expect(taggingStore.mergePendingIndexes).toHaveBeenCalledWith( [{ extendedSecret: secret, lowestIndex: index1, highestIndex: index1 }], txHash1, 'test', ); - expect(taggingStore.storePendingIndexes).toHaveBeenCalledWith( + expect(taggingStore.mergePendingIndexes).toHaveBeenCalledWith( [{ extendedSecret: secret, lowestIndex: index2, highestIndex: index2 }], txHash2, 'test', @@ -164,13 +164,13 @@ describe('loadAndStoreNewTaggingIndexes', () => { await loadAndStoreNewTaggingIndexes(secret, 0, 10, aztecNode, taggingStore, MOCK_ANCHOR_BLOCK_HASH, 'test'); - expect(taggingStore.storePendingIndexes).toHaveBeenCalledTimes(2); - expect(taggingStore.storePendingIndexes).toHaveBeenCalledWith( + expect(taggingStore.mergePendingIndexes).toHaveBeenCalledTimes(2); + expect(taggingStore.mergePendingIndexes).toHaveBeenCalledWith( [{ extendedSecret: secret, lowestIndex: index, highestIndex: index }], txHash1, 'test', ); - expect(taggingStore.storePendingIndexes).toHaveBeenCalledWith( + expect(taggingStore.mergePendingIndexes).toHaveBeenCalledWith( [{ extendedSecret: secret, lowestIndex: index, highestIndex: index }], txHash2, 'test', @@ -216,18 +216,18 @@ describe('loadAndStoreNewTaggingIndexes', () => { await loadAndStoreNewTaggingIndexes(secret, 0, 10, aztecNode, taggingStore, MOCK_ANCHOR_BLOCK_HASH, 'test'); - expect(taggingStore.storePendingIndexes).toHaveBeenCalledTimes(3); - expect(taggingStore.storePendingIndexes).toHaveBeenCalledWith( + expect(taggingStore.mergePendingIndexes).toHaveBeenCalledTimes(3); + expect(taggingStore.mergePendingIndexes).toHaveBeenCalledWith( [{ extendedSecret: secret, lowestIndex: 1, highestIndex: 8 }], txHash1, 'test', ); - expect(taggingStore.storePendingIndexes).toHaveBeenCalledWith( + expect(taggingStore.mergePendingIndexes).toHaveBeenCalledWith( [{ extendedSecret: secret, lowestIndex: 3, highestIndex: 5 }], txHash2, 'test', ); - expect(taggingStore.storePendingIndexes).toHaveBeenCalledWith( + expect(taggingStore.mergePendingIndexes).toHaveBeenCalledWith( [{ extendedSecret: secret, lowestIndex: 9, highestIndex: 9 }], txHash3, 'test', @@ -261,8 +261,8 @@ describe('loadAndStoreNewTaggingIndexes', () => { await loadAndStoreNewTaggingIndexes(secret, start, end, aztecNode, taggingStore, MOCK_ANCHOR_BLOCK_HASH, 'test'); // Only the log at start should be stored; end is exclusive - expect(taggingStore.storePendingIndexes).toHaveBeenCalledTimes(1); - expect(taggingStore.storePendingIndexes).toHaveBeenCalledWith( + expect(taggingStore.mergePendingIndexes).toHaveBeenCalledTimes(1); + expect(taggingStore.mergePendingIndexes).toHaveBeenCalledWith( [{ extendedSecret: secret, lowestIndex: start, highestIndex: start }], txHashAtStart, 'test', diff --git a/yarn-project/pxe/src/tagging/sender_sync/utils/load_and_store_new_tagging_indexes.ts b/yarn-project/pxe/src/tagging/sender_sync/utils/load_and_store_new_tagging_indexes.ts index 88e32f7fca10..7080a8d509e4 100644 --- a/yarn-project/pxe/src/tagging/sender_sync/utils/load_and_store_new_tagging_indexes.ts +++ b/yarn-project/pxe/src/tagging/sender_sync/utils/load_and_store_new_tagging_indexes.ts @@ -36,11 +36,15 @@ export async function loadAndStoreNewTaggingIndexes( const txsForTags = await getTxsContainingTags(siloedTagsForWindow, aztecNode, anchorBlockHash); const txIndexesMap = getTxIndexesMap(txsForTags, start, siloedTagsForWindow.length); - // Now we iterate over the map, construct the tagging index ranges and store them in the db. + // Now we iterate over the map, construct the tagging index ranges and store them in the db. A tx already tracked + // in the store is merged rather than range-checked: if this PXE sent the tx and it partially reverted, the chain + // only shows the surviving sub-range of the prove-time entry (the finalized receipt step of the sync owns + // resolving that difference), and a tx from another PXE may straddle a sync window boundary, in which case the + // entry is widened so the next index choice covers the full onchain range. for (const [txHashStr, indexes] of txIndexesMap.entries()) { const txHash = TxHash.fromString(txHashStr); const ranges = [{ extendedSecret, lowestIndex: Math.min(...indexes), highestIndex: Math.max(...indexes) }]; - await taggingStore.storePendingIndexes(ranges, txHash, jobId); + await taggingStore.mergePendingIndexes(ranges, txHash, jobId); } } From 65bdbe2a22c893ce0c9dcc0d9f24b82ffcbdedeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Venturo?= Date: Mon, 20 Jul 2026 15:41:57 -0300 Subject: [PATCH 06/13] fix: tagging secrets not being scoped by sender (#24772) This fixes a scoping bug where an account's ivsk would be used to produce an ECDH shared secret even if that account was not in scope. The base wallet class was adjusted to put these in scope when an explicit sender is selected for tagged messages. (cherry picked from commit 499400637f4ff3623680193a7bfb55f92e8f4cb8) --- yarn-project/cli-wallet/src/utils/wallet.ts | 7 ++-- .../end-to-end/src/test-wallet/test_wallet.ts | 4 +-- .../oracle/private_execution_oracle.test.ts | 32 ++++++++++++++++++- .../oracle/private_execution_oracle.ts | 19 +++++++---- .../wallet-sdk/src/base-wallet/base_wallet.ts | 17 +++++++--- .../src/embedded/embedded_wallet.test.ts | 7 ++-- .../wallets/src/embedded/embedded_wallet.ts | 2 +- 7 files changed, 68 insertions(+), 20 deletions(-) diff --git a/yarn-project/cli-wallet/src/utils/wallet.ts b/yarn-project/cli-wallet/src/utils/wallet.ts index 865705f7038d..605939978ab1 100644 --- a/yarn-project/cli-wallet/src/utils/wallet.ts +++ b/yarn-project/cli-wallet/src/utils/wallet.ts @@ -145,7 +145,10 @@ export class CLIWallet extends BaseWallet { increasedFee: InteractionFeeOptions, ): Promise { const cancellationTxRequest = await this.createCancellationTxExecutionRequest(from, txNonce, increasedFee); - return await this.pxe.proveTx(cancellationTxRequest, { scopes: this.scopesFrom(from), senderForTags: from }); + return await this.pxe.proveTx(cancellationTxRequest, { + scopes: this.scopesFrom(from, [], undefined), + senderForTags: from, + }); } override async getAccountFromAddress(address: AztecAddress) { @@ -292,7 +295,7 @@ export class CLIWallet extends BaseWallet { opts: SimulateViaEntrypointOptions, ): Promise { const { from, feeOptions, additionalScopes, sendMessagesAs } = opts; - const scopes = this.scopesFrom(from, additionalScopes); + const scopes = this.scopesFrom(from, additionalScopes ?? [], sendMessagesAs); const feeExecutionPayload = await feeOptions.walletFeePaymentMethod?.getExecutionPayload(); const finalExecutionPayload = feeExecutionPayload ? mergeExecutionPayloads([feeExecutionPayload, executionPayload]) 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 862c2f864af2..0bf10aba4d2b 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 @@ -321,7 +321,7 @@ export class TestWallet extends BaseWallet { opts: SimulateViaEntrypointOptions, ): Promise { const { from, feeOptions, additionalScopes, skipTxValidation, skipFeeEnforcement, sendMessagesAs } = opts; - const scopes = this.scopesFrom(from, additionalScopes); + const scopes = this.scopesFrom(from, additionalScopes ?? [], sendMessagesAs); const skipKernels = this.simulationMode !== 'full'; const useOverride = this.simulationMode === 'kernelless-override'; @@ -387,7 +387,7 @@ export class TestWallet extends BaseWallet { }); const txRequest = await this.createTxExecutionRequestFromPayloadAndFee(exec, opts.from, fee); const txProvingResult = await this.pxe.proveTx(txRequest, { - scopes: this.scopesFrom(opts.from, opts.additionalScopes), + scopes: this.scopesFrom(opts.from, opts.additionalScopes ?? [], opts.sendMessagesAs), senderForTags: this.senderForTagsFrom(opts.from, opts.sendMessagesAs), }); return new ProvenTx( 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 5d2de605b03d..5c36893a0895 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 @@ -1,12 +1,13 @@ import { MAX_PROCESSABLE_L2_GAS, MAX_TX_DA_GAS } from '@aztec/constants'; import { Fr } from '@aztec/foundation/curves/bn254'; -import { Point } from '@aztec/foundation/curves/grumpkin'; +import { GrumpkinScalar, Point } from '@aztec/foundation/curves/grumpkin'; import type { KeyStore } from '@aztec/key-store'; import { WASMSimulator } from '@aztec/simulator/client'; import { FunctionSelector } from '@aztec/stdlib/abi'; import type { AuthWitness } from '@aztec/stdlib/auth-witness'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; import type { L2TipsProvider } from '@aztec/stdlib/block'; +import { CompleteAddress } from '@aztec/stdlib/contract'; import { Gas, GasFees, GasSettings } from '@aztec/stdlib/gas'; import type { AztecNode } from '@aztec/stdlib/interfaces/server'; import { AppTaggingSecret, AppTaggingSecretKind } from '@aztec/stdlib/logs'; @@ -80,6 +81,35 @@ describe('PrivateExecutionOracle', () => { }); }); + describe('getAppTaggingSecret', () => { + it('rejects a sender outside the execution scopes', async () => { + const inScopeSender = await AztecAddress.random(); + const outOfScopeSender = await AztecAddress.random(); + const recipient = await AztecAddress.random(); + const oracle = makeOracle({ scopes: [inScopeSender] }); + + await expect(oracle.getAppTaggingSecret(outOfScopeSender, recipient)).rejects.toThrow( + 'is not in the allowed scopes list', + ); + }); + + it('returns a secret for a sender inside the execution scopes', async () => { + const senderCompleteAddress = await CompleteAddress.random(); + const sender = senderCompleteAddress.address; + const recipient = (await CompleteAddress.random()).address; + + const addressStore = mock(); + addressStore.getCompleteAddress.mockResolvedValue(senderCompleteAddress); + const keyStore = mock(); + keyStore.getMasterIncomingViewingSecretKey.mockResolvedValue(GrumpkinScalar.random()); + + const oracle = makeOracle({ scopes: [sender], addressStore, keyStore }); + + const result = await oracle.getAppTaggingSecret(sender, recipient); + expect(result.isSome()).toBe(true); + }); + }); + describe('resolveTaggingStrategy', () => { let sender: AztecAddress; let recipient: AztecAddress; 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 f9b615e8aceb..89dd9da44dee 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 @@ -35,6 +35,7 @@ import type { TaggingSecretStrategy, } from '../../hooks/resolve_tagging_secret_strategy.js'; import { NoteService } from '../../notes/note_service.js'; +import { assertAllowedScope } from '../../storage/allowed_scopes.js'; import type { SenderTaggingStore } from '../../storage/tagging_store/sender_tagging_store.js'; import { syncSenderTaggingIndexes } from '../../tagging/index.js'; import type { ExecutionNoteCache } from '../execution_note_cache.js'; @@ -321,7 +322,17 @@ export class PrivateExecutionOracle extends UtilityExecutionOracle implements IP * @returns The app tagging secret, or `None` if the recipient is invalid. */ public async getAppTaggingSecret(sender: AztecAddress, recipient: AztecAddress): Promise> { - const extendedSecret = await this.#calculateAppTaggingSecret(this.contractAddress, sender, recipient); + assertAllowedScope(sender, this.scopes); + + const senderCompleteAddress = await this.getCompleteAddressOrFail(sender); + const senderIvsk = await this.keyStore.getMasterIncomingViewingSecretKey(sender); + const extendedSecret = await AppTaggingSecret.computeViaEcdh( + senderCompleteAddress, + senderIvsk, + recipient, + this.contractAddress, + recipient, + ); if (!extendedSecret) { this.logger.warn(`Computing a tagging secret for invalid recipient ${recipient} - returning no secret`, { @@ -354,12 +365,6 @@ export class PrivateExecutionOracle extends UtilityExecutionOracle implements IP return index; } - async #calculateAppTaggingSecret(contractAddress: AztecAddress, sender: AztecAddress, recipient: AztecAddress) { - const senderCompleteAddress = await this.getCompleteAddressOrFail(sender); - const senderIvsk = await this.keyStore.getMasterIncomingViewingSecretKey(sender); - return AppTaggingSecret.computeViaEcdh(senderCompleteAddress, senderIvsk, recipient, contractAddress, recipient); - } - async #getIndexToUseForSecret(secret: AppTaggingSecret): Promise { // If we have the tagging index in the cache, we use it. If not we obtain it from the execution data provider. const lastUsedIndexInTx = this.taggingIndexCache.getLastUsedIndex(secret); diff --git a/yarn-project/wallet-sdk/src/base-wallet/base_wallet.ts b/yarn-project/wallet-sdk/src/base-wallet/base_wallet.ts index 8ddf690bbb3e..7d1e942b2c41 100644 --- a/yarn-project/wallet-sdk/src/base-wallet/base_wallet.ts +++ b/yarn-project/wallet-sdk/src/base-wallet/base_wallet.ts @@ -125,8 +125,15 @@ export abstract class BaseWallet implements Wallet { protected log = createLogger('wallet-sdk:base_wallet'), ) {} - protected scopesFrom(from: AztecAddress | NoFrom, additionalScopes: AztecAddress[] = []): AztecAddress[] { - const allScopes = from === NO_FROM ? additionalScopes : [from, ...additionalScopes]; + protected scopesFrom( + from: AztecAddress | NoFrom, + additionalScopes: AztecAddress[], + sendMessagesAs: AztecAddress | undefined, + ): AztecAddress[] { + // The sendMessagesAs account must be in scope so that its tagging secrets can be accessed. + const tagSenderScopes = sendMessagesAs ? [sendMessagesAs] : []; + const baseScopes = from === NO_FROM ? [] : [from]; + const allScopes = [...baseScopes, ...additionalScopes, ...tagSenderScopes]; const scopeSet = new Set(allScopes.map(address => address.toString())); return [...scopeSet].map(AztecAddress.fromStringUnsafe); } @@ -402,7 +409,7 @@ export abstract class BaseWallet implements Wallet { simulatePublic: true, skipTxValidation: opts.skipTxValidation, skipFeeEnforcement: opts.skipFeeEnforcement, - scopes: this.scopesFrom(opts.from, opts.additionalScopes), + scopes: this.scopesFrom(opts.from, opts.additionalScopes ?? [], opts.sendMessagesAs), senderForTags: this.senderForTagsFrom(opts.from, opts.sendMessagesAs), overrides: opts.overrides, }); @@ -498,7 +505,7 @@ export abstract class BaseWallet implements Wallet { return this.pxe.profileTx(txRequest, { profileMode: opts.profileMode, skipProofGeneration: opts.skipProofGeneration ?? true, - scopes: this.scopesFrom(opts.from, opts.additionalScopes), + scopes: this.scopesFrom(opts.from, opts.additionalScopes ?? [], opts.sendMessagesAs), senderForTags: this.senderForTagsFrom(opts.from, opts.sendMessagesAs), }); } @@ -515,7 +522,7 @@ export abstract class BaseWallet implements Wallet { }); const txRequest = await this.createTxExecutionRequestFromPayloadAndFee(executionPayload, opts.from, feeOptions); const provenTx = await this.pxe.proveTx(txRequest, { - scopes: this.scopesFrom(opts.from, opts.additionalScopes), + scopes: this.scopesFrom(opts.from, opts.additionalScopes ?? [], opts.sendMessagesAs), senderForTags: this.senderForTagsFrom(opts.from, opts.sendMessagesAs), }); const offchainOutput = extractOffchainOutput( diff --git a/yarn-project/wallets/src/embedded/embedded_wallet.test.ts b/yarn-project/wallets/src/embedded/embedded_wallet.test.ts index e91818763460..cea4feec1f14 100644 --- a/yarn-project/wallets/src/embedded/embedded_wallet.test.ts +++ b/yarn-project/wallets/src/embedded/embedded_wallet.test.ts @@ -42,7 +42,7 @@ describe('EmbeddedWallet', () => { }); describe('sendTx', () => { - it('passes sendMessagesAs as senderForTags to PXE simulation', async () => { + it('passes sendMessagesAs as senderForTags and includes it in scopes for PXE simulation', async () => { getPredictedMinFees.mockResolvedValue([new GasFees(2, 2)]); getNodeInfo.mockResolvedValue({ l1ChainId: 1, @@ -69,7 +69,10 @@ describe('EmbeddedWallet', () => { expect(simulateTx).toHaveBeenCalledWith( expect.anything(), - expect.objectContaining({ senderForTags: sendMessagesAs }), + expect.objectContaining({ + senderForTags: sendMessagesAs, + scopes: expect.arrayContaining([sendMessagesAs]), + }), ); }); diff --git a/yarn-project/wallets/src/embedded/embedded_wallet.ts b/yarn-project/wallets/src/embedded/embedded_wallet.ts index 7616c047c59e..bbb83cef7546 100644 --- a/yarn-project/wallets/src/embedded/embedded_wallet.ts +++ b/yarn-project/wallets/src/embedded/embedded_wallet.ts @@ -330,7 +330,7 @@ export class EmbeddedWallet extends BaseWallet { opts: SimulateViaEntrypointOptions, ): Promise { const { from, feeOptions, additionalScopes, skipTxValidation, skipFeeEnforcement, sendMessagesAs } = opts; - const scopes = this.scopesFrom(from, additionalScopes); + const scopes = this.scopesFrom(from, additionalScopes ?? [], sendMessagesAs); const feeExecutionPayload = await feeOptions.walletFeePaymentMethod?.getExecutionPayload(); const finalExecutionPayload = feeExecutionPayload From f0be1c6c6424d6392d3e8ba57a89fcc6fd28c673 Mon Sep 17 00:00:00 2001 From: aminsammara Date: Thu, 9 Jul 2026 09:50:05 +0000 Subject: [PATCH 07/13] fix(aztec.js): give waitForNode a bounded default timeout waitForNode called retryUntil without a timeout, and retryUntil treats a zero timeout as the never-time-out sentinel, so an unreachable node caused the call to poll getNodeInfo forever with no way for the caller to bound it. Add an optional { timeout, interval } argument and default it to DefaultWaitOpts (300s / 1s), matching the sibling waitForTx. An unreachable node now rejects with a TimeoutError; pass timeout: 0 to opt into the previous infinite-wait behavior. (cherry picked from commit 482a6b86532bb8fb5f83c57d37230025c581fcce) --- yarn-project/aztec.js/src/utils/node.test.ts | 24 ++++++++++++- yarn-project/aztec.js/src/utils/node.ts | 37 +++++++++++++------- 2 files changed, 48 insertions(+), 13 deletions(-) diff --git a/yarn-project/aztec.js/src/utils/node.test.ts b/yarn-project/aztec.js/src/utils/node.test.ts index 18ea35d88e06..deec8998aba5 100644 --- a/yarn-project/aztec.js/src/utils/node.test.ts +++ b/yarn-project/aztec.js/src/utils/node.test.ts @@ -1,4 +1,5 @@ import { BlockNumber, EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; +import { TimeoutError } from '@aztec/foundation/error'; import { BlockHash } from '@aztec/stdlib/block'; import type { AztecNode } from '@aztec/stdlib/interfaces/client'; import { @@ -14,7 +15,7 @@ import { import { type MockProxy, mock } from 'jest-mock-extended'; -import { waitForTx } from './node.js'; +import { waitForNode, waitForTx } from './node.js'; describe('waitForTx', () => { let node: MockProxy; @@ -137,3 +138,24 @@ describe('waitForTx', () => { }); }); }); + +describe('waitForNode', () => { + let node: MockProxy; + + beforeEach(() => { + node = mock(); + }); + + it('resolves once the node becomes reachable', async () => { + node.getNodeInfo.mockRejectedValueOnce(new Error('not up yet')).mockResolvedValueOnce({} as any); + + await expect(waitForNode(node, undefined, { timeout: 5, interval: 0.01 })).resolves.toBeUndefined(); + expect(node.getNodeInfo).toHaveBeenCalledTimes(2); + }); + + it('rejects with a TimeoutError when the node stays unreachable', async () => { + node.getNodeInfo.mockRejectedValue(new Error('unreachable')); + + await expect(waitForNode(node, undefined, { timeout: 0.05, interval: 0.01 })).rejects.toThrow(TimeoutError); + }); +}); diff --git a/yarn-project/aztec.js/src/utils/node.ts b/yarn-project/aztec.js/src/utils/node.ts index ee88b68a7159..7f92a1a77948 100644 --- a/yarn-project/aztec.js/src/utils/node.ts +++ b/yarn-project/aztec.js/src/utils/node.ts @@ -6,18 +6,31 @@ import { SortedTxStatuses, TxStatus } from '@aztec/stdlib/tx'; import { DefaultWaitOpts, type WaitOpts } from '../contract/wait_opts.js'; -export const waitForNode = async (node: AztecNode, logger?: Logger) => { - await retryUntil(async () => { - try { - logger?.verbose('Attempting to contact Aztec node...'); - await node.getNodeInfo(); - logger?.verbose('Contacted Aztec node'); - return true; - } catch { - logger?.verbose('Failed to contact Aztec Node'); - } - return undefined; - }, 'RPC Get Node Info'); +/** + * Waits for an Aztec node to become reachable, polling {@link AztecNode.getNodeInfo} until it succeeds. + * @param node - The Aztec node to contact. + * @param logger - Optional logger for polling progress. + * @param opts - Optional timeout and interval (in seconds). `timeout` defaults to {@link DefaultWaitOpts.timeout}; + * pass `timeout: 0` to wait indefinitely. + * @throws TimeoutError if the node stays unreachable past the timeout. + */ +export const waitForNode = async (node: AztecNode, logger?: Logger, opts?: Pick) => { + await retryUntil( + async () => { + try { + logger?.verbose('Attempting to contact Aztec node...'); + await node.getNodeInfo(); + logger?.verbose('Contacted Aztec node'); + return true; + } catch { + logger?.verbose('Failed to contact Aztec Node'); + } + return undefined; + }, + 'RPC Get Node Info', + opts?.timeout ?? DefaultWaitOpts.timeout, + opts?.interval ?? DefaultWaitOpts.interval, + ); }; /** Returns true if the receipt status is at least the desired status level. */ From ef3ff6c9d053b090097421b108ce4e8804f3ac04 Mon Sep 17 00:00:00 2001 From: Maxim Vezenov Date: Wed, 8 Jul 2026 18:39:41 -0400 Subject: [PATCH 08/13] fix(txe): align tagging strategy oracle with PXE (#24561) Fixes F-700 Fixes F-765 - makes TXE tagging secret strategy resolution mode-aware, matching PXE's resolveTaggingSecretStrategy hook request - supports configuring unconstrained and constrained delivery strategies independently in Noir tests - falls back to PXE's default strategy for unset modes, while preserving the no-hook path when no strategy is configured - adds coverage for resolved tagging strategy serialization/deserialization and per-mode TXE defaults - updates execution hook docs for the new Noir test helpers (cherry picked from commit 6dd127124d1c0e2020b3d1d838a28c960b703fa6) --- .../pxe/execution_hooks.md | 13 ++- .../aztec/src/messages/delivery/tag.nr | 23 +++++ .../aztec-nr/aztec/src/test/helpers/mod.nr | 2 +- .../test/helpers/tagging_secret_strategy.nr | 45 ++++++--- .../src/test/helpers/test_environment.nr | 53 ++++++++--- .../test/resolve_tagging_strategy.nr | 91 ++++++++++++++----- .../aztec/src/test/helpers/txe_oracles.nr | 9 +- .../src/main.nr | 8 +- .../src/test.nr | 91 +++++++++++++++++-- .../src/automine/delivery/constrained.test.ts | 23 ++++- .../noir-structs/resolved_tagging_strategy.ts | 8 ++ .../oracle/oracle_type_mappings.test.ts | 21 +++++ .../oracle/private_execution_oracle.ts | 10 +- yarn-project/pxe/src/hooks/index.ts | 1 + .../hooks/resolve_tagging_secret_strategy.ts | 3 + yarn-project/txe/src/oracle/interfaces.ts | 9 +- .../oracle/tagging_secret_strategy.test.ts | 47 ++++++++++ .../txe/src/oracle/tagging_secret_strategy.ts | 25 +++++ .../txe/src/oracle/txe_oracle_registry.ts | 9 +- .../oracle/txe_oracle_top_level_context.ts | 28 ++++-- .../txe/src/oracle/txe_oracle_version.ts | 4 +- yarn-project/txe/src/rpc_translator.ts | 7 +- yarn-project/txe/src/txe_session.ts | 18 ++-- 23 files changed, 447 insertions(+), 101 deletions(-) create mode 100644 yarn-project/txe/src/oracle/tagging_secret_strategy.test.ts create mode 100644 yarn-project/txe/src/oracle/tagging_secret_strategy.ts 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 7d9fbfa86d59..6b3dc2c43775 100644 --- a/docs/docs-developers/docs/foundational-topics/pxe/execution_hooks.md +++ b/docs/docs-developers/docs/foundational-topics/pxe/execution_hooks.md @@ -82,14 +82,23 @@ For an unconstrained self-send (the recipient is one of the wallet's own account ### In Noir tests -When testing in Noir, leaving the strategy unset makes `TestEnvironment` fall back to the bare PXE default. Set a strategy when creating the environment to exercise a specific one; it affects message delivery in private executions: +When testing in Noir, leaving the strategy unset makes `TestEnvironment` fall back to the bare PXE default. Set a strategy +when creating the environment to exercise a specific one; it affects message delivery in private executions that use the +default wallet strategy hook. Use `with_default_tag_secret_strategy` to configure the strategy for a specific delivery +mode: ```rust let env = TestEnvironment::new_opts( - TestEnvironmentOptions::new().with_tagging_secret_strategy(TaggingSecretStrategy::non_interactive_handshake()), + TestEnvironmentOptions::new().with_default_tag_secret_strategy( + MessageDelivery::onchain_unconstrained(), + TaggingSecretStrategy::non_interactive_handshake(), + ), ); ``` +Use `with_default_tag_secret_strategy_all_modes` only when the same strategy should apply to both constrained and +unconstrained delivery. Contract-fixed delivery derivations bypass this default strategy. + ### In production Pass a `resolveTaggingSecretStrategy` hook when [creating the PXE](#configuring-hooks). It receives a `TaggingSecretStrategyRequest` with the executing contract's address and the message's sender, recipient, and delivery mode (`'constrained'` or `'unconstrained'`), so a wallet can apply per-application or per-recipient policies, or surface the decision to the user, instead of returning a fixed value. diff --git a/noir-projects/aztec-nr/aztec/src/messages/delivery/tag.nr b/noir-projects/aztec-nr/aztec/src/messages/delivery/tag.nr index 056e5479782e..93ee3b2b3041 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/delivery/tag.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/delivery/tag.nr @@ -180,6 +180,29 @@ mod test { }); } + #[test(should_fail_with = "an unconstrained tagging secret cannot back constrained delivery")] + unconstrained fn constrained_delivery_rejects_a_resolved_unconstrained_secret() { + let env = TestEnvironment::new(); + let secret: Field = 7; + let index: u32 = 0; + + env.private_context(|context| { + mock_existing_handshake_secrets(Option::none()); + let _ = OracleMock::mock("aztec_prv_resolveTaggingStrategy").returns( + ResolvedTaggingStrategy::unconstrained_secret(secret), + ); + let _ = OracleMock::mock("aztec_prv_getNextTaggingIndex").returns(index); + + let _ = derive_log_tag( + context, + OnchainDeliveryMode::onchain_constrained(), + SENDER, + RECIPIENT, + Option::none(), + ); + }); + } + #[test] unconstrained fn constrained_delivery_emits_the_sequence_nullifier_and_constrained_tag() { let env = TestEnvironment::new(); diff --git a/noir-projects/aztec-nr/aztec/src/test/helpers/mod.nr b/noir-projects/aztec-nr/aztec/src/test/helpers/mod.nr index 6cd7f18ac34f..1732db45a308 100644 --- a/noir-projects/aztec-nr/aztec/src/test/helpers/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/test/helpers/mod.nr @@ -13,7 +13,7 @@ pub mod test_environment; quote { private_call_new_flow_oracle }, // TODO: implement once we support more complex types quote { public_call_new_flow_oracle }, // TODO: implement once we support more complex types quote { set_private_txe_context_oracle }, // TODO: implement once we support more complex types - quote { set_tagging_secret_strategy }, // TODO: implement once we support more complex types + quote { set_tagging_secret_strategies }, // TODO: implement once we support more complex types ], )] pub mod txe_oracles; 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 93f4baa0b0c8..8a694b5b3592 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 @@ -1,4 +1,11 @@ -use crate::protocol::{point::EmbeddedCurvePoint, traits::{Deserialize, Serialize}, utils::reader::Reader}; +use crate::keys::ecdh_shared_secret::compute_app_siloed_shared_secret; +use crate::protocol::{ + address::AztecAddress, + hash::poseidon2_hash, + point::EmbeddedCurvePoint, + traits::{Deserialize, Serialize, ToField}, + utils::reader::Reader, +}; global NON_INTERACTIVE_HANDSHAKE: u8 = 1; global ARBITRARY_SECRET: u8 = 2; @@ -7,7 +14,7 @@ global INTERACTIVE_HANDSHAKE: u8 = 4; /// How a message's tagging secret is chosen: the wallet's strategy. /// -/// This type only exists for tests: a Noir test sets it via `with_tagging_secret_strategy` on +/// This type only exists for tests: a Noir test sets it via `with_default_tag_secret_strategy` on /// [`TestEnvironmentOptions`](crate::test::helpers::test_environment::TestEnvironmentOptions). /// Production wallets express the strategy in PXE; the Noir path only consumes the resolved /// [`ResolvedTaggingStrategy`](crate::messages::delivery::ResolvedTaggingStrategy). @@ -45,18 +52,26 @@ impl TaggingSecretStrategy { /// Validates a raw discriminant, as deserialization must always reject unknown values. fn from_parts(kind: u8, secret: EmbeddedCurvePoint) -> Self { let strategy = Self { kind, secret }; + let is_no_payload_strategy = + (kind == NON_INTERACTIVE_HANDSHAKE) | (kind == INTERACTIVE_HANDSHAKE) | (kind == ADDRESS_DERIVED); assert( - ( - ((kind == NON_INTERACTIVE_HANDSHAKE) | (kind == ADDRESS_DERIVED) | (kind == INTERACTIVE_HANDSHAKE)) - & secret.is_infinite() - ) - | (kind == ARBITRARY_SECRET), + (is_no_payload_strategy & secret.is_infinite()) | (kind == ARBITRARY_SECRET), f"unrecognized tagging secret strategy kind: {kind}", ); strategy } } +/// Computes the directional tagging secret PXE derives from an arbitrary shared secret point: the point is app-siloed +/// and the recipient is folded in for direction. Mirrors `AppTaggingSecret.computeDirectional` on the PXE side; tests +/// use it to derive the secret they expect an [`arbitrary_secret`](TaggingSecretStrategy::arbitrary_secret) strategy +/// to produce. +pub fn compute_directional_app_secret(secret: EmbeddedCurvePoint, app: AztecAddress, recipient: AztecAddress) -> Field { + poseidon2_hash( + [compute_app_siloed_shared_secret(secret, app), recipient.to_field()], + ) +} + impl Deserialize for TaggingSecretStrategy { let N: u32 = 3; @@ -81,14 +96,14 @@ mod test { #[test] fn strategy_roundtrips_through_serialization() { let non_interactive = TaggingSecretStrategy::non_interactive_handshake(); + let interactive = TaggingSecretStrategy::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(interactive.serialize()) == 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")] @@ -98,16 +113,22 @@ mod test { #[test(should_fail_with = "unrecognized tagging secret strategy kind")] fn deserializing_handshake_with_noninfinite_point_fails() { - let _ = TaggingSecretStrategy::deserialize([1, 7, 11]); + let _ = TaggingSecretStrategy::deserialize([ + TaggingSecretStrategy::non_interactive_handshake().kind as Field, + 7, + 11, + ]); } #[test(should_fail_with = "unrecognized tagging secret strategy kind")] fn deserializing_address_derived_with_noninfinite_point_fails() { - let _ = TaggingSecretStrategy::deserialize([3, 7, 11]); + let _ = TaggingSecretStrategy::deserialize( + [TaggingSecretStrategy::address_derived().kind as Field, 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]); + let _ = TaggingSecretStrategy::deserialize([TaggingSecretStrategy::interactive_handshake().kind as Field, 7, 11]); } } 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 e1c3f26e022c..7697a4509cba 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 @@ -13,6 +13,7 @@ use crate::{ event::{event_interface::EventInterface, EventMessage}, hash::{compute_secret_hash, hash_args}, messages::{ + delivery::OnchainDeliveryMode, discovery::{ ComputeNoteHash, ComputeNoteNullifier, CustomMessageHandler, process_message::process_message_plaintext, }, @@ -91,28 +92,53 @@ pub struct TestEnvironment { /// methods setting each value, e.g.: /// ```noir /// let env = TestEnvironment::new_opts( -/// TestEnvironmentOptions::new().with_tagging_secret_strategy(TaggingSecretStrategy::non_interactive_handshake()), +/// TestEnvironmentOptions::new().with_default_tag_secret_strategy( +/// MessageDelivery::onchain_unconstrained(), +/// TaggingSecretStrategy::non_interactive_handshake(), +/// ), /// ); /// ``` pub struct TestEnvironmentOptions { - tagging_secret_strategy: Option, + default_unconstrained_tagging_secret_strategy: Option, + default_constrained_tagging_secret_strategy: Option, } impl TestEnvironmentOptions { /// Creates a new `TestEnvironmentOptions` with default values. pub fn new() -> Self { - Self { tagging_secret_strategy: Option::none() } + Self { + default_unconstrained_tagging_secret_strategy: Option::none(), + default_constrained_tagging_secret_strategy: Option::none(), + } } - /// Sets the [`TaggingSecretStrategy`] the wallet reports through the + /// Sets the default [`TaggingSecretStrategy`] the wallet reports for `mode` through the /// [`resolve_tagging_strategy`](crate::oracle::resolve_tagging_strategy) oracle, affecting message delivery in - /// private executions. + /// private executions that do not fix their own delivery derivation. /// - /// If not set, the wallet hook is left unconfigured and the private execution environment applies its own default. - pub fn with_tagging_secret_strategy(&mut self, strategy: TaggingSecretStrategy) -> Self { - self.tagging_secret_strategy = Option::some(strategy); + /// If no strategy is set for either mode, the wallet hook is left unconfigured and the private execution + /// environment applies its own default. A mode left unset while the other is configured falls back to the default + /// [`TaggingSecretStrategy::non_interactive_handshake`], the strategy a PXE `resolveTaggingSecretStrategy` hook + /// typically hardcodes for modes it does not customize. + pub fn with_default_tag_secret_strategy(&mut self, mode: M, strategy: TaggingSecretStrategy) -> Self + where + M: Into, + { + if mode.into() == OnchainDeliveryMode::onchain_unconstrained() { + self.default_unconstrained_tagging_secret_strategy = Option::some(strategy); + } else { + self.default_constrained_tagging_secret_strategy = Option::some(strategy); + } *self } + + /// Sets `strategy` for both delivery modes: the equivalent of a PXE `resolveTaggingSecretStrategy` hook that + /// ignores the request's mode. See + /// [`with_default_tag_secret_strategy`](Self::with_default_tag_secret_strategy) to configure a single mode. + pub fn with_default_tag_secret_strategy_all_modes(&mut self, strategy: TaggingSecretStrategy) -> Self { + let _ = self.with_default_tag_secret_strategy(OnchainDeliveryMode::onchain_unconstrained(), strategy); + self.with_default_tag_secret_strategy(OnchainDeliveryMode::onchain_constrained(), strategy) + } } /// Configuration values for [`TestEnvironment::private_context_opts`]. Meant to be used by calling `new` and then @@ -648,18 +674,19 @@ impl TestEnvironment { /// /// ### Sample usage /// ```noir + /// let strategy = TaggingSecretStrategy::non_interactive_handshake(); /// let env = TestEnvironment::new_opts( - /// TestEnvironmentOptions::new().with_tagging_secret_strategy( - /// TaggingSecretStrategy::non_interactive_handshake(), - /// ), + /// TestEnvironmentOptions::new().with_default_tag_secret_strategy_all_modes(strategy), /// ); /// ``` pub unconstrained fn new_opts(options: TestEnvironmentOptions) -> Self { assert_compatible_oracle_version(); txe_oracles::assert_compatible_txe_oracle_version(); - // Forward the configured strategy to the wallet. - txe_oracles::set_tagging_secret_strategy(options.tagging_secret_strategy); + txe_oracles::set_tagging_secret_strategies( + options.default_unconstrained_tagging_secret_strategy, + options.default_constrained_tagging_secret_strategy, + ); Self { // Use an offset to avoid secret collision with account secrets. Without this, when 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 b02994d2e6ca..e2aed6337206 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 @@ -1,14 +1,56 @@ use crate::{ - keys::ecdh_shared_secret::compute_app_siloed_shared_secret, - messages::delivery::{OnchainDeliveryMode, ResolvedTaggingStrategy}, + messages::delivery::{MessageDelivery, OnchainDeliveryMode, ResolvedTaggingStrategy}, oracle::resolve_tagging_strategy::resolve_tagging_strategy, - protocol::{address::AztecAddress, hash::poseidon2_hash, point::EmbeddedCurvePoint, traits::{FromField, ToField}}, + protocol::{address::AztecAddress, point::EmbeddedCurvePoint, traits::FromField}, test::helpers::{ - tagging_secret_strategy::TaggingSecretStrategy, + tagging_secret_strategy::{compute_directional_app_secret, TaggingSecretStrategy}, test_environment::{TestEnvironment, TestEnvironmentOptions}, }, }; +#[test] +unconstrained fn an_unset_constrained_mode_falls_back_to_the_default_strategy() { + // Only the unconstrained mode is configured: the constrained mode falls back to the default non-interactive + // handshake strategy. + let mut env = TestEnvironment::new_opts(TestEnvironmentOptions::new().with_default_tag_secret_strategy( + MessageDelivery::onchain_unconstrained(), + TaggingSecretStrategy::arbitrary_secret(EmbeddedCurvePoint { x: 7, y: 11 }), + )); + let app = env.create_contract_account(); + let recipient = env.create_light_account(); + + env.private_context_at(app, |_| { + let resolved = resolve_tagging_strategy( + AztecAddress::from_field(1), + recipient, + OnchainDeliveryMode::onchain_constrained(), + ); + assert_eq(resolved, ResolvedTaggingStrategy::non_interactive_handshake()); + }); +} + +#[test] +unconstrained fn an_unset_unconstrained_mode_falls_back_to_the_default_strategy() { + // Only the constrained mode is configured: the unconstrained mode falls back to the default non-interactive + // handshake strategy for an external recipient. + let mut env = TestEnvironment::new_opts(TestEnvironmentOptions::new().with_default_tag_secret_strategy( + MessageDelivery::onchain_constrained(), + TaggingSecretStrategy::interactive_handshake(), + )); + let app = env.create_contract_account(); + let sender = env.create_light_account(); + let recipient = AztecAddress::from_field(8); + + env.private_context_at(app, |_| { + let resolved = resolve_tagging_strategy( + sender, + recipient, + OnchainDeliveryMode::onchain_unconstrained(), + ); + assert_eq(resolved, ResolvedTaggingStrategy::non_interactive_handshake()); + }); +} + #[test] unconstrained fn defaults_an_unconstrained_self_send_to_the_address_derived_shared_secret() { let mut env = TestEnvironment::new(); @@ -69,10 +111,9 @@ unconstrained fn defaults_constrained_delivery_to_a_non_interactive_handshake() #[test] unconstrained fn constrained_delivery_succeeds_with_a_configured_strategy() { - let mut env = TestEnvironment::new_opts(TestEnvironmentOptions::new().with_tagging_secret_strategy( + let mut env = TestEnvironment::new_opts(TestEnvironmentOptions::new().with_default_tag_secret_strategy_all_modes( TaggingSecretStrategy::non_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 = env.create_light_account(); @@ -88,12 +129,12 @@ 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( + let mut env = TestEnvironment::new_opts(TestEnvironmentOptions::new().with_default_tag_secret_strategy( + MessageDelivery::onchain_constrained(), 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); + let recipient = env.create_light_account(); env.private_context_at(app, |_| { let resolved = resolve_tagging_strategy( @@ -107,10 +148,10 @@ unconstrained fn applies_a_configured_interactive_handshake_strategy() { #[test] unconstrained fn applies_the_strategy_set_in_the_options() { - let mut env = TestEnvironment::new_opts(TestEnvironmentOptions::new().with_tagging_secret_strategy( + let mut env = TestEnvironment::new_opts(TestEnvironmentOptions::new().with_default_tag_secret_strategy( + MessageDelivery::onchain_unconstrained(), TaggingSecretStrategy::arbitrary_secret(EmbeddedCurvePoint { x: 7, y: 11 }), )); - // 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); @@ -126,33 +167,40 @@ unconstrained fn applies_the_strategy_set_in_the_options() { #[test] unconstrained fn app_silos_an_arbitrary_secret_point() { + // Both delivery modes resolve the configured arbitrary secret to the same app-siloed point. Mode-blindness + // is deliberate: PXE's tagging-strategy hook resolves whatever the wallet configured regardless of mode, and + // the TXE oracle must match that behavior. Constrained delivery rejects unsound secrets later, not here. let point = EmbeddedCurvePoint { x: 7, y: 11 }; - let mut env = TestEnvironment::new_opts(TestEnvironmentOptions::new().with_tagging_secret_strategy( + let mut env = TestEnvironment::new_opts(TestEnvironmentOptions::new().with_default_tag_secret_strategy_all_modes( TaggingSecretStrategy::arbitrary_secret(point), )); - // 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( + let expected_secret = compute_directional_app_secret(point, app, recipient); + + let resolved_unconstrained = resolve_tagging_strategy( AztecAddress::from_field(1), recipient, OnchainDeliveryMode::onchain_unconstrained(), ); + assert_eq(resolved_unconstrained, ResolvedTaggingStrategy::unconstrained_secret(expected_secret)); - // PXE app-silos the raw point before handing it over, then folds in the recipient for direction. This mirrors - // `AppTaggingSecret.computeDirectional` on the PXE side. - let app_secret = compute_app_siloed_shared_secret(point, app); - let expected_secret = poseidon2_hash([app_secret, recipient.to_field()]); - assert_eq(resolved, ResolvedTaggingStrategy::unconstrained_secret(expected_secret)); + let resolved_constrained = resolve_tagging_strategy( + AztecAddress::from_field(1), + recipient, + OnchainDeliveryMode::onchain_constrained(), + ); + assert_eq(resolved_constrained, ResolvedTaggingStrategy::unconstrained_secret(expected_secret)); }); } #[test] unconstrained fn overrides_a_configured_arbitrary_secret_for_an_unconstrained_self_send() { let point = EmbeddedCurvePoint { x: 7, y: 11 }; - let mut env = TestEnvironment::new_opts(TestEnvironmentOptions::new().with_tagging_secret_strategy( + let mut env = TestEnvironment::new_opts(TestEnvironmentOptions::new().with_default_tag_secret_strategy( + MessageDelivery::onchain_unconstrained(), TaggingSecretStrategy::arbitrary_secret(point), )); let app = env.create_contract_account(); @@ -168,8 +216,7 @@ unconstrained fn overrides_a_configured_arbitrary_secret_for_an_unconstrained_se // A self-send forces an address-derived secret even with an arbitrary secret configured. The resolved // secret is unconstrained, but not the app-siloed arbitrary point the configured secret would have produced. - let app_secret = compute_app_siloed_shared_secret(point, app); - let arbitrary_secret = poseidon2_hash([app_secret, recipient.to_field()]); + let arbitrary_secret = compute_directional_app_secret(point, app, recipient); assert(resolved.is_unconstrained_secret()); assert(resolved != ResolvedTaggingStrategy::unconstrained_secret(arbitrary_secret)); }); 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 fbf3e519e450..f8dcf4dd6157 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 @@ -21,7 +21,7 @@ use crate::protocol::{ /// [`oracle::version`](crate::oracle::version), which covers oracles used during contract execution by PXE. /// /// The TypeScript counterparts are in `yarn-project/txe/src/txe_oracle_version.ts`. -pub global TXE_ORACLE_VERSION_MAJOR: Field = 3; +pub global TXE_ORACLE_VERSION_MAJOR: Field = 4; pub global TXE_ORACLE_VERSION_MINOR: Field = 0; /// Asserts that the TXE oracle interface version is compatible. @@ -265,8 +265,11 @@ pub unconstrained fn send_l1_to_l2_message( recipient: AztecAddress, ) -> Field {} -#[oracle(aztec_txe_setTaggingSecretStrategy)] -pub unconstrained fn set_tagging_secret_strategy(strategy: Option) {} +#[oracle(aztec_txe_setTaggingSecretStrategies)] +pub unconstrained fn set_tagging_secret_strategies( + unconstrained_strategy: Option, + constrained_strategy: Option, +) {} #[oracle(aztec_txe_privateCallNewFlow)] unconstrained fn private_call_new_flow_oracle( diff --git a/noir-projects/noir-contracts/contracts/test/onchain_delivery_test_contract/src/main.nr b/noir-projects/noir-contracts/contracts/test/onchain_delivery_test_contract/src/main.nr index 1fa646cdc699..1377f7466025 100644 --- a/noir-projects/noir-contracts/contracts/test/onchain_delivery_test_contract/src/main.nr +++ b/noir-projects/noir-contracts/contracts/test/onchain_delivery_test_contract/src/main.nr @@ -1,4 +1,4 @@ -//! Thin wrappers around onchain message delivery for tests. +//! Thin wrappers around onchain message delivery for TXE tests. use aztec::macros::aztec; mod test; @@ -7,7 +7,7 @@ mod test; pub contract OnchainDeliveryTest { use aztec::{ macros::{events::event, functions::external, storage::storage}, - messages::delivery::MessageDelivery, + messages::delivery::{MessageDelivery, OnchainDeliveryMode}, note::note_viewer_options::NoteViewerOptions, oracle::notes::get_next_tagging_index, protocol::{ @@ -34,10 +34,10 @@ pub contract OnchainDeliveryTest { } #[external("private")] - fn next_index_for_secret(secret: Field) -> u32 { + fn next_index_for_secret(secret: Field, mode: OnchainDeliveryMode) -> u32 { // Safety: test-only observation of the index the PXE hands out next for this secret. unsafe { - get_next_tagging_index(secret, MessageDelivery::onchain_constrained()) + get_next_tagging_index(secret, mode) } } diff --git a/noir-projects/noir-contracts/contracts/test/onchain_delivery_test_contract/src/test.nr b/noir-projects/noir-contracts/contracts/test/onchain_delivery_test_contract/src/test.nr index 32b62ead583d..8a0fa818b7c5 100644 --- a/noir-projects/noir-contracts/contracts/test/onchain_delivery_test_contract/src/test.nr +++ b/noir-projects/noir-contracts/contracts/test/onchain_delivery_test_contract/src/test.nr @@ -1,20 +1,33 @@ -//! Tests for constrained delivery through the public message-delivery API. +//! Tests for onchain message delivery through the public message-delivery API. //! -//! These tests exercise the sender-side helper flow indirectly via constrained note/event delivery: resolve or +//! The constrained tests exercise the sender-side helper flow indirectly via note/event delivery: resolve or //! 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. +//! sequence nullifier so the next real delivery must reject the broken sequence. The unconstrained tests cover +//! delivery in the mode without sequence nullifiers, alongside and independent of the constrained flow. use crate::OnchainDeliveryTest; use aztec::{ - protocol::address::AztecAddress, + messages::delivery::MessageDelivery, + protocol::{address::AztecAddress, point::EmbeddedCurvePoint, traits::FromField}, standard_addresses::STANDARD_HANDSHAKE_REGISTRY_ADDRESS, - test::helpers::test_environment::{CallPrivateOptions, DeployOptions, ExecuteUtilityOptions, TestEnvironment}, + test::helpers::{ + tagging_secret_strategy::{compute_directional_app_secret, TaggingSecretStrategy}, + test_environment::{ + CallPrivateOptions, DeployOptions, ExecuteUtilityOptions, TestEnvironment, TestEnvironmentOptions, + }, + }, }; use handshake_registry_contract::HandshakeRegistry; unconstrained fn setup() -> (TestEnvironment, AztecAddress, AztecAddress, AztecAddress, AztecAddress) { - let mut env = TestEnvironment::new(); + setup_opts(TestEnvironmentOptions::new()) +} + +unconstrained fn setup_opts( + options: TestEnvironmentOptions, +) -> (TestEnvironment, AztecAddress, AztecAddress, AztecAddress, AztecAddress) { + let mut env = TestEnvironment::new_opts(options); let sender = env.create_light_account(); let recipient = env.create_light_account(); @@ -35,6 +48,62 @@ unconstrained fn authorizing(registry_address: AztecAddress) -> CallPrivateOptio CallPrivateOptions::new().with_authorized_utility_call_targets([registry_address]) } +unconstrained fn external_recipient() -> AztecAddress { + AztecAddress::from_field(8) +} + +#[test] +unconstrained fn unconstrained_delivery_works_alongside_constrained_delivery() { + let point = EmbeddedCurvePoint { + x: 0x15d55a5b3b2caa6a6207f313f05c5113deba5da9927d6421bcaa164822b911bc, + y: 0x0974c3d0825031ae933243d653ebb1a0b08b90ee7f228f94c5c74739ea3c871e, + }; + // Only the unconstrained mode is configured: the constrained mode falls back to the default non-interactive + // handshake strategy, so the constrained delivery below still bootstraps a registry handshake. + let options = TestEnvironmentOptions::new().with_default_tag_secret_strategy( + MessageDelivery::onchain_unconstrained(), + TaggingSecretStrategy::arbitrary_secret(point), + ); + let (env, registry_address, test_address, sender, _) = setup_opts(options); + let recipient = external_recipient(); + let test_contract = OnchainDeliveryTest::at(test_address); + let registry = HandshakeRegistry::at(registry_address); + + env.call_private(sender, test_contract.emit_event_unconstrained(recipient, 1)); + + let expected_unconstrained_secret = compute_directional_app_secret(point, test_address, recipient); + let next_unconstrained_index = env.call_private( + sender, + test_contract.next_index_for_secret( + expected_unconstrained_secret, + MessageDelivery::onchain_unconstrained().into(), + ), + ); + assert_eq(next_unconstrained_index, 1); + + let maybe_handshake = env.execute_utility_opts( + ExecuteUtilityOptions::new().with_from(test_address), + registry.get_app_siloed_secrets(sender, recipient), + ); + assert(maybe_handshake.is_none()); + + env.call_private_opts(sender, authorizing(registry_address), test_contract.emit_event(recipient, 1)); + + let handshake = env + .execute_utility_opts( + ExecuteUtilityOptions::new().with_from(test_address), + registry.get_app_siloed_secrets(sender, recipient), + ) + .expect(f"constrained delivery should have stored a handshake"); + assert(handshake.shared != expected_unconstrained_secret); + + let next_constrained_index = env.call_private( + sender, + test_contract.next_index_for_secret(handshake.shared, MessageDelivery::onchain_constrained().into()), + ); + assert_eq(next_constrained_index, 1); +} + #[test] unconstrained fn rehandshake_replaces_registry_secret_for_future_delivery() { let (env, registry_address, test_address, sender, recipient) = setup(); @@ -62,7 +131,10 @@ unconstrained fn rehandshake_replaces_registry_secret_for_future_delivery() { env.call_private_opts(sender, authorizing(registry_address), test_contract.emit_event(recipient, 1)); - let next_index = env.call_private(sender, test_contract.next_index_for_secret(second_secret)); + let next_index = env.call_private( + sender, + test_contract.next_index_for_secret(second_secret, MessageDelivery::onchain_constrained().into()), + ); assert_eq(next_index, 1); } @@ -104,7 +176,10 @@ unconstrained fn interactive_handshake_reuses_an_existing_registry_handshake() { test_contract.emit_event_via_interactive_handshake(recipient, 1), ); - let next_index = env.call_private(sender, test_contract.next_index_for_secret(secret)); + let next_index = env.call_private( + sender, + test_contract.next_index_for_secret(secret, MessageDelivery::onchain_constrained().into()), + ); assert_eq(next_index, 1); } diff --git a/yarn-project/end-to-end/src/automine/delivery/constrained.test.ts b/yarn-project/end-to-end/src/automine/delivery/constrained.test.ts index c0ee53301c98..2389638bbfc5 100644 --- a/yarn-project/end-to-end/src/automine/delivery/constrained.test.ts +++ b/yarn-project/end-to-end/src/automine/delivery/constrained.test.ts @@ -17,6 +17,9 @@ import { AUTOMINE_E2E_OPTS } from '../../fixtures/fixtures.js'; import { ensureHandshakeRegistryPublished, setup } from '../../fixtures/setup.js'; import type { TestWallet } from '../../test-wallet/test_wallet.js'; +// Keep in sync with aztec::messages::delivery::OnchainDeliveryMode. +const ONCHAIN_CONSTRAINED_DELIVERY_MODE = { inner: 3 }; + // Delivery-method-specific tests that don't fit the generic (strategy, mode) matrix in `onchain.test.ts` describe('delivery/constrained', () => { jest.setTimeout(300_000); @@ -62,7 +65,9 @@ describe('delivery/constrained', () => { // The second send reuses the handshake rather than bootstrapping a new one: the secret is unchanged. expect(secret).toEqual(secretAfterFirstSend); - const { result: index } = await contract.methods.next_index_for_secret(secret).simulate({ from: sender }); + const { result: index } = await contract.methods + .next_index_for_secret(secret, ONCHAIN_CONSTRAINED_DELIVERY_MODE) + .simulate({ from: sender }); expect(index).toEqual(2n); }); @@ -98,7 +103,9 @@ describe('delivery/constrained', () => { .simulate({ from: sender }); expect(secret).toBeDefined(); - const { result: index } = await contract.methods.next_index_for_secret(secret).simulate({ from: sender }); + const { result: index } = await contract.methods + .next_index_for_secret(secret, ONCHAIN_CONSTRAINED_DELIVERY_MODE) + .simulate({ from: sender }); expect(index).toEqual(2n); }); @@ -117,7 +124,9 @@ describe('delivery/constrained', () => { .simulate({ from: sender }); expect(secret).toBeDefined(); - const { result: index } = await contract.methods.next_index_for_secret(secret).simulate({ from: sender }); + const { result: index } = await contract.methods + .next_index_for_secret(secret, ONCHAIN_CONSTRAINED_DELIVERY_MODE) + .simulate({ from: sender }); expect(index).toEqual(2n); }); @@ -134,7 +143,9 @@ describe('delivery/constrained', () => { .simulate({ from: sender }); expect(secret).toBeDefined(); - const { result: index } = await contract.methods.next_index_for_secret(secret).simulate({ from: sender }); + const { result: index } = await contract.methods + .next_index_for_secret(secret, ONCHAIN_CONSTRAINED_DELIVERY_MODE) + .simulate({ from: sender }); expect(index).toEqual(1n); }); @@ -152,7 +163,9 @@ describe('delivery/constrained', () => { .simulate({ from: sender }); expect(secret).toBeDefined(); - const { result: index } = await contract.methods.next_index_for_secret(secret).simulate({ from: sender }); + const { result: index } = await contract.methods + .next_index_for_secret(secret, ONCHAIN_CONSTRAINED_DELIVERY_MODE) + .simulate({ from: sender }); expect(index).toEqual(1n); }); }); 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 8b1930281d3e..578ec805c6dd 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 @@ -47,8 +47,10 @@ export function resolvedTaggingStrategyToFields(resolved: ResolvedTaggingStrateg export function resolvedTaggingStrategyFromFields(kind: number, secret: Fr): ResolvedTaggingStrategy { switch (kind) { case NON_INTERACTIVE_HANDSHAKE: + assertAbsentSecret(kind, secret); return { type: 'non-interactive-handshake' }; case INTERACTIVE_HANDSHAKE: + assertAbsentSecret(kind, secret); return { type: 'interactive-handshake' }; case UNCONSTRAINED_SECRET: return { type: 'unconstrained-secret', secret }; @@ -56,3 +58,9 @@ export function resolvedTaggingStrategyFromFields(kind: number, secret: Fr): Res throw new Error(`Unrecognized resolved tagging strategy kind: ${kind}`); } } + +function assertAbsentSecret(kind: number, secret: Fr): void { + if (!secret.isZero()) { + throw new Error(`Resolved tagging strategy ${kind} must not include a secret`); + } +} diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_type_mappings.test.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_type_mappings.test.ts index ee6405fd1b3f..23ca2fbc3108 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_type_mappings.test.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_type_mappings.test.ts @@ -8,6 +8,7 @@ import { EphemeralArrayService } from '../ephemeral_array_service.js'; import { BoundedVec } from '../noir-structs/bounded_vec.js'; import { type LogRetrievalRequest, LogSource } from '../noir-structs/log_retrieval_request.js'; import { Option } from '../noir-structs/option.js'; +import type { ResolvedTaggingStrategy } from '../noir-structs/resolved_tagging_strategy.js'; import { ARRAY, AZTEC_ADDRESS, @@ -21,6 +22,7 @@ import { OPTION, POINT, PROVIDED_SECRET, + RESOLVED_TAGGING_STRATEGY, type SlotShape, type TypeMapping, U32, @@ -107,6 +109,25 @@ describe('oracle type mappings', () => { }); }); + describe('RESOLVED_TAGGING_STRATEGY', () => { + const secret = new Fr(42); + + it('rejects an unknown strategy kind', () => { + expect(() => deserializeStrategy(new Fr(99), Fr.ZERO)).toThrow('Unrecognized resolved tagging strategy kind'); + }); + + it.each([ + ['non-interactive handshake', new Fr(1)], + ['interactive handshake', new Fr(3)], + ])('rejects %s with a nonzero secret', (_name, kind) => { + expect(() => deserializeStrategy(kind, secret)).toThrow(`Resolved tagging strategy ${kind.toNumber()}`); + }); + + function deserializeStrategy(kind: Fr, secret: Fr): ResolvedTaggingStrategy { + return RESOLVED_TAGGING_STRATEGY.deserialization!.fn([new FieldReader([kind]), new FieldReader([secret])]); + } + }); + describe('AZTEC_ADDRESS', () => { it('serializes to its declared shape', async () => { expect(shapeOf(AZTEC_ADDRESS.serialization!.fn(await AztecAddress.random()))).toEqual(AZTEC_ADDRESS.shape); 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 89dd9da44dee..43642589f3f9 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 @@ -30,9 +30,10 @@ import { } from '@aztec/stdlib/tx'; import type { ResolveCustomRequest } from '../../hooks/resolve_custom_request.js'; -import type { - ResolveTaggingSecretStrategy, - TaggingSecretStrategy, +import { + DEFAULT_TAGGING_SECRET_STRATEGY, + type ResolveTaggingSecretStrategy, + type TaggingSecretStrategy, } from '../../hooks/resolve_tagging_secret_strategy.js'; import { NoteService } from '../../notes/note_service.js'; import { assertAllowedScope } from '../../storage/allowed_scopes.js'; @@ -238,8 +239,7 @@ export class PrivateExecutionOracle extends UtilityExecutionOracle implements IP ): Promise { const hook: ResolveTaggingSecretStrategy | undefined = this.hooks?.resolveTaggingSecretStrategy; if (!hook) { - // With no hook, both delivery modes default to a non-interactive handshake - return { type: 'non-interactive-handshake' }; + return DEFAULT_TAGGING_SECRET_STRATEGY; } const contractClassId = await this.#getCurrentContractClassId(this.contractAddress); diff --git a/yarn-project/pxe/src/hooks/index.ts b/yarn-project/pxe/src/hooks/index.ts index 360cae3e904f..029b85c5e000 100644 --- a/yarn-project/pxe/src/hooks/index.ts +++ b/yarn-project/pxe/src/hooks/index.ts @@ -6,6 +6,7 @@ export type { export { type ExecutionHooks, composeHooks } from './execution_hooks.js'; export { type CustomRequest, type ResolveCustomRequest } from './resolve_custom_request.js'; export { + DEFAULT_TAGGING_SECRET_STRATEGY, type ResolveTaggingSecretStrategy, type TaggingSecretStrategy, type TaggingSecretStrategyRequest, 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 6ca37cc05e68..fdfd3e8e0f25 100644 --- a/yarn-project/pxe/src/hooks/resolve_tagging_secret_strategy.ts +++ b/yarn-project/pxe/src/hooks/resolve_tagging_secret_strategy.ts @@ -39,6 +39,9 @@ export type TaggingSecretStrategy = secret: Point; }; +/** The strategy PXE applies to both delivery modes when no `resolveTaggingSecretStrategy` hook is configured. */ +export const DEFAULT_TAGGING_SECRET_STRATEGY: TaggingSecretStrategy = { type: 'non-interactive-handshake' }; + /** Information about the message delivery requesting a tagging secret strategy. */ export type TaggingSecretStrategyRequest = { contractAddress: AztecAddress; diff --git a/yarn-project/txe/src/oracle/interfaces.ts b/yarn-project/txe/src/oracle/interfaces.ts index 1f48c9a3753b..baea1a37c7f4 100644 --- a/yarn-project/txe/src/oracle/interfaces.ts +++ b/yarn-project/txe/src/oracle/interfaces.ts @@ -73,7 +73,14 @@ export interface ITxeExecutionOracle { addAccount(secret: Fr): Promise; addAuthWitness(address: AztecAddress, messageHash: Fr): Promise; sendL1ToL2Message(content: Fr, secretHash: Fr, sender: EthAddress, recipient: AztecAddress): Promise; - setTaggingSecretStrategy(strategy: Option): void; + /** + * Configures the tagging secret strategy the test's simulated wallet resolves for each delivery mode. A `none` + * clears that mode, so it falls back to the default strategy (or, when both modes end up unset, to no hook at all). + */ + setTaggingSecretStrategies( + unconstrainedStrategy: Option, + constrainedStrategy: Option, + ): void; getLastBlockTimestamp(): Promise; getLastTxEffects(): Promise<{ txHash: TxHash; diff --git a/yarn-project/txe/src/oracle/tagging_secret_strategy.test.ts b/yarn-project/txe/src/oracle/tagging_secret_strategy.test.ts new file mode 100644 index 000000000000..ef2f03e2b97a --- /dev/null +++ b/yarn-project/txe/src/oracle/tagging_secret_strategy.test.ts @@ -0,0 +1,47 @@ +import { Fr } from '@aztec/foundation/curves/bn254'; +import { Point } from '@aztec/foundation/curves/grumpkin'; +import { DEFAULT_TAGGING_SECRET_STRATEGY, type TaggingSecretStrategy } from '@aztec/pxe/server'; +import { AztecAddress } from '@aztec/stdlib/aztec-address'; +import { AppTaggingSecretKind } from '@aztec/stdlib/logs'; + +import { makeResolveTaggingSecretStrategyHook } from './tagging_secret_strategy.js'; + +describe('makeResolveTaggingSecretStrategyHook', () => { + it('returns undefined when no TXE strategy is configured', () => { + expect(makeResolveTaggingSecretStrategyHook(new Map())).toBeUndefined(); + }); + + it('selects a strategy by delivery mode', async () => { + const unconstrained: TaggingSecretStrategy = { type: 'arbitrary-secret', secret: await Point.random() }; + const constrained: TaggingSecretStrategy = { type: 'interactive-handshake' }; + const hook = makeResolveTaggingSecretStrategyHook( + new Map([ + [AppTaggingSecretKind.UNCONSTRAINED, unconstrained], + [AppTaggingSecretKind.CONSTRAINED, constrained], + ]), + ); + + await expect(hook?.(makeRequest(AppTaggingSecretKind.UNCONSTRAINED))).resolves.toBe(unconstrained); + await expect(hook?.(makeRequest(AppTaggingSecretKind.CONSTRAINED))).resolves.toBe(constrained); + }); + + it('defaults an unset mode to PXE default strategy when another mode is configured', async () => { + const unconstrained = { type: 'address-derived' as const }; + const hook = makeResolveTaggingSecretStrategyHook(new Map([[AppTaggingSecretKind.UNCONSTRAINED, unconstrained]])); + + await expect(hook?.(makeRequest(AppTaggingSecretKind.UNCONSTRAINED))).resolves.toBe(unconstrained); + await expect(hook?.(makeRequest(AppTaggingSecretKind.CONSTRAINED))).resolves.toEqual( + DEFAULT_TAGGING_SECRET_STRATEGY, + ); + }); +}); + +function makeRequest(deliveryMode: AppTaggingSecretKind) { + return { + contractAddress: AztecAddress.ZERO, + contractClassId: Fr.ZERO, + sender: AztecAddress.ZERO, + recipient: AztecAddress.ZERO, + deliveryMode, + }; +} diff --git a/yarn-project/txe/src/oracle/tagging_secret_strategy.ts b/yarn-project/txe/src/oracle/tagging_secret_strategy.ts new file mode 100644 index 000000000000..089ae0d78ff8 --- /dev/null +++ b/yarn-project/txe/src/oracle/tagging_secret_strategy.ts @@ -0,0 +1,25 @@ +import { + DEFAULT_TAGGING_SECRET_STRATEGY, + type ResolveTaggingSecretStrategy, + type TaggingSecretStrategy, +} from '@aztec/pxe/server'; +import type { AppTaggingSecretKind } from '@aztec/stdlib/logs'; + +/** The tagging secret strategies a TXE test has configured, keyed by delivery mode. Absence means "not configured". */ +export type TXETaggingSecretStrategies = Map; + +/** + * Builds the `resolveTaggingSecretStrategy` hook backing the `aztec_txe_setTaggingSecretStrategies` oracle. Returns + * `undefined` when no strategy is configured, so PXE's own no-hook default path is exercised. When at least one mode + * is configured, modes without an entry resolve to {@link DEFAULT_TAGGING_SECRET_STRATEGY}, matching what PXE would + * apply without a hook. + */ +export function makeResolveTaggingSecretStrategyHook( + strategies: TXETaggingSecretStrategies, +): ResolveTaggingSecretStrategy | undefined { + if (strategies.size === 0) { + return undefined; + } + + return ({ deliveryMode }) => Promise.resolve(strategies.get(deliveryMode) ?? DEFAULT_TAGGING_SECRET_STRATEGY); +} diff --git a/yarn-project/txe/src/oracle/txe_oracle_registry.ts b/yarn-project/txe/src/oracle/txe_oracle_registry.ts index f3694549fc77..77af1da161d5 100644 --- a/yarn-project/txe/src/oracle/txe_oracle_registry.ts +++ b/yarn-project/txe/src/oracle/txe_oracle_registry.ts @@ -67,7 +67,7 @@ const GAS_SETTINGS: TypeMapping = { }; // Tagging secret strategy discriminants. Must match the Noir test helper `TaggingSecretStrategy` in -// aztec-nr `test/helpers/tagging_secret_strategy.nr`. This is a test-only oracle (only `set_tagging_secret_strategy` +// aztec-nr `test/helpers/tagging_secret_strategy.nr`. This is a test-only oracle (only `set_tagging_secret_strategies` // reads it), so the mapping lives here on the TXE side rather than in the production oracle type mappings. const STRATEGY_NON_INTERACTIVE_HANDSHAKE = 1; const STRATEGY_ARBITRARY_SECRET = 2; @@ -323,8 +323,11 @@ export const TXE_ORACLE_REGISTRY = { returnType: FIELD, }), - aztec_txe_setTaggingSecretStrategy: makeEntry({ - params: [{ name: 'strategy', type: OPTION(TAGGING_SECRET_STRATEGY) }], + aztec_txe_setTaggingSecretStrategies: makeEntry({ + params: [ + { name: 'unconstrainedStrategy', type: OPTION(TAGGING_SECRET_STRATEGY) }, + { name: 'constrainedStrategy', type: OPTION(TAGGING_SECRET_STRATEGY) }, + ], }), aztec_txe_getLastBlockTimestamp: makeEntry({ diff --git a/yarn-project/txe/src/oracle/txe_oracle_top_level_context.ts b/yarn-project/txe/src/oracle/txe_oracle_top_level_context.ts index 47494e939aab..3e3b3a7f6e26 100644 --- a/yarn-project/txe/src/oracle/txe_oracle_top_level_context.ts +++ b/yarn-project/txe/src/oracle/txe_oracle_top_level_context.ts @@ -73,6 +73,7 @@ import { } from '@aztec/stdlib/kernel'; import { deriveKeys, hashPublicKey } from '@aztec/stdlib/keys'; import type { PrivateLog } from '@aztec/stdlib/logs'; +import { AppTaggingSecretKind } from '@aztec/stdlib/logs'; import { L1Actor, L1ToL2Message, L2Actor } from '@aztec/stdlib/messaging'; import { ChonkProof } from '@aztec/stdlib/proofs'; import { makeGlobalVariables } from '@aztec/stdlib/testing'; @@ -99,6 +100,7 @@ import type { TXEAccountStore } from '../utils/txe_account_store.js'; import type { TXEArtifactResolver } from '../utils/txe_artifact_resolver.js'; import { TXEPublicContractDataSource } from '../utils/txe_public_contract_data_source.js'; import type { ITxeExecutionOracle } from './interfaces.js'; +import { type TXETaggingSecretStrategies, makeResolveTaggingSecretStrategyHook } from './tagging_secret_strategy.js'; export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracle { isMisc = true as const; @@ -123,7 +125,7 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl private version: Fr, private chainId: Fr, private authwits: Map, - private taggingSecretStrategy: TaggingSecretStrategy | undefined, + private taggingSecretStrategies: TXETaggingSecretStrategies, private readonly artifactResolver: TXEArtifactResolver, private readonly rootPath: string, private readonly packageName: string, @@ -355,8 +357,19 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl this.authwits.set(authWitness.requestHash.toString(), authWitness); } - setTaggingSecretStrategy(strategy: Option): void { - this.taggingSecretStrategy = strategy.value; + setTaggingSecretStrategies( + unconstrainedStrategy: Option, + constrainedStrategy: Option, + ): void { + const apply = (mode: AppTaggingSecretKind, strategy: Option) => { + if (strategy.isSome()) { + this.taggingSecretStrategies.set(mode, strategy.value); + } else { + this.taggingSecretStrategies.delete(mode); + } + }; + apply(AppTaggingSecretKind.UNCONSTRAINED, unconstrainedStrategy); + apply(AppTaggingSecretKind.CONSTRAINED, constrainedStrategy); } async sendL1ToL2Message(content: Fr, secretHash: Fr, sender: EthAddress, recipient: AztecAddress): Promise { @@ -470,7 +483,6 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl const simulator = new WASMSimulator(); const transientArrayService = new TransientArrayService(); - const taggingSecretStrategy = this.taggingSecretStrategy; const privateExecutionOracle = new PrivateExecutionOracle({ argsHash, txContext, @@ -510,9 +522,7 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl isStaticCall ? 'private view' : 'private', authorizedUtilityCallTargets, ), - // Only configure the hook when a strategy was explicitly set, so that otherwise the default tagging secret - // strategy is exercised. - resolveTaggingSecretStrategy: taggingSecretStrategy ? () => Promise.resolve(taggingSecretStrategy) : undefined, + resolveTaggingSecretStrategy: makeResolveTaggingSecretStrategyHook(this.taggingSecretStrategies), }), transientArrayService, }); @@ -972,9 +982,9 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl } } - close(): [bigint, Map, TaggingSecretStrategy | undefined] { + close(): [bigint, Map, TXETaggingSecretStrategies] { this.logger.debug('Exiting Top Level Context'); - return [this.nextBlockTimestamp, this.authwits, this.taggingSecretStrategy]; + return [this.nextBlockTimestamp, this.authwits, this.taggingSecretStrategies]; } private async getLastBlockNumber(): Promise { diff --git a/yarn-project/txe/src/oracle/txe_oracle_version.ts b/yarn-project/txe/src/oracle/txe_oracle_version.ts index 5768821d902e..ed285f23552c 100644 --- a/yarn-project/txe/src/oracle/txe_oracle_version.ts +++ b/yarn-project/txe/src/oracle/txe_oracle_version.ts @@ -5,7 +5,7 @@ * * The Noir counterparts are in `noir-projects/aztec-nr/aztec/src/test/helpers/txe_oracles.nr`. */ -export const TXE_ORACLE_VERSION_MAJOR = 3; +export const TXE_ORACLE_VERSION_MAJOR = 4; export const TXE_ORACLE_VERSION_MINOR = 0; /** @@ -14,4 +14,4 @@ export const TXE_ORACLE_VERSION_MINOR = 0; * - TXE_ORACLE_VERSION_MAJOR (and reset MINOR to 0) for breaking changes, or * - TXE_ORACLE_VERSION_MINOR for additive changes (new oracle method added). */ -export const TXE_ORACLE_INTERFACE_HASH = '50bd2ce5971fb29adb6484318aa5ce8bb1a0c9cde10b554cd544c513f814c9ca'; +export const TXE_ORACLE_INTERFACE_HASH = 'e7a3a641f1ebe5396f9c0756109cbd48b170b50a492b6de74a6c453e014e096e'; diff --git a/yarn-project/txe/src/rpc_translator.ts b/yarn-project/txe/src/rpc_translator.ts index 22712c254e16..8abae5a7b681 100644 --- a/yarn-project/txe/src/rpc_translator.ts +++ b/yarn-project/txe/src/rpc_translator.ts @@ -221,11 +221,12 @@ export class RPCTranslator { } // eslint-disable-next-line camelcase - aztec_txe_setTaggingSecretStrategy(...inputs: ForeignCallArgs) { + aztec_txe_setTaggingSecretStrategies(...inputs: ForeignCallArgs) { return callTxeHandler({ - oracle: 'aztec_txe_setTaggingSecretStrategy', + oracle: 'aztec_txe_setTaggingSecretStrategies', inputs, - handler: ([strategy]) => this.handlerAsTxe().setTaggingSecretStrategy(strategy), + handler: ([unconstrainedStrategy, constrainedStrategy]) => + this.handlerAsTxe().setTaggingSecretStrategies(unconstrainedStrategy, constrainedStrategy), }); } diff --git a/yarn-project/txe/src/txe_session.ts b/yarn-project/txe/src/txe_session.ts index 77f2dcb045ed..6b6345023be6 100644 --- a/yarn-project/txe/src/txe_session.ts +++ b/yarn-project/txe/src/txe_session.ts @@ -20,7 +20,6 @@ import { RecipientTaggingStore, SenderTaggingStore, TaggingSecretSourcesStore, - type TaggingSecretStrategy, composeHooks, } from '@aztec/pxe/server'; import { @@ -58,6 +57,10 @@ import { z } from 'zod'; import { DEFAULT_ADDRESS, MAX_OFFCHAIN_EFFECTS_PER_TXE_QUERY, MAX_OFFCHAIN_EFFECT_LEN } from './constants.js'; import type { IAvmExecutionOracle, ITxeExecutionOracle } from './oracle/interfaces.js'; +import { + type TXETaggingSecretStrategies, + makeResolveTaggingSecretStrategyHook, +} from './oracle/tagging_secret_strategy.js'; import { TXEOraclePublicContext } from './oracle/txe_oracle_public_context.js'; import { callTxeLegacyHandler } from './oracle/txe_oracle_registry.js'; import { TXEOracleTopLevelContext } from './oracle/txe_oracle_top_level_context.js'; @@ -237,7 +240,7 @@ function emptyLastCallState(): LastCallState { export class TXESession implements TXESessionStateHandler { private state: SessionState = { name: 'TOP_LEVEL' }; private authwits: Map = new Map(); - private taggingSecretStrategy: TaggingSecretStrategy | undefined = undefined; + private taggingSecretStrategies: TXETaggingSecretStrategies = new Map(); private lastCallInfo: LastCallState = emptyLastCallState(); private txeOracleVersion: { major: number; minor: number } | undefined; @@ -365,7 +368,7 @@ export class TXESession implements TXESessionStateHandler { version, chainId, new Map(), - undefined, + new Map(), artifactResolver, rootPath, packageName, @@ -695,7 +698,7 @@ export class TXESession implements TXESessionStateHandler { this.version, this.chainId, this.authwits, - this.taggingSecretStrategy, + this.taggingSecretStrategies, this.artifactResolver, this.rootPath, this.packageName, @@ -745,7 +748,6 @@ export class TXESession implements TXESessionStateHandler { this.stateMachine.contractClassService, anchorBlock!, ); - const taggingSecretStrategy = this.taggingSecretStrategy; this.oracleHandler = new TXEPrivateExecutionOracle({ argsHash: Fr.ZERO, txContext: new TxContext(this.chainId, this.version, gasSettings), @@ -776,7 +778,7 @@ export class TXESession implements TXESessionStateHandler { txResolver: this.stateMachine.txResolver, simulator: new WASMSimulator(), hooks: composeHooks({ - resolveTaggingSecretStrategy: taggingSecretStrategy ? () => Promise.resolve(taggingSecretStrategy) : undefined, + resolveTaggingSecretStrategy: makeResolveTaggingSecretStrategyHook(this.taggingSecretStrategies), }), transientArrayService, }); @@ -899,13 +901,13 @@ export class TXESession implements TXESessionStateHandler { // accounts to PXE (via `addAccount`), etc. This is a slight inconsistency in the working model of this class, but // is not too bad. The `close` call below therefore only hands back the session-scoped values that a test // sets directly at the top level, outside any contract execution (e.g. via `advanceTimestampBy`, - // `addAuthWitness`, `setTaggingSecretStrategy`). The oracle handler is discarded on every state transition, + // `addAuthWitness`, `setTaggingSecretStrategies`). The oracle handler is discarded on every state transition, // so the session must seed these values into the contexts it creates later. // TODO: persisting authwits this way is quite unfortunate: they create a temporary utility context that would // otherwise reset them, so we'd not be able to pass more than one per execution. Ideally authwits would be passed // alongside a contract call instead of pre-seeded. - [this.nextBlockTimestamp, this.authwits, this.taggingSecretStrategy] = ( + [this.nextBlockTimestamp, this.authwits, this.taggingSecretStrategies] = ( this.oracleHandler as TXEOracleTopLevelContext ).close(); } From e4dcdd3c56e866596eb8fa81e3f41c4a38688a5c Mon Sep 17 00:00:00 2001 From: Maxim Vezenov Date: Thu, 9 Jul 2026 10:02:13 -0400 Subject: [PATCH 09/13] feat(pxe)!: Add AppTaggingSecret kinds to keys in tagging stores (#24604) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes [F-680](https://linear.app/aztec-labs/issue/F-680/migrate-pxe-tagging-stores-to-prefixed-apptaggingsecret-keys) Migrates PXE tagging-store keys off the legacy two-part `AppTaggingSecret` string format (`secret:app`) so every persisted key uses the uniform self-describing `kind:secret:app` form. - `AppTaggingSecret.toString()` now always emits `kind:secret:app` (the unconstrained special case that emitted the legacy two-part key is gone). - `AppTaggingSecret.fromString()` only accepts the three-part form; a two-part legacy key now throws instead of parsing. - Bumped `PXE_DATA_SCHEMA_VERSION` 12 → 13. Since the general PXE migration framework does not exist, and per the issue discussion, this cuts over via the schema-version bump rather than a dual-read / self-healing path. `initStoreForRollupAndSchemaVersion` wipes any DB whose stored schema version differs from the current one on open, so a DB written with legacy keys is cleared before the new parser ever reads it. **Blast radius:** the wipe resets the *entire* PXE DB (addresses, notes, contracts, key store, L2 tips, facts, and tagging), not just tagging data, because they share one backing KV store. Existing wallets re-sync from genesis after upgrade. This is inherent to any `PXE_DATA_SCHEMA_VERSION` bump. - `app_tagging_secret.test.ts`: pins the new three-part unconstrained `toString()`, adds a "rejects the legacy two-part format" case, drops the now-redundant kind-prefixed-unconstrained test. - `pxe_db_compatibility.test.ts`: adds a pre-migration wipe test that writes a raw legacy two-part key at schema v12, reopens at v13, and asserts the raw map is empty (it asserts raw map contents rather than a high-level getter, which would false-pass since a new three-part `toString()` never reconstructs a legacy key). (cherry picked from commit 736f39189c0beb73ef4a514e7dfa09cfdb84ec3f) --- .../__snapshots__/RecipientTaggingStore.json | 14 ++- .../__snapshots__/SenderTaggingStore.json | 16 +++- .../__snapshots__/opened_stores.json | 2 +- .../pxe_db_compatibility.test.ts | 94 +++++++++++++------ .../schema_tests.ts | 31 +++++- yarn-project/pxe/src/storage/metadata.ts | 2 +- .../src/logs/app_tagging_secret.test.ts | 11 +-- .../stdlib/src/logs/app_tagging_secret.ts | 25 ++--- 8 files changed, 131 insertions(+), 64 deletions(-) diff --git a/yarn-project/pxe/src/storage/backwards_compatibility_tests/__snapshots__/RecipientTaggingStore.json b/yarn-project/pxe/src/storage/backwards_compatibility_tests/__snapshots__/RecipientTaggingStore.json index 86234edfe1fc..8b9271d749e3 100644 --- a/yarn-project/pxe/src/storage/backwards_compatibility_tests/__snapshots__/RecipientTaggingStore.json +++ b/yarn-project/pxe/src/storage/backwards_compatibility_tests/__snapshots__/RecipientTaggingStore.json @@ -1,17 +1,25 @@ { "highest_aged_index": [ { - "key": "utf8:0x0000000000000000000000000000000000000000000000000000000000000002:0x0000000000000000000000000000000000000000000000000000000000000003", + "key": "utf8:constrained:0x0000000000000000000000000000000000000000000000000000000000000013:0x0000000000000000000000000000000000000000000000000000000000000017", + "value": "num:13" + }, + { + "key": "utf8:unconstrained:0x0000000000000000000000000000000000000000000000000000000000000002:0x0000000000000000000000000000000000000000000000000000000000000003", "value": "num:13" } ], "highest_finalized_index": [ { - "key": "utf8:0x0000000000000000000000000000000000000000000000000000000000000002:0x0000000000000000000000000000000000000000000000000000000000000003", + "key": "utf8:constrained:0x0000000000000000000000000000000000000000000000000000000000000013:0x0000000000000000000000000000000000000000000000000000000000000017", + "value": "num:11" + }, + { + "key": "utf8:unconstrained:0x0000000000000000000000000000000000000000000000000000000000000002:0x0000000000000000000000000000000000000000000000000000000000000003", "value": "num:11" }, { - "key": "utf8:0x0000000000000000000000000000000000000000000000000000000000000005:0x0000000000000000000000000000000000000000000000000000000000000007", + "key": "utf8:unconstrained:0x0000000000000000000000000000000000000000000000000000000000000005:0x0000000000000000000000000000000000000000000000000000000000000007", "value": "num:17" } ] diff --git a/yarn-project/pxe/src/storage/backwards_compatibility_tests/__snapshots__/SenderTaggingStore.json b/yarn-project/pxe/src/storage/backwards_compatibility_tests/__snapshots__/SenderTaggingStore.json index 487674b3cde4..dcf8654b3ecf 100644 --- a/yarn-project/pxe/src/storage/backwards_compatibility_tests/__snapshots__/SenderTaggingStore.json +++ b/yarn-project/pxe/src/storage/backwards_compatibility_tests/__snapshots__/SenderTaggingStore.json @@ -1,21 +1,29 @@ { "pending_indexes": [ { - "key": "utf8:0x0000000000000000000000000000000000000000000000000000000000000002:0x0000000000000000000000000000000000000000000000000000000000000003", + "key": "utf8:constrained:0x0000000000000000000000000000000000000000000000000000000000000013:0x0000000000000000000000000000000000000000000000000000000000000017", + "value": "[{\"lowestIndex\":4,\"highestIndex\":7,\"txHash\":\"0x0000000000000000000000000000000000000000000000000000000000000025\"}]" + }, + { + "key": "utf8:unconstrained:0x0000000000000000000000000000000000000000000000000000000000000002:0x0000000000000000000000000000000000000000000000000000000000000003", "value": "[{\"lowestIndex\":4,\"highestIndex\":7,\"txHash\":\"0x0000000000000000000000000000000000000000000000000000000000000013\"},{\"lowestIndex\":8,\"highestIndex\":11,\"txHash\":\"0x0000000000000000000000000000000000000000000000000000000000000017\"}]" }, { - "key": "utf8:0x000000000000000000000000000000000000000000000000000000000000000b:0x000000000000000000000000000000000000000000000000000000000000000d", + "key": "utf8:unconstrained:0x000000000000000000000000000000000000000000000000000000000000000b:0x000000000000000000000000000000000000000000000000000000000000000d", "value": "[{\"lowestIndex\":1,\"highestIndex\":9,\"txHash\":\"0x000000000000000000000000000000000000000000000000000000000000001d\"}]" } ], "last_finalized_indexes": [ { - "key": "utf8:0x0000000000000000000000000000000000000000000000000000000000000002:0x0000000000000000000000000000000000000000000000000000000000000003", + "key": "utf8:constrained:0x0000000000000000000000000000000000000000000000000000000000000013:0x0000000000000000000000000000000000000000000000000000000000000017", + "value": "num:3" + }, + { + "key": "utf8:unconstrained:0x0000000000000000000000000000000000000000000000000000000000000002:0x0000000000000000000000000000000000000000000000000000000000000003", "value": "num:3" }, { - "key": "utf8:0x0000000000000000000000000000000000000000000000000000000000000005:0x0000000000000000000000000000000000000000000000000000000000000007", + "key": "utf8:unconstrained:0x0000000000000000000000000000000000000000000000000000000000000005:0x0000000000000000000000000000000000000000000000000000000000000007", "value": "num:5" } ] 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 5afea8e276cf..fe8a7f6856ea 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": 12, + "schemaVersion": 13, "stores": [ { "name": "capsules", diff --git a/yarn-project/pxe/src/storage/backwards_compatibility_tests/pxe_db_compatibility.test.ts b/yarn-project/pxe/src/storage/backwards_compatibility_tests/pxe_db_compatibility.test.ts index 0cfe79e80992..e40e0209e4a1 100644 --- a/yarn-project/pxe/src/storage/backwards_compatibility_tests/pxe_db_compatibility.test.ts +++ b/yarn-project/pxe/src/storage/backwards_compatibility_tests/pxe_db_compatibility.test.ts @@ -1,5 +1,7 @@ +import { Fr } from '@aztec/foundation/curves/bn254'; import { EthAddress } from '@aztec/foundation/eth-address'; import { KeyStore } from '@aztec/key-store'; +import type { AztecAsyncKVStore } from '@aztec/kv-store'; import { createStore, openTmpStore } from '@aztec/kv-store/lmdb-v2'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; import { GENESIS_BLOCK_HEADER_HASH } from '@aztec/stdlib/block'; @@ -23,6 +25,9 @@ expect.extend({ toMatchFile }); const __dirname = dirname(fileURLToPath(import.meta.url)); // The last schema in which the key store still persisted the message-signing and fallback secret keys. const PRE_MESSAGE_AND_FALLBACK_SECRET_KEY_REMOVAL_PXE_SCHEMA_VERSION = 10; +// The last schema in which the tagging stores keyed unconstrained entries by the legacy two-part AppTaggingSecret +// format (`:`), before every key moved to the self-describing `::` form. +const PRE_KIND_PREFIXED_TAGGING_KEY_PXE_SCHEMA_VERSION = 12; /** * Asserts that `value` matches the per-store snapshot file `__snapshots__/.json`. Each store gets its own file @@ -140,6 +145,42 @@ async function collectOpenedStores() { } } +/** + * Seeds rows into a `pxe_data` store opened at `oldSchemaVersion`, then reopens it at the current + * `PXE_DATA_SCHEMA_VERSION`. The version mismatch makes DatabaseVersionManager wipe the database on open, so + * `assertWiped` runs against a cleared store and can prove the legacy rows are gone. + */ +async function expectStoreWipedOnUpgradeFrom( + oldSchemaVersion: number, + seedLegacyRows: (oldStore: AztecAsyncKVStore) => Promise, + assertWiped: (currentStore: AztecAsyncKVStore) => Promise, +) { + const dataDirectory = await mkdtemp(join(tmpdir(), 'pxe-schema-wipe-')); + const config = { + dataDirectory, + dataStoreMapSizeKb: 1024, + rollupAddress: EthAddress.ZERO, + }; + + try { + const oldStore = await createStore('pxe_data', oldSchemaVersion, config); + try { + await seedLegacyRows(oldStore); + } finally { + await oldStore.close(); + } + + const currentStore = await createStore('pxe_data', PXE_DATA_SCHEMA_VERSION, config); + try { + await assertWiped(currentStore); + } finally { + await currentStore.close(); + } + } finally { + await rm(dataDirectory, { recursive: true, force: true, maxRetries: 3 }); + } +} + /** * Backwards-compatibility test suite for PXE storage. The intent is to detect any change to the bytes PXE writes to * disk that would render existing on-device data unreadable after a version bump. Each schema test (in @@ -159,42 +200,35 @@ async function collectOpenedStores() { describe('PXE storage compatibility test suite', () => { it('wipes key-store rows written before the message-signing and fallback secret keys were removed', async () => { const account = AztecAddress.fromStringUnsafe('0x0b3683ee9df3ed6ed7027145bd6093f783b0bb4d8354501d906db7bb8cb58ea3'); - const dataDirectory = await mkdtemp(join(tmpdir(), 'pxe-schema-reset-')); - const config = { - dataDirectory, - dataStoreMapSizeKb: 1024, - rollupAddress: EthAddress.ZERO, - }; - - try { - const oldStore = await createStore( - 'pxe_data', - PRE_MESSAGE_AND_FALLBACK_SECRET_KEY_REMOVAL_PXE_SCHEMA_VERSION, - config, - ); - try { - await oldStore + await expectStoreWipedOnUpgradeFrom( + PRE_MESSAGE_AND_FALLBACK_SECRET_KEY_REMOVAL_PXE_SCHEMA_VERSION, + oldStore => + oldStore .openMap('key_store') .set( `${account.toString()}-ivsk_m`, Buffer.from('1fb01c42d1aaa2662041b899c77cb19e08192193acc5a94405f1b43c974eba7a', 'hex'), - ); - } finally { - await oldStore.close(); - } - - const currentStore = await createStore('pxe_data', PXE_DATA_SCHEMA_VERSION, config); - try { + ), + async currentStore => { const keyStore = new KeyStore(currentStore); - // Opening a below-current-version DB triggers DatabaseVersionManager to wipe it, so the account written under - // the old schema is gone and the new code never reads its now-incompatible rows. await expect(keyStore.hasAccount(account)).resolves.toBe(false); - } finally { - await currentStore.close(); - } - } finally { - await rm(dataDirectory, { recursive: true, force: true, maxRetries: 3 }); - } + }, + ); + }); + + it('wipes tagging-store rows written under the legacy two-part AppTaggingSecret key format', async () => { + // The current toString() only emits the three-part `::` form, so build the legacy two-part + // `:` key by hand. + const legacyKey = `${new Fr(2n).toString()}:${AztecAddress.fromBigIntUnsafe(3n).toString()}`; + await expectStoreWipedOnUpgradeFrom( + PRE_KIND_PREFIXED_TAGGING_KEY_PXE_SCHEMA_VERSION, + oldStore => oldStore.openMap('highest_aged_index').set(legacyKey, 13), + async currentStore => { + // Assert on the raw map, not a high-level getter: the new three-part toString() never reconstructs the + // legacy two-part key, so a getter would report it absent (false-pass) whether or not the wipe happened. + await expect(currentStore.openMap('highest_aged_index').sizeAsync()).resolves.toBe(0); + }, + ); }); it('opens the expected set of stores', async () => { 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 196f97b4cd67..a8561507d41d 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 @@ -18,6 +18,7 @@ import { GasFees } from '@aztec/stdlib/gas'; import { PublicKey, PublicKeys, deriveKeys } from '@aztec/stdlib/keys'; import { AppTaggingSecret, + AppTaggingSecretKind, ContractClassLog, ContractClassLogFields, PrivateLog, @@ -530,10 +531,18 @@ export const SCHEMA_TESTS: readonly SchemaTest[] = [ const jobId = 'fixture-job'; const secretA = new AppTaggingSecret(new Fr(2n), AztecAddress.fromBigIntUnsafe(3n)); const secretB = new AppTaggingSecret(new Fr(5n), AztecAddress.fromBigIntUnsafe(7n)); + // A constrained secret keys under the `constrained:` prefix, so the snapshot pins both kinds side by side. + const secretConstrained = new AppTaggingSecret( + new Fr(19n), + AztecAddress.fromBigIntUnsafe(23n), + AppTaggingSecretKind.CONSTRAINED, + ); await recipientTaggingStore.updateHighestFinalizedIndex(secretA, 11, jobId); await recipientTaggingStore.updateHighestAgedIndex(secretA, 13, jobId); await recipientTaggingStore.updateHighestFinalizedIndex(secretB, 17, jobId); + await recipientTaggingStore.updateHighestFinalizedIndex(secretConstrained, 11, jobId); + await recipientTaggingStore.updateHighestAgedIndex(secretConstrained, 13, jobId); await kvStore.transactionAsync(() => recipientTaggingStore.commit(jobId)); }, snapshotStore: async kvStore => ({ @@ -584,10 +593,17 @@ export const SCHEMA_TESTS: readonly SchemaTest[] = [ const secretA = new AppTaggingSecret(new Fr(2n), AztecAddress.fromBigIntUnsafe(3n)); const secretB = new AppTaggingSecret(new Fr(5n), AztecAddress.fromBigIntUnsafe(7n)); const secretC = new AppTaggingSecret(new Fr(11n), AztecAddress.fromBigIntUnsafe(13n)); + const secretConstrained = new AppTaggingSecret( + new Fr(19n), + AztecAddress.fromBigIntUnsafe(23n), + AppTaggingSecretKind.CONSTRAINED, + ); const txHashA = TxHash.fromBigInt(17n); const txHashB = TxHash.fromBigInt(19n); const txHashC = TxHash.fromBigInt(23n); const txHashD = TxHash.fromBigInt(29n); + const txHashE = TxHash.fromBigInt(31n); + const txHashF = TxHash.fromBigInt(37n); // secretA receives three pending ranges (one per tx); secretB receives one. After finalizing txHashA below, // secretA's array shrinks to two elements (the txHashB and txHashC ranges, both with highestIndex > 3) which @@ -626,7 +642,20 @@ export const SCHEMA_TESTS: readonly SchemaTest[] = [ jobId, ); - await senderTaggingStore.finalizePendingIndexes([txHashA], jobId); + // secretConstrained gets a finalized range (txHashE) plus a surviving higher pending range (txHashF), so the + // `constrained:` key lands in both pending_indexes and last_finalized_indexes. + await senderTaggingStore.storePendingIndexes( + [{ extendedSecret: secretConstrained, lowestIndex: 1, highestIndex: 3 }], + txHashE, + jobId, + ); + await senderTaggingStore.storePendingIndexes( + [{ extendedSecret: secretConstrained, lowestIndex: 4, highestIndex: 7 }], + txHashF, + jobId, + ); + + await senderTaggingStore.finalizePendingIndexes([txHashA, txHashE], jobId); await kvStore.transactionAsync(() => senderTaggingStore.commit(jobId)); }, diff --git a/yarn-project/pxe/src/storage/metadata.ts b/yarn-project/pxe/src/storage/metadata.ts index ca02f2aa5901..71cc183a6299 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 = 12; +export const PXE_DATA_SCHEMA_VERSION = 13; diff --git a/yarn-project/stdlib/src/logs/app_tagging_secret.test.ts b/yarn-project/stdlib/src/logs/app_tagging_secret.test.ts index e20729703003..04b6a28a7f48 100644 --- a/yarn-project/stdlib/src/logs/app_tagging_secret.test.ts +++ b/yarn-project/stdlib/src/logs/app_tagging_secret.test.ts @@ -128,15 +128,12 @@ describe('AppTaggingSecret', () => { expect(parsed.toString()).toBe(original.toString()); }); - // TODO(F-680): Remove once unconstrained `toString()` always emits the kind-prefixed format. - it('parses kind-prefixed unconstrained secrets', async () => { + it('rejects a string that is not three-part', async () => { const original = await randomAppTaggingSecret(AppTaggingSecretKind.UNCONSTRAINED); - const parsed = AppTaggingSecret.fromString( - `${AppTaggingSecretKind.UNCONSTRAINED}:${original.secret.toString()}:${original.app.toString()}`, - ); - expect(parsed.kind).toBe(AppTaggingSecretKind.UNCONSTRAINED); - expect(parsed.toString()).toBe(original.toString()); + expect(() => AppTaggingSecret.fromString(`${original.secret.toString()}:${original.app.toString()}`)).toThrow( + /Invalid AppTaggingSecret string/, + ); }); it('rejects unknown kind prefixes', async () => { diff --git a/yarn-project/stdlib/src/logs/app_tagging_secret.ts b/yarn-project/stdlib/src/logs/app_tagging_secret.ts index ad263cee3b06..779618a2461b 100644 --- a/yarn-project/stdlib/src/logs/app_tagging_secret.ts +++ b/yarn-project/stdlib/src/logs/app_tagging_secret.ts @@ -89,29 +89,20 @@ export class AppTaggingSecret { } toString(): string { - // TODO(F-680): Migrate stored tagging keys and remove the legacy unconstrained format. - if (this.kind === AppTaggingSecretKind.UNCONSTRAINED) { - return `${this.secret.toString()}:${this.app.toString()}`; - } return `${this.kind}:${this.secret.toString()}:${this.app.toString()}`; } static fromString(str: string): AppTaggingSecret { const parts = str.split(':'); - if (parts.length === 2) { - // TODO(F-680): Remove legacy two-part parsing after stored tagging keys are migrated. - const [secretStr, appStr] = parts; - return new AppTaggingSecret(Fr.fromString(secretStr), AztecAddress.fromStringUnsafe(appStr)); - } - if (parts.length === 3) { - const [kindStr, secretStr, appStr] = parts; - return new AppTaggingSecret( - Fr.fromString(secretStr), - AztecAddress.fromStringUnsafe(appStr), - appTaggingSecretKindFromString(kindStr), - ); + if (parts.length !== 3) { + throw new Error(`Invalid AppTaggingSecret string: ${str}`); } - throw new Error(`Invalid AppTaggingSecret string: ${str}`); + const [kindStr, secretStr, appStr] = parts; + return new AppTaggingSecret( + Fr.fromString(secretStr), + AztecAddress.fromStringUnsafe(appStr), + appTaggingSecretKindFromString(kindStr), + ); } } From 3a27ea7439c6915442f77f700b362ab656509572 Mon Sep 17 00:00:00 2001 From: Martin Verzilli Date: Thu, 9 Jul 2026 17:03:00 +0200 Subject: [PATCH 10/13] feat: add batch is block in archive oracle (#24634) Adds an oracle to check whether a collection of block hashes is present in the archiver tree as of a given block. An upstream port of: https://github.com/aztec-labs-eng/oxide/pull/257 Note: I considered making this batch call return something more "interesting" than existence booleans (such as MembershipWitnesses), but that would potentially be heavy over the wire and there's no current evidence of it being needed. We can add a new oracle for that if turns out to be useful in the future. Closes F-810 (cherry picked from commit 296717987f169251ade417a503e39395da7a4520) --- .../src/oracle/get_membership_witness.nr | 28 +++++++++++++++---- .../aztec-nr/aztec/src/oracle/mod.nr | 7 ++++- .../aztec-nr/aztec/src/oracle/version.nr | 2 +- .../oracle/oracle_registry.ts | 8 ++++++ .../oracle/utility_execution.test.ts | 21 ++++++++++++++ .../oracle/utility_execution_oracle.ts | 16 +++++++++++ yarn-project/pxe/src/oracle_version.ts | 4 +-- yarn-project/txe/src/rpc_translator.ts | 10 +++++++ 8 files changed, 87 insertions(+), 9 deletions(-) diff --git a/noir-projects/aztec-nr/aztec/src/oracle/get_membership_witness.nr b/noir-projects/aztec-nr/aztec/src/oracle/get_membership_witness.nr index 1b77cd7d7885..d81e99a71067 100644 --- a/noir-projects/aztec-nr/aztec/src/oracle/get_membership_witness.nr +++ b/noir-projects/aztec-nr/aztec/src/oracle/get_membership_witness.nr @@ -1,8 +1,11 @@ -use crate::protocol::{ - abis::block_header::BlockHeader, - constants::{ARCHIVE_HEIGHT, NOTE_HASH_TREE_HEIGHT}, - merkle_tree::MembershipWitness, - traits::Hash, +use crate::{ + ephemeral::EphemeralArray, + protocol::{ + abis::block_header::BlockHeader, + constants::{ARCHIVE_HEIGHT, NOTE_HASH_TREE_HEIGHT}, + merkle_tree::MembershipWitness, + traits::Hash, + }, }; #[oracle(aztec_utl_getNoteHashMembershipWitness)] @@ -17,6 +20,12 @@ unconstrained fn get_block_hash_membership_witness_oracle( block_hash: Field, ) -> Option> {} +#[oracle(aztec_utl_areBlockHashesInArchive)] +unconstrained fn are_block_hashes_in_archive_oracle( + anchor_block_hash: Field, + block_hashes: EphemeralArray, +) -> EphemeralArray {} + // Note: get_nullifier_membership_witness function is implemented in get_nullifier_membership_witness.nr /// Returns a membership witness for a `note_hash` in the note hash tree whose root is defined in @@ -53,6 +62,15 @@ pub unconstrained fn get_maybe_block_hash_membership_witness( get_block_hash_membership_witness_oracle(anchor_block_hash, block_hash) } +/// Returns whether each block hash is present in the archive tree whose root is defined in `anchor_block_header`. +pub unconstrained fn are_block_hashes_in_archive( + anchor_block_header: BlockHeader, + block_hashes: EphemeralArray, +) -> EphemeralArray { + let anchor_block_hash = anchor_block_header.hash(); + are_block_hashes_in_archive_oracle(anchor_block_hash, block_hashes) +} + mod test { use crate::history::test::{create_note, NOTE_CREATED_AT}; use crate::note::note_interface::NoteHash; diff --git a/noir-projects/aztec-nr/aztec/src/oracle/mod.nr b/noir-projects/aztec-nr/aztec/src/oracle/mod.nr index 3fe750eb3e10..c84420c7fac0 100644 --- a/noir-projects/aztec-nr/aztec/src/oracle/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/oracle/mod.nr @@ -99,7 +99,12 @@ pub mod get_nullifier_membership_witness; ], )] pub mod get_public_data_witness; -#[generate_oracle_tests] +#[generate_oracle_tests_excluding( + @[ + // TODO: cover once the test resolver can serve EphemeralArray contents. + quote { are_block_hashes_in_archive_oracle }, + ], +)] pub mod get_membership_witness; #[generate_oracle_tests] pub mod keys; diff --git a/noir-projects/aztec-nr/aztec/src/oracle/version.nr b/noir-projects/aztec-nr/aztec/src/oracle/version.nr index 54924c79d5d3..cb77267bd8c5 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 = 6; +pub global ORACLE_VERSION_MINOR: Field = 7; /// 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/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_registry.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_registry.ts index b9bba4a8efc5..18bf3305e570 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 @@ -141,6 +141,14 @@ export const ORACLE_REGISTRY = { returnType: OPTION(MEMBERSHIP_WITNESS(ARCHIVE_HEIGHT)), }), + aztec_utl_areBlockHashesInArchive: makeEntry({ + params: [ + { name: 'anchorBlockHash', type: BLOCK_HASH }, + { name: 'blockHashes', type: EPHEMERAL_ARRAY(BLOCK_HASH) }, + ], + returnType: EPHEMERAL_ARRAY(BOOL), + }), + aztec_utl_getNullifierMembershipWitness: makeEntry({ params: [ { name: 'blockHash', type: BLOCK_HASH }, diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution.test.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution.test.ts index 48beacfb3d23..c9608cb3c5e3 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution.test.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution.test.ts @@ -748,6 +748,27 @@ describe('Utility Execution test suite', () => { expect(aztecNode.getBlockHashMembershipWitness).toHaveBeenCalledTimes(1); }); + it('returns aligned archive-membership booleans for block hash batches', async () => { + const service = new EphemeralArrayService(); + const oracle = makeOracle({ scopes: [scope] }); + const referenceBlockHash = await anchorBlockHeader.hash(); + const presentBlockHash = BlockHash.random(); + const missingBlockHash = BlockHash.random(); + const witness = MembershipWitness.empty(ARCHIVE_HEIGHT); + + aztecNode.getBlockHashMembershipWitness.mockImplementation((_referenceBlockHash, blockHash) => + Promise.resolve(blockHash.equals(presentBlockHash) ? witness : undefined), + ); + + const result = await oracle.areBlockHashesInArchive( + referenceBlockHash, + EphemeralArray.fromValues(service, [presentBlockHash, missingBlockHash, presentBlockHash]), + ); + + expect(result.readAll(service)).toEqual([true, false, true]); + expect(aztecNode.getBlockHashMembershipWitness).toHaveBeenCalledTimes(2); + }); + it('reuses public storage reads within a utility execution', async () => { const oracle = makeOracle({ scopes: [scope] }); const blockHash = await anchorBlockHeader.hash(); 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 834e87c9f58d..637817bc5d7f 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 @@ -274,6 +274,22 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra return witness ? Option.some(witness) : Option.none(); } + /** Returns whether each block hash is present in the archive tree at the referenced block. */ + public async areBlockHashesInArchive( + referenceBlockHash: BlockHash, + blockHashes: EphemeralArray, + ): Promise> { + const hashes = blockHashes.readAll(this.ephemeralArrayService); + const memberships = await this.#queryWithBlockHashNotAfterAnchor(referenceBlockHash, () => + Promise.all( + hashes.map(blockHash => + this.aztecNodeReadCache.getBlockHashMembershipWitness(referenceBlockHash, blockHash).then(Boolean), + ), + ), + ); + return EphemeralArray.fromValues(this.ephemeralArrayService, memberships); + } + /** * Returns a nullifier membership witness for a given nullifier at a given block. * @param blockHash - The block hash at which to get the index. diff --git a/yarn-project/pxe/src/oracle_version.ts b/yarn-project/pxe/src/oracle_version.ts index 2072d50d221d..688b384f4d14 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 = 6; +export const ORACLE_VERSION_MINOR = 7; /// 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 = 6; /// - 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 = '6094994f539407001d2adce5b6a8792c08ef645ee39813e32390f6208e78bc16'; +export const ORACLE_INTERFACE_HASH = '416b0e7d3e6dca5803ebe2a65ea93f1e79759dc39ef8ec4c52eeba5e2b0f3fca'; diff --git a/yarn-project/txe/src/rpc_translator.ts b/yarn-project/txe/src/rpc_translator.ts index 8abae5a7b681..b86d90375b5c 100644 --- a/yarn-project/txe/src/rpc_translator.ts +++ b/yarn-project/txe/src/rpc_translator.ts @@ -572,6 +572,16 @@ export class RPCTranslator { }); } + // eslint-disable-next-line camelcase + aztec_utl_areBlockHashesInArchive(...inputs: ForeignCallArgs) { + return callTxeHandler({ + oracle: 'aztec_utl_areBlockHashesInArchive', + inputs, + handler: ([anchorBlockHash, blockHashes]) => + this.handlerAsUtility().areBlockHashesInArchive(anchorBlockHash, blockHashes), + }); + } + // eslint-disable-next-line camelcase aztec_utl_getLowNullifierMembershipWitness(...inputs: ForeignCallArgs) { return callTxeHandler({ From 1424a53e3b6ef0d40ed2512e324900184ed39d76 Mon Sep 17 00:00:00 2001 From: Martin Verzilli Date: Fri, 10 Jul 2026 09:33:02 +0200 Subject: [PATCH 11/13] feat: getTxEffects oracle (#24636) Adds a new `getTxEffects` oracle to fetch tx effects in batch. (cherry picked from commit 7a77232ed7e3dd0783482e7f9230d3a0f866775c) --- .../aztec/src/oracle/message_processing.nr | 8 +++ .../aztec-nr/aztec/src/oracle/version.nr | 2 +- .../oracle/oracle_registry.ts | 5 ++ .../oracle/utility_execution.test.ts | 53 ++++++++++++++++++- .../oracle/utility_execution_oracle.ts | 51 +++++++++++++----- yarn-project/pxe/src/oracle_version.ts | 4 +- yarn-project/txe/src/rpc_translator.ts | 9 ++++ 7 files changed, 114 insertions(+), 18 deletions(-) diff --git a/noir-projects/aztec-nr/aztec/src/oracle/message_processing.nr b/noir-projects/aztec-nr/aztec/src/oracle/message_processing.nr index f6182d59f83d..fc7722521040 100644 --- a/noir-projects/aztec-nr/aztec/src/oracle/message_processing.nr +++ b/noir-projects/aztec-nr/aztec/src/oracle/message_processing.nr @@ -66,3 +66,11 @@ pub unconstrained fn get_tx_effect(tx_hash: Field) -> Option { #[oracle(aztec_utl_getTxEffect)] unconstrained fn get_tx_effect_oracle(tx_hash: Field) -> Option {} + +/// Fetches all effects of settled transactions by hash, preserving request order. +pub unconstrained fn get_tx_effects(tx_hashes: EphemeralArray) -> EphemeralArray> { + get_tx_effects_oracle(tx_hashes) +} + +#[oracle(aztec_utl_getTxEffects)] +unconstrained fn get_tx_effects_oracle(tx_hashes: EphemeralArray) -> EphemeralArray> {} diff --git a/noir-projects/aztec-nr/aztec/src/oracle/version.nr b/noir-projects/aztec-nr/aztec/src/oracle/version.nr index cb77267bd8c5..6137d3d641d7 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 = 7; +pub global ORACLE_VERSION_MINOR: Field = 8; /// 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/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_registry.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_registry.ts index 18bf3305e570..09c53de0730b 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 @@ -273,6 +273,11 @@ export const ORACLE_REGISTRY = { returnType: OPTION(TX_EFFECT), }), + aztec_utl_getTxEffects: makeEntry({ + params: [{ name: 'txHashes', type: EPHEMERAL_ARRAY(TX_HASH) }], + returnType: EPHEMERAL_ARRAY(OPTION(TX_EFFECT)), + }), + aztec_utl_setCapsule: makeEntry({ params: [ { name: 'contractAddress', type: AZTEC_ADDRESS }, diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution.test.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution.test.ts index c9608cb3c5e3..d6f3358693f5 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution.test.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution.test.ts @@ -34,6 +34,7 @@ import { BlockHeader, CallContext, Capsule, + DroppedTxReceipt, GlobalVariables, MinedTxReceipt, TxEffect, @@ -677,14 +678,19 @@ describe('Utility Execution test suite', () => { }); describe('node read cache', () => { - const makeMinedReceipt = (txHash: TxHash, txEffect = TxEffect.empty()) => + const makeTxEffect = (txHash: TxHash) => TxEffect.from({ ...TxEffect.empty(), txHash }); + const makeMinedReceipt = ( + txHash: TxHash, + txEffect = makeTxEffect(txHash), + blockNumber = BlockNumber(syncedBlockNumber), + ) => new MinedTxReceipt( txHash, TxStatus.FINALIZED, TxExecutionResult.SUCCESS, 0n, BlockHash.random(), - BlockNumber(syncedBlockNumber), + blockNumber, SlotNumber(1), 0, EpochNumber(1), @@ -734,6 +740,49 @@ describe('Utility Execution test suite', () => { expect(aztecNode.getTxReceipt).toHaveBeenCalledTimes(2); }); + it('returns aligned tx-effect options for tx hash batches', async () => { + const service = new EphemeralArrayService(); + const oracle = makeOracle({ scopes: [scope] }); + const presentTxHash = TxHash.random(); + const pendingTxHash = TxHash.random(); + const futureTxHash = TxHash.random(); + + aztecNode.getTxReceipt.mockImplementation(txHash => { + if (txHash.equals(presentTxHash)) { + return Promise.resolve(makeMinedReceipt(txHash)); + } + if (txHash.equals(futureTxHash)) { + return Promise.resolve(makeMinedReceipt(txHash, makeTxEffect(txHash), BlockNumber(syncedBlockNumber + 1))); + } + return Promise.resolve(new DroppedTxReceipt(txHash)); + }); + + const result = await oracle.getTxEffects( + EphemeralArray.fromValues(service, [presentTxHash, pendingTxHash, futureTxHash, presentTxHash]), + ); + const options = result.readAll(service); + + expect(options.map(option => option.isSome())).toEqual([true, false, false, true]); + expect(options[0].value?.txHash).toEqual(presentTxHash); + expect(options[3].value?.txHash).toEqual(presentTxHash); + expect(aztecNode.getTxReceipt).toHaveBeenCalledTimes(3); + }); + + it('does not share batched tx-effect reads across utility executions', async () => { + const service = new EphemeralArrayService(); + const txHash = TxHash.random(); + aztecNode.getTxReceipt.mockResolvedValue(makeMinedReceipt(txHash)); + + const firstOracle = makeOracle({ scopes: [scope] }); + const secondOracle = makeOracle({ scopes: [scope] }); + + await firstOracle.getTxEffects(EphemeralArray.fromValues(service, [txHash, txHash])); + expect(aztecNode.getTxReceipt).toHaveBeenCalledTimes(1); + + await secondOracle.getTxEffects(EphemeralArray.fromValues(service, [txHash])); + expect(aztecNode.getTxReceipt).toHaveBeenCalledTimes(2); + }); + it('reuses archive witness reads within a utility execution', async () => { const oracle = makeOracle({ scopes: [scope] }); const referenceBlockHash = await anchorBlockHeader.hash(); 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 637817bc5d7f..1a99ab102c3d 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 @@ -35,6 +35,7 @@ import { type Capsule, type IndexedTxEffect, type OffchainEffect, + type TxEffect, type TxHash, } from '@aztec/stdlib/tx'; @@ -685,21 +686,25 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra throw new Error('Invalid tx hash passed into aztec_utl_getTxEffect oracle handler'); } - const receipt = await this.aztecNodeReadCache.getTxReceiptWithEffect(txHash); - if (!receipt.isMined() || !receipt.txEffect || receipt.blockNumber > this.anchorBlockHeader.getBlockNumber()) { - return Option.none(); + return await this.#getTxEffectOption(txHash); + } + + /** Fetches transaction effects for all hashes, preserving request order. */ + public async getTxEffects(txHashes: EphemeralArray): Promise>> { + const hashes = txHashes.readAll(this.ephemeralArrayService); + const invalidHash = hashes.find(txHash => txHash.hash.isZero()); + if (invalidHash) { + throw new Error('Invalid tx hash passed into aztec_utl_getTxEffects oracle handler'); } - const txEffect = receipt.txEffect; - return Option.some({ - ...txEffect, - publicLogs: FlatPublicLogs.fromLogs(txEffect.publicLogs), - contractClassLogs: txEffect.contractClassLogs.map(log => ({ - contractAddress: log.contractAddress, - fields: log.fields.toFields(), - emittedLength: log.emittedLength, - })), - }); + const uniqueTxHashes = uniqueBy(hashes, h => h.toString()); + const options = await Promise.all(uniqueTxHashes.map(txHash => this.#getTxEffectOption(txHash))); + const optionsByHash = new Map(uniqueTxHashes.map((txHash, i) => [txHash.toString(), options[i]])); + + return EphemeralArray.fromValues( + this.ephemeralArrayService, + hashes.map(txHash => optionsByHash.get(txHash.toString())!), + ); } public setCapsule(contractAddress: AztecAddress, slot: Fr, capsule: Fr[], scope: AztecAddress): void { @@ -1127,6 +1132,26 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra ); } + async #getTxEffectOption(txHash: TxHash): Promise> { + const receipt = await this.aztecNodeReadCache.getTxReceiptWithEffect(txHash); + if (!receipt.isMined() || !receipt.txEffect || receipt.blockNumber > this.anchorBlockHeader.getBlockNumber()) { + return Option.none(); + } + return Option.some(this.#toTxEffectData(receipt.txEffect)); + } + + #toTxEffectData(txEffect: TxEffect): TxEffectData { + return { + ...txEffect, + publicLogs: FlatPublicLogs.fromLogs(txEffect.publicLogs), + contractClassLogs: txEffect.contractClassLogs.map(log => ({ + contractAddress: log.contractAddress, + fields: log.fields.toFields(), + emittedLength: log.emittedLength, + })), + }; + } + /** Runs a query concurrently with a validation that the block hash is not ahead of the anchor block. */ async #queryWithBlockHashNotAfterAnchor(blockHash: BlockHash, query: () => Promise): Promise { // Most contracts query state at the "current" block, which is the anchor. Skip the validation when we can. diff --git a/yarn-project/pxe/src/oracle_version.ts b/yarn-project/pxe/src/oracle_version.ts index 688b384f4d14..92cf5a9d3aa1 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 = 7; +export const ORACLE_VERSION_MINOR = 8; /// 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 = 7; /// - 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 = '416b0e7d3e6dca5803ebe2a65ea93f1e79759dc39ef8ec4c52eeba5e2b0f3fca'; +export const ORACLE_INTERFACE_HASH = 'b302a8e65d37043c0a0dc08a39895603122dce334843f0442462edb3f56e32e2'; diff --git a/yarn-project/txe/src/rpc_translator.ts b/yarn-project/txe/src/rpc_translator.ts index b86d90375b5c..396574f7fb20 100644 --- a/yarn-project/txe/src/rpc_translator.ts +++ b/yarn-project/txe/src/rpc_translator.ts @@ -633,6 +633,15 @@ export class RPCTranslator { }); } + // eslint-disable-next-line camelcase + aztec_utl_getTxEffects(...inputs: ForeignCallArgs) { + return callTxeHandler({ + oracle: 'aztec_utl_getTxEffects', + inputs, + handler: ([txHashes]) => this.handlerAsUtility().getTxEffects(txHashes), + }); + } + // eslint-disable-next-line camelcase aztec_utl_setCapsule(...inputs: ForeignCallArgs) { return callTxeHandler({ From a621bbb9ac7898f1b9ee8eca791d6dcd8c571135 Mon Sep 17 00:00:00 2001 From: Nicolas Chamo Date: Mon, 13 Jul 2026 18:20:49 -0300 Subject: [PATCH 12/13] feat(txe): add option to authorize all utility call targets (#24662) (cherry picked from commit 97b4c75c95ebc4071b7ac9e6187a5692f77df1bd) --- .../src/test/helpers/test_environment.nr | 16 ++++++++++ .../aztec/src/test/helpers/txe_oracles.nr | 5 +++- .../test/nested_utility_contract/src/test.nr | 27 +++++++++++++++-- yarn-project/txe/src/oracle/interfaces.ts | 4 +++ .../txe/src/oracle/txe_oracle_registry.ts | 4 +++ .../oracle/txe_oracle_top_level_context.ts | 30 +++++++++++++++++-- .../txe/src/oracle/txe_oracle_version.ts | 4 +-- yarn-project/txe/src/rpc_translator.ts | 9 ++++++ yarn-project/txe/src/txe_session.ts | 18 ++++++++--- 9 files changed, 106 insertions(+), 11 deletions(-) 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 7697a4509cba..fae0e28ab92f 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 @@ -101,6 +101,7 @@ pub struct TestEnvironment { pub struct TestEnvironmentOptions { default_unconstrained_tagging_secret_strategy: Option, default_constrained_tagging_secret_strategy: Option, + authorize_all_utility_call_targets: bool, } impl TestEnvironmentOptions { @@ -109,6 +110,7 @@ impl TestEnvironmentOptions { Self { default_unconstrained_tagging_secret_strategy: Option::none(), default_constrained_tagging_secret_strategy: Option::none(), + authorize_all_utility_call_targets: false, } } @@ -139,6 +141,18 @@ impl TestEnvironmentOptions { let _ = self.with_default_tag_secret_strategy(OnchainDeliveryMode::onchain_unconstrained(), strategy); self.with_default_tag_secret_strategy(OnchainDeliveryMode::onchain_constrained(), strategy) } + + /// Authorizes all cross-contract utility call targets for the entire test. + /// + /// Removes the need to list targets on each call via + /// [`CallPrivateOptions::with_authorized_utility_call_targets`] and its siblings. + /// + /// By default, cross-contract utility calls are denied, mirroring a wallet that authorizes no such call. Use + /// this in tests that do not care about utility call authorization. + pub fn with_all_utility_call_targets_authorized(&mut self) -> Self { + self.authorize_all_utility_call_targets = true; + *self + } } /// Configuration values for [`TestEnvironment::private_context_opts`]. Meant to be used by calling `new` and then @@ -688,6 +702,8 @@ impl TestEnvironment { options.default_constrained_tagging_secret_strategy, ); + txe_oracles::set_authorize_all_utility_call_targets(options.authorize_all_utility_call_targets); + Self { // Use an offset to avoid secret collision with account secrets. Without this, when // deploying multiple contracts and creating accounts, they could end up with identical 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 f8dcf4dd6157..54cbf803d8f0 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 = 4; -pub global TXE_ORACLE_VERSION_MINOR: Field = 0; +pub global TXE_ORACLE_VERSION_MINOR: Field = 1; /// Asserts that the TXE oracle interface version is compatible. pub unconstrained fn assert_compatible_txe_oracle_version() { @@ -271,6 +271,9 @@ pub unconstrained fn set_tagging_secret_strategies( constrained_strategy: Option, ) {} +#[oracle(aztec_txe_setAuthorizeAllUtilityCallTargets)] +pub unconstrained fn set_authorize_all_utility_call_targets(authorize_all: bool) {} + #[oracle(aztec_txe_privateCallNewFlow)] unconstrained fn private_call_new_flow_oracle( _from: Option, diff --git a/noir-projects/noir-contracts/contracts/test/nested_utility_contract/src/test.nr b/noir-projects/noir-contracts/contracts/test/nested_utility_contract/src/test.nr index 954984055473..b00671840b2a 100644 --- a/noir-projects/noir-contracts/contracts/test/nested_utility_contract/src/test.nr +++ b/noir-projects/noir-contracts/contracts/test/nested_utility_contract/src/test.nr @@ -1,11 +1,19 @@ use crate::NestedUtility; use aztec::{ protocol::{address::AztecAddress, traits::FromField}, - test::helpers::test_environment::{CallPrivateOptions, ExecuteUtilityOptions, TestEnvironment, ViewPrivateOptions}, + test::helpers::test_environment::{ + CallPrivateOptions, ExecuteUtilityOptions, TestEnvironment, TestEnvironmentOptions, ViewPrivateOptions, + }, }; unconstrained fn setup() -> (TestEnvironment, AztecAddress, AztecAddress, AztecAddress) { - let mut env = TestEnvironment::new(); + setup_with_opts(TestEnvironmentOptions::new()) +} + +unconstrained fn setup_with_opts( + options: TestEnvironmentOptions, +) -> (TestEnvironment, AztecAddress, AztecAddress, AztecAddress) { + let mut env = TestEnvironment::new_opts(options); let account = env.create_light_account(); let addr_a = env.deploy("NestedUtility").without_initializer(); let addr_b = env.deploy("NestedUtility").without_initializer(); @@ -88,6 +96,21 @@ unconstrained fn cross_contract_utility_call_from_private_view_succeeds_with_aut assert_eq(result, 8); } +#[test] +unconstrained fn all_call_types_succeed_with_all_utility_call_targets_authorized() { + let (env, account, addr_a, addr_b) = setup_with_opts(TestEnvironmentOptions::new() + .with_all_utility_call_targets_authorized()); + + let utility_result = env.execute_utility(NestedUtility::at(addr_a).delegate_pow_utility(addr_b, 2, 3)); + assert_eq(utility_result, 8); + + let private_result = env.call_private(account, NestedUtility::at(addr_a).delegate_pow_private(addr_b, 2, 3)); + assert_eq(private_result, 8); + + let view_result = env.view_private(account, NestedUtility::at(addr_a).delegate_pow_view(addr_b, 2, 3)); + assert_eq(view_result, 8); +} + #[test] unconstrained fn top_level_utility_observes_no_caller_by_default() { let (env, _, addr_a, _) = setup(); diff --git a/yarn-project/txe/src/oracle/interfaces.ts b/yarn-project/txe/src/oracle/interfaces.ts index baea1a37c7f4..90e2881308aa 100644 --- a/yarn-project/txe/src/oracle/interfaces.ts +++ b/yarn-project/txe/src/oracle/interfaces.ts @@ -81,6 +81,10 @@ export interface ITxeExecutionOracle { unconstrainedStrategy: Option, constrainedStrategy: Option, ): void; + /** + * Configures whether the test's simulated wallet authorizes every cross-contract utility call target. + */ + setAuthorizeAllUtilityCallTargets(authorizeAll: boolean): void; getLastBlockTimestamp(): Promise; getLastTxEffects(): Promise<{ txHash: TxHash; diff --git a/yarn-project/txe/src/oracle/txe_oracle_registry.ts b/yarn-project/txe/src/oracle/txe_oracle_registry.ts index 77af1da161d5..104ccde275d2 100644 --- a/yarn-project/txe/src/oracle/txe_oracle_registry.ts +++ b/yarn-project/txe/src/oracle/txe_oracle_registry.ts @@ -330,6 +330,10 @@ export const TXE_ORACLE_REGISTRY = { ], }), + aztec_txe_setAuthorizeAllUtilityCallTargets: makeEntry({ + params: [{ name: 'authorizeAll', type: BOOL }], + }), + aztec_txe_getLastBlockTimestamp: makeEntry({ returnType: BIGINT, }), diff --git a/yarn-project/txe/src/oracle/txe_oracle_top_level_context.ts b/yarn-project/txe/src/oracle/txe_oracle_top_level_context.ts index 3e3b3a7f6e26..21af3d40f03c 100644 --- a/yarn-project/txe/src/oracle/txe_oracle_top_level_context.ts +++ b/yarn-project/txe/src/oracle/txe_oracle_top_level_context.ts @@ -126,6 +126,7 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl private chainId: Fr, private authwits: Map, private taggingSecretStrategies: TXETaggingSecretStrategies, + private authorizeAllUtilityCallTargets: boolean, private readonly artifactResolver: TXEArtifactResolver, private readonly rootPath: string, private readonly packageName: string, @@ -372,6 +373,10 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl apply(AppTaggingSecretKind.CONSTRAINED, constrainedStrategy); } + setAuthorizeAllUtilityCallTargets(authorizeAll: boolean): void { + this.authorizeAllUtilityCallTargets = authorizeAll; + } + async sendL1ToL2Message(content: Fr, secretHash: Fr, sender: EthAddress, recipient: AztecAddress): Promise { // Messages are appended to the tree, so the next free slot is simply the current tree size. const { size } = await this.stateMachine.synchronizer @@ -982,9 +987,19 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl } } - close(): [bigint, Map, TXETaggingSecretStrategies] { + close(): { + nextBlockTimestamp: bigint; + authwits: Map; + taggingSecretStrategies: TXETaggingSecretStrategies; + authorizeAllUtilityCallTargets: boolean; + } { this.logger.debug('Exiting Top Level Context'); - return [this.nextBlockTimestamp, this.authwits, this.taggingSecretStrategies]; + return { + nextBlockTimestamp: this.nextBlockTimestamp, + authwits: this.authwits, + taggingSecretStrategies: this.taggingSecretStrategies, + authorizeAllUtilityCallTargets: this.authorizeAllUtilityCallTargets, + }; } private async getLastBlockNumber(): Promise { @@ -996,6 +1011,9 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl callerContext: 'private' | 'private view' | 'utility', authorizedTargets: AztecAddress[], ): ExecutionHooks['authorizeUtilityCall'] | undefined { + if (this.authorizeAllUtilityCallTargets) { + return authorizeAllUtilityCallsHook; + } if (authorizedTargets.length === 0) { return undefined; } @@ -1005,3 +1023,11 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl }); } } + +/** + * An `authorizeUtilityCall` hook that authorizes every cross-contract utility call. + * + * Backs the `aztec_txe_setAuthorizeAllUtilityCallTargets` oracle. + */ +export const authorizeAllUtilityCallsHook: NonNullable = () => + Promise.resolve({ authorized: true }); diff --git a/yarn-project/txe/src/oracle/txe_oracle_version.ts b/yarn-project/txe/src/oracle/txe_oracle_version.ts index ed285f23552c..3fc51aca2b67 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 = 4; -export const TXE_ORACLE_VERSION_MINOR = 0; +export const TXE_ORACLE_VERSION_MINOR = 1; /** * This hash is computed from the TXE oracle interfaces (IAvmExecutionOracle and ITxeExecutionOracle) and is used to @@ -14,4 +14,4 @@ export const TXE_ORACLE_VERSION_MINOR = 0; * - TXE_ORACLE_VERSION_MAJOR (and reset MINOR to 0) for breaking changes, or * - TXE_ORACLE_VERSION_MINOR for additive changes (new oracle method added). */ -export const TXE_ORACLE_INTERFACE_HASH = 'e7a3a641f1ebe5396f9c0756109cbd48b170b50a492b6de74a6c453e014e096e'; +export const TXE_ORACLE_INTERFACE_HASH = '52ed86292711cb13cb64d333207e0c1993c008f1aa5d68cab4f6b67a9ce29e8e'; diff --git a/yarn-project/txe/src/rpc_translator.ts b/yarn-project/txe/src/rpc_translator.ts index 396574f7fb20..d3f854e3bf67 100644 --- a/yarn-project/txe/src/rpc_translator.ts +++ b/yarn-project/txe/src/rpc_translator.ts @@ -230,6 +230,15 @@ export class RPCTranslator { }); } + // eslint-disable-next-line camelcase + aztec_txe_setAuthorizeAllUtilityCallTargets(...inputs: ForeignCallArgs) { + return callTxeHandler({ + oracle: 'aztec_txe_setAuthorizeAllUtilityCallTargets', + inputs, + handler: ([authorizeAll]) => this.handlerAsTxe().setAuthorizeAllUtilityCallTargets(authorizeAll), + }); + } + // PXE oracles // eslint-disable-next-line camelcase diff --git a/yarn-project/txe/src/txe_session.ts b/yarn-project/txe/src/txe_session.ts index 6b6345023be6..ca027ecbaa17 100644 --- a/yarn-project/txe/src/txe_session.ts +++ b/yarn-project/txe/src/txe_session.ts @@ -63,7 +63,7 @@ import { } from './oracle/tagging_secret_strategy.js'; import { TXEOraclePublicContext } from './oracle/txe_oracle_public_context.js'; import { callTxeLegacyHandler } from './oracle/txe_oracle_registry.js'; -import { TXEOracleTopLevelContext } from './oracle/txe_oracle_top_level_context.js'; +import { TXEOracleTopLevelContext, authorizeAllUtilityCallsHook } from './oracle/txe_oracle_top_level_context.js'; import { TXE_ORACLE_VERSION_MAJOR, TXE_ORACLE_VERSION_MINOR } from './oracle/txe_oracle_version.js'; import { TXEPrivateExecutionOracle } from './oracle/txe_private_execution_oracle.js'; import { RPCTranslator, UnavailableOracleError } from './rpc_translator.js'; @@ -241,6 +241,7 @@ export class TXESession implements TXESessionStateHandler { private state: SessionState = { name: 'TOP_LEVEL' }; private authwits: Map = new Map(); private taggingSecretStrategies: TXETaggingSecretStrategies = new Map(); + private authorizeAllUtilityCallTargets = false; private lastCallInfo: LastCallState = emptyLastCallState(); private txeOracleVersion: { major: number; minor: number } | undefined; @@ -369,6 +370,7 @@ export class TXESession implements TXESessionStateHandler { chainId, new Map(), new Map(), + false, // authorizeAllUtilityCallTargets artifactResolver, rootPath, packageName, @@ -699,6 +701,7 @@ export class TXESession implements TXESessionStateHandler { this.chainId, this.authwits, this.taggingSecretStrategies, + this.authorizeAllUtilityCallTargets, this.artifactResolver, this.rootPath, this.packageName, @@ -779,6 +782,7 @@ export class TXESession implements TXESessionStateHandler { simulator: new WASMSimulator(), hooks: composeHooks({ resolveTaggingSecretStrategy: makeResolveTaggingSecretStrategyHook(this.taggingSecretStrategies), + authorizeUtilityCall: this.authorizeAllUtilityCallTargets ? authorizeAllUtilityCallsHook : undefined, }), transientArrayService, }); @@ -879,6 +883,9 @@ export class TXESession implements TXESessionStateHandler { scopes: await this.keyStore.getAccounts(), simulator: new WASMSimulator(), utilityExecutor: this.utilityExecutorForContractSync(anchorBlockHeader), + hooks: composeHooks({ + authorizeUtilityCall: this.authorizeAllUtilityCallTargets ? authorizeAllUtilityCallsHook : undefined, + }), // Execution-tree root (top-level utility run): own store; nested frames inherit it. transientArrayService: new TransientArrayService(), }); @@ -907,9 +914,12 @@ export class TXESession implements TXESessionStateHandler { // TODO: persisting authwits this way is quite unfortunate: they create a temporary utility context that would // otherwise reset them, so we'd not be able to pass more than one per execution. Ideally authwits would be passed // alongside a contract call instead of pre-seeded. - [this.nextBlockTimestamp, this.authwits, this.taggingSecretStrategies] = ( - this.oracleHandler as TXEOracleTopLevelContext - ).close(); + ({ + nextBlockTimestamp: this.nextBlockTimestamp, + authwits: this.authwits, + taggingSecretStrategies: this.taggingSecretStrategies, + authorizeAllUtilityCallTargets: this.authorizeAllUtilityCallTargets, + } = (this.oracleHandler as TXEOracleTopLevelContext).close()); } private async exitPrivateState() { From 030442417a580646b0241ff7589194906c160164 Mon Sep 17 00:00:00 2001 From: Nicolas Chamo Date: Mon, 20 Jul 2026 13:46:05 -0300 Subject: [PATCH 13/13] refactor(pxe): compute oracle interface hash from wire-structural mapping labels (#24752) (cherry picked from commit d2db07464064f4286349a1e0b64e3238c3faeb70) --- .../aztec-nr/aztec/src/oracle/mod.nr | 8 +- .../aztec/src/oracle/tx_resolution.nr | 101 ---- .../pxe/src/bin/check_oracle_version.ts | 13 +- yarn-project/pxe/src/bin/index.ts | 6 +- .../src/bin/oracle_version_helpers.test.ts | 77 ++- .../pxe/src/bin/oracle_version_helpers.ts | 279 +--------- .../src/contract_function_simulator/index.ts | 26 +- .../noir-structs/resolved_tx.test.ts | 105 ---- .../noir-structs/resolved_tx.ts | 56 +- .../noir-structs/tx_effect_data.ts | 7 +- .../oracle/oracle_registry.ts | 15 +- .../oracle/oracle_type_mappings.test.ts | 70 ++- .../oracle/oracle_type_mappings.ts | 482 +++++++++++------- .../oracle/utility_execution.test.ts | 15 +- .../oracle/utility_execution_oracle.ts | 11 +- yarn-project/pxe/src/logs/log_service.ts | 9 +- .../src/messages/tx_resolver_service.test.ts | 31 +- .../pxe/src/messages/tx_resolver_service.ts | 16 +- yarn-project/pxe/src/oracle_version.ts | 4 +- .../pxe/src/storage/fact_store/index.ts | 1 + .../src/storage/fact_store/origin_state.ts | 9 + .../txe/src/bin/check_txe_oracle_version.ts | 21 +- .../txe/src/oracle/oracle_kinds.test.ts | 33 ++ .../test-resolver/default_fixtures.test.ts | 6 +- .../oracle/test-resolver/default_fixtures.ts | 127 +++-- .../txe/src/oracle/test-resolver/resolver.ts | 4 +- .../txe/src/oracle/txe_oracle_registry.ts | 58 ++- .../txe/src/oracle/txe_oracle_version.ts | 4 +- 28 files changed, 622 insertions(+), 972 deletions(-) delete mode 100644 yarn-project/pxe/src/contract_function_simulator/noir-structs/resolved_tx.test.ts create mode 100644 yarn-project/txe/src/oracle/oracle_kinds.test.ts diff --git a/noir-projects/aztec-nr/aztec/src/oracle/mod.nr b/noir-projects/aztec-nr/aztec/src/oracle/mod.nr index c84420c7fac0..a37ac15b6fa1 100644 --- a/noir-projects/aztec-nr/aztec/src/oracle/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/oracle/mod.nr @@ -22,13 +22,7 @@ use crate::macros::oracle_testing::{generate_oracle_tests, generate_oracle_tests ], )] 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 }, - ], -)] +#[generate_oracle_tests] pub mod aes128_decrypt; #[generate_oracle_tests] pub mod auth_witness; diff --git a/noir-projects/aztec-nr/aztec/src/oracle/tx_resolution.nr b/noir-projects/aztec-nr/aztec/src/oracle/tx_resolution.nr index b288f4897e07..37f0e2e68985 100644 --- a/noir-projects/aztec-nr/aztec/src/oracle/tx_resolution.nr +++ b/noir-projects/aztec-nr/aztec/src/oracle/tx_resolution.nr @@ -18,104 +18,3 @@ pub(crate) unconstrained fn get_resolved_txs(requests: EphemeralArray) -> #[oracle(aztec_utl_getResolvedTxs)] unconstrained fn get_resolved_txs_oracle(requests: EphemeralArray) -> EphemeralArray> {} - -mod test { - use crate::oracle::tx_resolution::ResolvedTx; - use crate::protocol::traits::Deserialize; - - #[test] - unconstrained fn resolved_tx_serialization_matches_typescript() { - // Setup test data - let tx_hash = 123; - let unique_note_hashes = BoundedVec::from_array([4, 5]); - let first_nullifier = 6; - let block_number: u32 = 7; - let block_hash = 8; - - // Create a ResolvedTx instance - let resolved_tx = ResolvedTx { - tx_hash, - unique_note_hashes_in_tx: unique_note_hashes, - first_nullifier_in_tx: first_nullifier, - block_number, - block_hash, - }; - - // Expected output generated from TypeScript's `ResolvedTx.toFields()` - let serialized_resolved_tx_from_typescript = [ - 0x000000000000000000000000000000000000000000000000000000000000007b, - 0x0000000000000000000000000000000000000000000000000000000000000004, - 0x0000000000000000000000000000000000000000000000000000000000000005, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000002, - 0x0000000000000000000000000000000000000000000000000000000000000006, - 0x0000000000000000000000000000000000000000000000000000000000000007, - 0x0000000000000000000000000000000000000000000000000000000000000008, - ]; - - let deserialized = ResolvedTx::deserialize(serialized_resolved_tx_from_typescript); - - assert_eq(deserialized, resolved_tx); - } -} diff --git a/yarn-project/pxe/src/bin/check_oracle_version.ts b/yarn-project/pxe/src/bin/check_oracle_version.ts index 0475415dd69e..3310ccbe814b 100644 --- a/yarn-project/pxe/src/bin/check_oracle_version.ts +++ b/yarn-project/pxe/src/bin/check_oracle_version.ts @@ -3,6 +3,7 @@ import { keccak256String } from '@aztec/foundation/crypto/keccak'; import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; +import { ORACLE_REGISTRY } from '../contract_function_simulator/index.js'; import { ORACLE_INTERFACE_HASH, ORACLE_VERSION_MAJOR } from '../oracle_version.js'; import { getOracleRegistrySignature, readNumericGlobal } from './oracle_version_helpers.js'; @@ -10,7 +11,7 @@ import { getOracleRegistrySignature, readNumericGlobal } from './oracle_version_ * Verifies that the Oracle interface matches the expected interface hash. * * The Oracle interface needs to be versioned to ensure compatibility between Aztec.nr and PXE. This function computes - * a hash of the `ORACLE_REGISTRY` declaration (where each oracle's parameter names, parameter types, and return type + * a hash of `ORACLE_REGISTRY` (where each oracle's parameter names, parameter types, and return type * live) and compares it against a known hash. If they don't match, it means the interface has changed and the oracle * version needs to be bumped: * - If the change is backward-breaking (e.g. removing/renaming an oracle, or changing its params/return), bump @@ -18,16 +19,8 @@ import { getOracleRegistrySignature, readNumericGlobal } from './oracle_version_ * - If the change is an oracle addition (non-breaking), bump ORACLE_VERSION_MINOR. */ function assertOracleInterfaceMatches(): void { - // The script runs from dest/bin/ after compilation, so we go up to the package root then into src/ to find - // the source file. - const currentDir = dirname(fileURLToPath(import.meta.url)); - const packageRoot = dirname(dirname(currentDir)); // Go up from bin/ to pxe/ - const registrySourcePath = join(packageRoot, 'src/contract_function_simulator/oracle/oracle_registry.ts'); - - const oracleInterfaceSignature = getOracleRegistrySignature(registrySourcePath, 'ORACLE_REGISTRY'); - // We use keccak256 here just because we already have it in the dependencies. - const oracleInterfaceHash = keccak256String(oracleInterfaceSignature); + const oracleInterfaceHash = keccak256String(getOracleRegistrySignature(ORACLE_REGISTRY)); if (oracleInterfaceHash !== ORACLE_INTERFACE_HASH) { throw new Error( `The Oracle interface has changed. Update ORACLE_INTERFACE_HASH to ${oracleInterfaceHash} in pxe/src/oracle_version.ts and bump the oracle version (ORACLE_VERSION_MAJOR for breaking changes, ORACLE_VERSION_MINOR for oracle additions).`, diff --git a/yarn-project/pxe/src/bin/index.ts b/yarn-project/pxe/src/bin/index.ts index 44df032436f1..1acfa9e43f3f 100644 --- a/yarn-project/pxe/src/bin/index.ts +++ b/yarn-project/pxe/src/bin/index.ts @@ -1,5 +1 @@ -export { - getOracleInterfaceSignature, - getOracleRegistrySignature, - readNumericGlobal, -} from './oracle_version_helpers.js'; +export { getOracleRegistrySignature, readNumericGlobal } from './oracle_version_helpers.js'; diff --git a/yarn-project/pxe/src/bin/oracle_version_helpers.test.ts b/yarn-project/pxe/src/bin/oracle_version_helpers.test.ts index c2ea6c81f63c..ddbe2bb749fe 100644 --- a/yarn-project/pxe/src/bin/oracle_version_helpers.test.ts +++ b/yarn-project/pxe/src/bin/oracle_version_helpers.test.ts @@ -1,7 +1,10 @@ +/* eslint-disable camelcase */ import { mkdtempSync, rmSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; +import { ARRAY, AZTEC_ADDRESS, BOOL, FIELD, OPTION, U32, makeEntry } from '../contract_function_simulator/index.js'; +import { TX_HASH } from '../contract_function_simulator/oracle/oracle_type_mappings.js'; import { getOracleRegistrySignature, readNumericGlobal } from './oracle_version_helpers.js'; describe('readNumericGlobal', () => { @@ -51,17 +54,7 @@ describe('readNumericGlobal', () => { }); describe('getOracleRegistrySignature', () => { - let dir: string; - - beforeAll(() => { - dir = mkdtempSync(join(tmpdir(), 'oracle-registry-')); - }); - - afterAll(() => { - rmSync(dir, { recursive: true, force: true }); - }); - - const SAMPLE_REGISTRY = `export const ORACLE_REGISTRY = { + const SAMPLE_REGISTRY = { aztec_utl_foo: makeEntry({ params: [ { name: 'a', type: U32 }, @@ -72,54 +65,42 @@ describe('getOracleRegistrySignature', () => { aztec_utl_bar: makeEntry({ returnType: FIELD }), aztec_prv_baz: makeEntry({ params: [{ name: 'x', type: FIELD }] }), aztec_prv_qux: makeEntry(), - } satisfies Record; -`; + }; it('builds a sorted signature of names, ordered typed params, and return types', () => { - const path = writeFixture(dir, 'registry.ts', SAMPLE_REGISTRY); - expect(getOracleRegistrySignature(path, 'ORACLE_REGISTRY')).toBe( - 'aztec_prv_baz(x: FIELD): void\n' + + expect(getOracleRegistrySignature(SAMPLE_REGISTRY)).toBe( + 'aztec_prv_baz(x: field): void\n' + 'aztec_prv_qux(): void\n' + - 'aztec_utl_bar(): FIELD\n' + - 'aztec_utl_foo(a: U32, b: OPTION(AZTEC_ADDRESS)): BOOL', + 'aztec_utl_bar(): field\n' + + 'aztec_utl_foo(a: u32, b: option(aztec-address)): bool', ); }); it('changes when a parameter type changes (the gap the Oracle-class hash missed)', () => { - const before = writeFixture(dir, 'before.ts', SAMPLE_REGISTRY); - const after = writeFixture(dir, 'after.ts', SAMPLE_REGISTRY.replace('type: OPTION(AZTEC_ADDRESS)', 'type: FIELD')); - expect(getOracleRegistrySignature(after, 'ORACLE_REGISTRY')).not.toBe( - getOracleRegistrySignature(before, 'ORACLE_REGISTRY'), - ); + const after = { + ...SAMPLE_REGISTRY, + aztec_utl_foo: makeEntry({ + params: [ + { name: 'a', type: U32 }, + { name: 'b', type: FIELD }, + ], + returnType: BOOL, + }), + }; + expect(getOracleRegistrySignature(after)).not.toBe(getOracleRegistrySignature(SAMPLE_REGISTRY)); }); - it('is insensitive to formatting of the type expressions', () => { - const reformatted = writeFixture( - dir, - 'reformatted.ts', - SAMPLE_REGISTRY.replace('OPTION(AZTEC_ADDRESS)', 'OPTION(\n AZTEC_ADDRESS\n )'), - ); - const original = writeFixture(dir, 'original.ts', SAMPLE_REGISTRY); - expect(getOracleRegistrySignature(reformatted, 'ORACLE_REGISTRY')).toBe( - getOracleRegistrySignature(original, 'ORACLE_REGISTRY'), - ); - }); - - it('throws on spread members, which are not yet supported', () => { - const path = writeFixture( - dir, - 'spread.ts', - `export const ORACLE_REGISTRY = { - ...BASE_REGISTRY, - aztec_utl_foo: makeEntry({ returnType: FIELD }), - } satisfies Record;\n`, - ); - expect(() => getOracleRegistrySignature(path, 'ORACLE_REGISTRY')).toThrow(/Spread elements are not supported/); + it('does not change when a mapping is swapped for a wire-equivalent one', () => { + const withField = { aztec_utl_foo: makeEntry({ params: [{ name: 'a', type: FIELD }] }) }; + const withTxHash = { aztec_utl_foo: makeEntry({ params: [{ name: 'a', type: TX_HASH }] }) }; + expect(getOracleRegistrySignature(withTxHash)).toBe(getOracleRegistrySignature(withField)); }); - it('throws when the registry is absent', () => { - const path = writeFixture(dir, 'absent.ts', `export const SOMETHING_ELSE = {};\n`); - expect(() => getOracleRegistrySignature(path, 'ORACLE_REGISTRY')).toThrow(/Could not find oracle registry/); + it('captures nested composite kinds in the signature', () => { + const registry = { + aztec_utl_foo: makeEntry({ params: [{ name: 'a', type: OPTION(ARRAY(FIELD)) }], returnType: AZTEC_ADDRESS }), + }; + expect(getOracleRegistrySignature(registry)).toBe('aztec_utl_foo(a: option(array(field))): aztec-address'); }); }); diff --git a/yarn-project/pxe/src/bin/oracle_version_helpers.ts b/yarn-project/pxe/src/bin/oracle_version_helpers.ts index 00bdbcbb7979..ee5640230c9c 100644 --- a/yarn-project/pxe/src/bin/oracle_version_helpers.ts +++ b/yarn-project/pxe/src/bin/oracle_version_helpers.ts @@ -1,75 +1,13 @@ -/* eslint-disable import-x/no-named-as-default-member */ import { readFileSync } from 'fs'; -import ts from 'typescript'; -/** - * Extracts method signatures from TypeScript classes or interfaces and returns a deterministic string representation. - * - * This is used to detect when an oracle interface changes so that the oracle version can be bumped. It works with both - * class declarations (e.g. PXE's `Oracle` class) and interface declarations (e.g. TXE's `IAvmExecutionOracle`). - * - * @param sourcePath - Absolute path to the TypeScript source file to parse. - * @param targets - Names of classes or interfaces to extract methods from. - * @param excludedMembers - Method names to skip (e.g. non-oracle helpers like `constructor`). - */ -export function getOracleInterfaceSignature(sourcePath: string, targets: string[], excludedMembers: string[]): string { - const sourceCode = readFileSync(sourcePath, 'utf-8'); - const sourceFile = ts.createSourceFile(sourcePath, sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); - - const methodSignatures: string[] = []; - - function visit(node: ts.Node) { - const isTarget = - (ts.isClassDeclaration(node) || ts.isInterfaceDeclaration(node)) && targets.includes(node.name?.text ?? ''); - - if (isTarget) { - node.members.forEach(member => { - if ( - (ts.isMethodDeclaration(member) || ts.isMethodSignature(member)) && - member.name && - ts.isIdentifier(member.name) - ) { - const methodName = member.name.text; - - if (excludedMembers.includes(methodName)) { - return; - } - - const paramSignatures: string[] = []; - member.parameters.forEach(param => { - const paramName = extractParameterName(param, sourceFile); - const paramType = extractTypeString(param.type, sourceFile); - paramSignatures.push(`${paramName}: ${paramType}`); - }); - - const returnType = extractTypeString(member.type, sourceFile); - - const signature = `${methodName}(${paramSignatures.join(', ')}): ${returnType}`; - methodSignatures.push(signature); - } - }); - } - - ts.forEachChild(node, visit); - } - - visit(sourceFile); - - methodSignatures.sort(); - - return methodSignatures.join(''); -} +import type { OracleRegistryEntry } from '../contract_function_simulator/index.js'; /** - * Extracts a deterministic signature string from an oracle registry object literal (e.g. PXE's `ORACLE_REGISTRY`). - * - * Reads the registry declaration where each oracle's wire ABI lives: the ordered parameter names with their - * `TypeMapping` expressions and the return type. The resulting hash is sensitive to parameter, type, and return - * changes, not just oracle additions and removals. + * Extracts a deterministic signature string from an oracle registry (e.g. PXE's `ORACLE_REGISTRY`). * - * Type expressions are captured as their source text (e.g. `OPTION(AZTEC_ADDRESS)`, `BOUNDED_VEC(NOTE)`), so the - * signature tracks the composition of types. However, if the internal serialization logic of a `TypeMapping` constant - * (e.g. `FIELD`) changes without the constant being renamed, the hash will not change. + * Reads the registry where each oracle's wire ABI lives: the ordered parameter names with their `TypeMapping` labels + * and the return type. The resulting hash is sensitive to parameter, type, and return changes, not just oracle + * additions and removals. * * @example * // Given a registry like: @@ -79,32 +17,13 @@ export function getOracleInterfaceSignature(sourcePath: string, targets: string[ * // } satisfies Record; * // * // Returns (sorted, newline-joined): - * // "aztec_prv_bar(): void\naztec_utl_foo(a: U32): BOOL" - * - * @param sourcePath - Absolute path to the TypeScript source file declaring the registry. - * @param registryName - Name of the registry constant to read (e.g. `ORACLE_REGISTRY`). + * // "aztec_prv_bar(): void\naztec_utl_foo(a: u32): bool" */ -export function getOracleRegistrySignature(sourcePath: string, registryName: string): string { - const sourceCode = readFileSync(sourcePath, 'utf-8'); - const sourceFile = ts.createSourceFile(sourcePath, sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); - - const registry = findObjectLiteral(sourceFile, registryName); - if (!registry) { - throw new Error(`Could not find oracle registry '${registryName}' in ${sourcePath}.`); - } - - const oracleSignatures = registry.properties.map(property => { - if (!ts.isPropertyAssignment(property)) { - throw new Error( - `Unexpected member in oracle registry '${registryName}': ${property.getText(sourceFile)}. Spread elements ` + - `are not supported.`, - ); - } - - const oracleName = getPropertyName(property.name, sourceFile); - const { params, returnType } = extractRegistryEntry(property.initializer, sourceFile); - const paramSignatures = params.map(param => `${param.name}: ${param.type}`); - return `${oracleName}(${paramSignatures.join(', ')}): ${returnType}`; +export function getOracleRegistrySignature(registry: Record): string { + const oracleSignatures = Object.entries(registry).map(([name, entry]) => { + const paramSignatures = entry.params.map(p => `${p.name}: ${p.type.label}`); + const returnType = entry.returnType === undefined ? 'void' : entry.returnType.label; + return `${name}(${paramSignatures.join(', ')}): ${returnType}`; }); oracleSignatures.sort(); @@ -133,179 +52,3 @@ export function readNumericGlobal(sourcePath: string, name: string): number { } return Number(match[1]); } - -function extractParameterName(param: ts.ParameterDeclaration, sourceFile: ts.SourceFile): string { - const name = param.name; - - if (ts.isIdentifier(name)) { - return name.text; - } - - if (ts.isArrayBindingPattern(name)) { - if (name.elements.length > 0) { - const element = name.elements[0]; - if (ts.isBindingElement(element)) { - const elementName = element.name; - if (ts.isIdentifier(elementName)) { - return elementName.text; - } - if (ts.isArrayBindingPattern(elementName) || ts.isObjectBindingPattern(elementName)) { - return elementName.getText(sourceFile); - } - } - } - return name.getText(sourceFile); - } - - if (ts.isObjectBindingPattern(name)) { - return name.getText(sourceFile); - } - - return (name as ts.Node).getText(sourceFile); -} - -/** Returns the source text of a type annotation, or `'void'` if absent. */ -function extractTypeString(typeNode: ts.TypeNode | undefined, sourceFile: ts.SourceFile): string { - if (!typeNode) { - return 'void'; - } - - return typeNode.getText(sourceFile).replace(/\s+/g, ' ').trim(); -} - -/** - * Finds a top-level object-literal-valued constant by name, unwrapping `satisfies`/`as`/parenthesized wrappers. - * - * @example - * // `const REGISTRY = { ... } satisfies Record` => the `{ ... }` ObjectLiteralExpression - */ -function findObjectLiteral(sourceFile: ts.SourceFile, name: string): ts.ObjectLiteralExpression | undefined { - let result: ts.ObjectLiteralExpression | undefined; - - function visit(node: ts.Node) { - if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === name && node.initializer) { - result = unwrapObjectLiteral(node.initializer); - } - ts.forEachChild(node, visit); - } - - visit(sourceFile); - return result; -} - -/** Peels `satisfies`, `as`, and parenthesized wrappers off an expression to reach the underlying object literal. */ -function unwrapObjectLiteral(node: ts.Expression): ts.ObjectLiteralExpression | undefined { - let current = node; - while (ts.isSatisfiesExpression(current) || ts.isAsExpression(current) || ts.isParenthesizedExpression(current)) { - current = current.expression; - } - return ts.isObjectLiteralExpression(current) ? current : undefined; -} - -/** - * Extracts the ordered `params` (names + type expressions) and `returnType` from a `makeEntry({ ... })` call. - * - * @example - * // makeEntry({ params: [{ name: 'a', type: OPTION(FIELD) }], returnType: BOOL }) - * // => { params: [{ name: 'a', type: 'OPTION(FIELD)' }], returnType: 'BOOL' } - * // - * // makeEntry() - * // => { params: [], returnType: 'void' } - */ -function extractRegistryEntry( - initializer: ts.Expression, - sourceFile: ts.SourceFile, -): { params: { name: string; type: string }[]; returnType: string } { - if (!ts.isCallExpression(initializer)) { - throw new Error(`Expected a makeEntry(...) call but got: ${initializer.getText(sourceFile)}`); - } - - const [arg] = initializer.arguments; - if (arg === undefined) { - return { params: [], returnType: 'void' }; - } - if (!ts.isObjectLiteralExpression(arg)) { - throw new Error(`Expected a makeEntry object argument but got: ${arg.getText(sourceFile)}`); - } - - let params: { name: string; type: string }[] = []; - let returnType = 'void'; - - arg.properties.forEach(property => { - if (!ts.isPropertyAssignment(property)) { - throw new Error(`Unexpected makeEntry property: ${property.getText(sourceFile)}`); - } - const key = getPropertyName(property.name, sourceFile); - if (key === 'params') { - params = extractRegistryParams(property.initializer, sourceFile); - } else if (key === 'returnType') { - returnType = normalizeExpressionText(property.initializer, sourceFile); - } else { - throw new Error(`Unexpected makeEntry property '${key}'.`); - } - }); - - return { params, returnType }; -} - -/** - * Extracts `{ name, type }` pairs from the `params` array literal inside a `makeEntry` call. - */ -function extractRegistryParams( - initializer: ts.Expression, - sourceFile: ts.SourceFile, -): { name: string; type: string }[] { - if (!ts.isArrayLiteralExpression(initializer)) { - throw new Error(`Expected a params array but got: ${initializer.getText(sourceFile)}`); - } - - return initializer.elements.map(element => { - if (!ts.isObjectLiteralExpression(element)) { - throw new Error(`Expected a param object but got: ${element.getText(sourceFile)}`); - } - - let name: string | undefined; - let type: string | undefined; - element.properties.forEach(property => { - if (!ts.isPropertyAssignment(property)) { - throw new Error(`Unexpected param property: ${property.getText(sourceFile)}`); - } - const key = getPropertyName(property.name, sourceFile); - if (key === 'name') { - if (!ts.isStringLiteralLike(property.initializer)) { - throw new Error(`Expected a string literal param name but got: ${property.initializer.getText(sourceFile)}`); - } - name = property.initializer.text; - } else if (key === 'type') { - type = normalizeExpressionText(property.initializer, sourceFile); - } else { - throw new Error(`Unexpected param property '${key}'.`); - } - }); - - if (name === undefined || type === undefined) { - throw new Error(`Param missing 'name' or 'type': ${element.getText(sourceFile)}`); - } - return { name, type }; - }); -} - -/** Returns the text of an identifier or string-literal property name, throwing on computed or numeric names. */ -function getPropertyName(name: ts.PropertyName, sourceFile: ts.SourceFile): string { - if (ts.isIdentifier(name) || ts.isStringLiteralLike(name)) { - return name.text; - } - throw new Error(`Unsupported property name: ${name.getText(sourceFile)}`); -} - -/** - * Returns the source text of an expression with all whitespace stripped for format-insensitive comparison. - * - * @example - * // `OPTION(\n AZTEC_ADDRESS\n)` => `"OPTION(AZTEC_ADDRESS)"` - */ -function normalizeExpressionText(node: ts.Expression, sourceFile: ts.SourceFile): string { - // Type expressions are TypeMapping references and calls (identifiers, parentheses, commas, numbers) with no - // meaningful whitespace, so we strip it entirely to keep the signature stable across reformatting. - return node.getText(sourceFile).replace(/\s+/g, ''); -} diff --git a/yarn-project/pxe/src/contract_function_simulator/index.ts b/yarn-project/pxe/src/contract_function_simulator/index.ts index 1453126d9d15..64d3cde6661f 100644 --- a/yarn-project/pxe/src/contract_function_simulator/index.ts +++ b/yarn-project/pxe/src/contract_function_simulator/index.ts @@ -6,6 +6,7 @@ export { type ParamTypes, } from './oracle/oracle_registry.js'; export { + ALIAS, ARRAY, AZTEC_ADDRESS, BIGINT, @@ -13,8 +14,7 @@ export { BLOCK_NUMBER, BOOL, BOUNDED_VEC, - BUFFER, - BYTE, + U8, CONTRACT_INSTANCE, DELIVERY_MODE, EPHEMERAL_ARRAY, @@ -23,6 +23,8 @@ export { FIELD, FIXED_ARRAY, FUNCTION_SELECTOR, + LEAF, + LEAF_INDEX, LOG_RETRIEVAL_REQUEST, LOG_RETRIEVAL_RESPONSE, MEMBERSHIP_WITNESS, @@ -32,16 +34,34 @@ export { PENDING_TAGGED_LOG, POINT, PROVIDED_SECRET, + SCALAR, SLOT_NUMBER, STR, STRUCT, U32, + U64, + U128, tryFieldWidth, + isArrayMapping, + isBoundedVecMapping, + isEphemeralArrayMapping, + isFixedArrayMapping, + isFixedBoundedVecMapping, + isOptionMapping, + isStructMapping, + type ArrayMapping, + type BoundedVecMapping, + type CompositeMapping, + type EphemeralArrayMapping, + type FixedArrayMapping, + type FixedBoundedVecMapping, type InputSlot, type MaybePromise, + type OptionMapping, type OutputSlot, type SlotShape, type StructField, + type StructMapping, type TypeMapping, } from './oracle/oracle_type_mappings.js'; export { ExecutionNoteCache } from './execution_note_cache.js'; @@ -74,5 +94,5 @@ export { NoteValidationRequest } from './noir-structs/note_validation_request.js export type { PendingTaggedLog } from './noir-structs/pending_tagged_log.js'; export type { TxEffectData } from './noir-structs/tx_effect_data.js'; export type { ProvidedSecret } from './noir-structs/provided_secret.js'; -export { ResolvedTx } from './noir-structs/resolved_tx.js'; +export type { ResolvedTx } from './noir-structs/resolved_tx.js'; export { TransientArrayService } from './transient_array_service.js'; diff --git a/yarn-project/pxe/src/contract_function_simulator/noir-structs/resolved_tx.test.ts b/yarn-project/pxe/src/contract_function_simulator/noir-structs/resolved_tx.test.ts deleted file mode 100644 index ce4aba29c4cc..000000000000 --- a/yarn-project/pxe/src/contract_function_simulator/noir-structs/resolved_tx.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { Fr } from '@aztec/foundation/curves/bn254'; -import { updateInlineTestData } from '@aztec/foundation/testing/files'; -import { TxHash } from '@aztec/stdlib/tx'; - -import { ResolvedTx } from './resolved_tx.js'; - -describe('ResolvedTx', () => { - it('serialization matches snapshots and output of Noir serialization', () => { - // Setup test data - const txHash = new TxHash(new Fr(123n)); - const uniqueNoteHashes = [new Fr(4n), new Fr(5n)]; - const firstNullifier = new Fr(6n); - const blockNumber = 7; - const blockHash = new Fr(8n); - - // Create a ResolvedTx instance - const resolvedTx = new ResolvedTx(txHash, uniqueNoteHashes, firstNullifier, blockNumber, blockHash); - - // Serialize the resolved tx - const serialized = resolvedTx.toFields(); - - // Compare with snapshot - expect(serialized.map(f => f.toString())).toMatchInlineSnapshot(` - [ - "0x000000000000000000000000000000000000000000000000000000000000007b", - "0x0000000000000000000000000000000000000000000000000000000000000004", - "0x0000000000000000000000000000000000000000000000000000000000000005", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000002", - "0x0000000000000000000000000000000000000000000000000000000000000006", - "0x0000000000000000000000000000000000000000000000000000000000000007", - "0x0000000000000000000000000000000000000000000000000000000000000008", - ] - `); - - // Run with AZTEC_GENERATE_TEST_DATA=1 to update noir test data - const fieldArrayStr = `[${serialized.map(f => f.toString()).join(',')}]`; - updateInlineTestData( - 'noir-projects/aztec-nr/aztec/src/oracle/tx_resolution.nr', - 'serialized_resolved_tx_from_typescript', - fieldArrayStr, - ); - }); -}); diff --git a/yarn-project/pxe/src/contract_function_simulator/noir-structs/resolved_tx.ts b/yarn-project/pxe/src/contract_function_simulator/noir-structs/resolved_tx.ts index 0da90f0a157b..b3997b60d2af 100644 --- a/yarn-project/pxe/src/contract_function_simulator/noir-structs/resolved_tx.ts +++ b/yarn-project/pxe/src/contract_function_simulator/noir-structs/resolved_tx.ts @@ -1,7 +1,5 @@ -import { MAX_NOTE_HASHES_PER_TX } from '@aztec/constants'; -import { padArrayEnd } from '@aztec/foundation/collection'; -import { Fr } from '@aztec/foundation/curves/bn254'; -import { TxHash } from '@aztec/stdlib/tx'; +import type { Fr } from '@aztec/foundation/curves/bn254'; +import type { TxHash } from '@aztec/stdlib/tx'; /** * The resolved on-chain context of a transaction. @@ -9,45 +7,13 @@ import { TxHash } from '@aztec/stdlib/tx'; * Carries the note hashes and first nullifier needed to discover notes that originated from the transaction, plus the * number and hash of the block in which it was mined. * - * A TS version of the `ResolvedTx` struct in `oracle/tx_resolution.nr`. + * A TS version of the `ResolvedTx` struct in `oracle/tx_resolution.nr`; its wire layout lives in the `RESOLVED_TX` + * type mapping. */ -export class ResolvedTx { - constructor( - public txHash: TxHash, - public uniqueNoteHashesInTx: Fr[], - public firstNullifierInTx: Fr, - public blockNumber: number, - public blockHash: Fr, - ) {} - - toFields(): Fr[] { - return [ - this.txHash.hash, - ...serializeBoundedVec(this.uniqueNoteHashesInTx, MAX_NOTE_HASHES_PER_TX), - this.firstNullifierInTx, - new Fr(this.blockNumber), - this.blockHash, - ]; - } - - static empty(): ResolvedTx { - return new ResolvedTx(TxHash.zero(), [], Fr.ZERO, 0, Fr.ZERO); - } -} - -/** - * Helper function to serialize a bounded vector according to Noir's BoundedVec format: the storage array padded to - * `maxLength`, followed by the actual length. - * @param values - The values to serialize - * @param maxLength - The maximum length of the bounded vector - * @returns The serialized bounded vector as Fr[] - */ -function serializeBoundedVec(values: Fr[], maxLength: number): Fr[] { - const storage = padArrayEnd( - values, - Fr.ZERO, - maxLength, - `Attempted to serialize ${values} values into a BoundedVec with max length ${maxLength}`, - ); - return [...storage, new Fr(values.length)]; -} +export type ResolvedTx = { + txHash: TxHash; + uniqueNoteHashesInTx: Fr[]; + firstNullifierInTx: Fr; + blockNumber: number; + blockHash: Fr; +}; diff --git a/yarn-project/pxe/src/contract_function_simulator/noir-structs/tx_effect_data.ts b/yarn-project/pxe/src/contract_function_simulator/noir-structs/tx_effect_data.ts index 11b131d97894..d814fbe50957 100644 --- a/yarn-project/pxe/src/contract_function_simulator/noir-structs/tx_effect_data.ts +++ b/yarn-project/pxe/src/contract_function_simulator/noir-structs/tx_effect_data.ts @@ -6,10 +6,11 @@ import type { ContractClassLogData } from './contract_class_log_data.js'; /** * Wire form of a {@link TxEffect} as the `getTxEffect` oracle returns it: identical to the domain type except its logs - * are flattened to their Noir layout. Keeping the conversion in the handler lets the `TX_EFFECT` mapping stay purely - * structural. + * are flattened to their Noir layout and its revert code is the plain `u8` Noir declares. Keeping the conversion in + * the handler lets the `TX_EFFECT` mapping stay purely structural. */ -export type TxEffectData = Omit, 'publicLogs' | 'contractClassLogs'> & { +export type TxEffectData = Omit, 'revertCode' | 'publicLogs' | 'contractClassLogs'> & { + revertCode: number; publicLogs: FlatPublicLogs; contractClassLogs: ContractClassLogData[]; }; 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 09c53de0730b..cff2615f8a6b 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 @@ -13,8 +13,6 @@ import { BLOCK_NUMBER, BOOL, BOUNDED_VEC, - BUFFER, - BYTE, CALL_PRIVATE_RESULT, CONTRACT_CLASS_LOG, CONTRACT_INSTANCE, @@ -23,6 +21,7 @@ import { EVENT_VALIDATION_REQUEST, FACT_COLLECTION, FIELD, + FIXED_ARRAY, FUNCTION_SELECTOR, type InputSlot, KEY_VALIDATION_REQUEST, @@ -50,6 +49,7 @@ import { TX_EFFECT, TX_HASH, type TypeMapping, + U8, U32, UTILITY_CONTEXT, assertReadersConsumed, @@ -63,8 +63,7 @@ export { BLOCK_NUMBER, BOOL, BOUNDED_VEC, - BUFFER, - BYTE, + U8, CONTRACT_CLASS_LOG, DELIVERY_MODE, RESOLVED_TAGGING_STRATEGY, @@ -317,11 +316,11 @@ export const ORACLE_REGISTRY = { aztec_utl_decryptAes128: makeEntry({ params: [ - { name: 'ciphertext', type: BOUNDED_VEC(BYTE) }, - { name: 'iv', type: BUFFER(8, 16) }, - { name: 'symKey', type: BUFFER(8, 16) }, + { name: 'ciphertext', type: BOUNDED_VEC(U8) }, + { name: 'iv', type: FIXED_ARRAY(U8, 16) }, + { name: 'symKey', type: FIXED_ARRAY(U8, 16) }, ], - returnType: OPTION(BOUNDED_VEC(BYTE)), + returnType: OPTION(BOUNDED_VEC(U8)), }), aztec_utl_getSharedSecrets: makeEntry({ diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_type_mappings.test.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_type_mappings.test.ts index 23ca2fbc3108..56cbc494e5ac 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_type_mappings.test.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_type_mappings.test.ts @@ -13,7 +13,6 @@ import { ARRAY, AZTEC_ADDRESS, BOUNDED_VEC, - BYTE, DELIVERY_MODE, EPHEMERAL_ARRAY, FIELD, @@ -21,14 +20,15 @@ import { MEMBERSHIP_WITNESS, OPTION, POINT, - PROVIDED_SECRET, RESOLVED_TAGGING_STRATEGY, type SlotShape, type TypeMapping, + U8, U32, makeEntry, slotsOf, } from './oracle_registry.js'; +import { FIXED_ARRAY, FIXED_BOUNDED_VEC, LEAF, SCALAR, STRUCT, TX_HASH } from './oracle_type_mappings.js'; /** * Tests for the oracle type mappings: how the PXE encodes values to, and decodes them from, the flat field arrays that @@ -63,21 +63,21 @@ describe('oracle type mappings', () => { }); it('rejects a value above the maximum', () => { - expect(() => deserialize(U32, new Fr(0x100000000n))).toThrow('U32 overflow'); + expect(() => deserialize(U32, new Fr(0x100000000n))).toThrow('u32 overflow'); }); }); - describe('BYTE', () => { + describe('U8', () => { it('deserializes a value in range', () => { - expect(deserialize(BYTE, new Fr(0))).toBe(0); + expect(deserialize(U8, new Fr(0))).toBe(0); }); it('deserializes the maximum value', () => { - expect(deserialize(BYTE, new Fr(255))).toBe(255); + expect(deserialize(U8, new Fr(255))).toBe(255); }); it('rejects a value above the maximum', () => { - expect(() => deserialize(BYTE, new Fr(256))).toThrow('BYTE overflow'); + expect(() => deserialize(U8, new Fr(256))).toThrow('u8 overflow'); }); }); @@ -97,18 +97,6 @@ describe('oracle type mappings', () => { }); }); - describe('PROVIDED_SECRET', () => { - it('deserializes a secret and delivery mode from a two-field slot', () => { - const provided = PROVIDED_SECRET.deserialization!.fn([new FieldReader([new Fr(42), new Fr(3)])]); - expect(provided.secret).toEqual(new Fr(42)); - expect(provided.mode).toBe(AppTaggingSecretKind.CONSTRAINED); - }); - - it('declares the two-field wire shape', () => { - expect(PROVIDED_SECRET.shape).toEqual([{ len: 2 }]); - }); - }); - describe('RESOLVED_TAGGING_STRATEGY', () => { const secret = new Fr(42); @@ -162,7 +150,7 @@ describe('oracle type mappings', () => { Fr.ZERO, [Fr.ZERO, Fr.ZERO, Fr.ZERO], ]); - expect(OPTION(BOUNDED_VEC(BYTE)).serialization!.fn(Option.none>({ maxLength: 2 }))).toEqual([ + expect(OPTION(BOUNDED_VEC(U8)).serialization!.fn(Option.none>({ maxLength: 2 }))).toEqual([ Fr.ZERO, [Fr.ZERO, Fr.ZERO], Fr.ZERO, @@ -224,8 +212,8 @@ describe('oracle type mappings', () => { it('fully consumes a partially-full vec as an oracle param', () => { // The registry rejects a param whose slots aren't fully read. A partially-full vec leaves zero padding in its // storage slot, so this checks the deserializer drains that padding instead of tripping the consumption check. - const entry = makeEntry({ params: [{ name: 'ciphertext', type: BOUNDED_VEC(BYTE) }] }); - const inputs = toInputSlots(BOUNDED_VEC(BYTE).serialization!.fn(BoundedVec.from({ data: [1, 2], maxLength: 4 }))); + const entry = makeEntry({ params: [{ name: 'ciphertext', type: BOUNDED_VEC(U8) }] }); + const inputs = toInputSlots(BOUNDED_VEC(U8).serialization!.fn(BoundedVec.from({ data: [1, 2], maxLength: 4 }))); const [{ value }] = entry.deserializeParams(inputs); expect(value.data).toEqual([1, 2]); expect(value.maxLength).toBe(4); @@ -321,10 +309,36 @@ describe('oracle type mappings', () => { expect(shapeOf(witness.serialization!.fn(MembershipWitness.random(4)))).toEqual(witness.shape); }); }); + + describe('label', () => { + it('renders a scalar leaf as its kind', () => { + expect(FIELD.label).toBe('field'); + expect(TX_HASH.label).toBe('field'); + expect(AZTEC_ADDRESS.label).toBe('aztec-address'); + }); + + it('recurses through composites, including numeric generics', () => { + expect(OPTION(ARRAY(FIELD)).label).toBe('option(array(field))'); + expect(FIXED_ARRAY(FIELD, 4).label).toBe('array(field,4)'); + expect(FIXED_BOUNDED_VEC(AZTEC_ADDRESS, 8).label).toBe('bounded-vec(aztec-address,8)'); + expect(EPHEMERAL_ARRAY(FIELD).label).toBe('ephemeral-array(field)'); + }); + + it('renders a struct namelessly, splicing nested structs into the parent', () => { + const inner = STRUCT([{ name: 'blockNumber', type: U32 }]); + const struct = STRUCT([ + { name: 'txHash', type: TX_HASH }, + { name: 'origin', type: inner }, + { name: 'wrapped', type: OPTION(inner) }, + ]); + expect(struct.label).toBe('{field,u32,option({u32})}'); + }); + }); }); /** A field mapping that throws on a zero, proving a None skips its zero-padded inner instead of parsing it. */ -const REJECTS_ZERO_FIELD_TYPE: TypeMapping = { +const REJECTS_ZERO_FIELD_TYPE: TypeMapping = SCALAR({ + kind: 'field', serialization: { fn: v => [v] }, deserialization: { fn: ([reader]) => { @@ -335,14 +349,14 @@ const REJECTS_ZERO_FIELD_TYPE: TypeMapping = { return field; }, }, - shape: ['scalar'], -}; +}); /** A mapping whose shape claims two fields but whose fn reads only one, leaving a trailing field unread. */ -const UNDER_READS_SLOT_TYPE: TypeMapping = { +const UNDER_READS_SLOT_TYPE: TypeMapping = LEAF({ + kind: 'field', deserialization: { fn: ([reader]) => reader.readField() }, shape: [{ len: 2 }], -}; +}); /** Round-trips a value through a bidirectional mapping: serialize to wire slots, then deserialize back. */ function roundTrip(mapping: TypeMapping, value: T): T { @@ -356,7 +370,7 @@ function toInputSlots(slots: (Fr | Fr[])[]): string[][] { return slots.map(slot => (Array.isArray(slot) ? slot : [slot]).map(f => f.toString())); } -/** Deserializes a single-slot scalar mapping (U32, BYTE, ...) from one field. */ +/** Deserializes a single-slot scalar mapping (U32, U8, ...) from one field. */ function deserialize(mapping: TypeMapping, value: Fr): T { return mapping.deserialization!.fn([new FieldReader([value])]); } 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 2dc5ea63d7fd..7de4b4bff2ca 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 @@ -19,9 +19,9 @@ import { Fr } from '@aztec/foundation/curves/bn254'; import { EthAddress } from '@aztec/foundation/eth-address'; import { FieldReader } from '@aztec/foundation/serialize'; import { MembershipWitness, type SiblingPath } from '@aztec/foundation/trees'; -import { type ACVMField, fromUintArray } from '@aztec/simulator/client'; +import type { ACVMField } from '@aztec/simulator/client'; import { FunctionSelector, NoteSelector } from '@aztec/stdlib/abi'; -import { PublicDataWrite, RevertCode } from '@aztec/stdlib/avm'; +import { PublicDataWrite } from '@aztec/stdlib/avm'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; import { BlockHash } from '@aztec/stdlib/block'; import type { ContractInstancePreimage, PartialAddress } from '@aztec/stdlib/contract'; @@ -52,7 +52,12 @@ import { TxHash, } from '@aztec/stdlib/tx'; -import type { OriginBlock, RetractableFactOrigin } from '../../storage/fact_store/index.js'; +import { + type OriginBlock, + type OriginBlockState, + type RetractableFactOrigin, + originBlockStateFromNumber, +} from '../../storage/fact_store/index.js'; import { BoundedVec } from '../noir-structs/bounded_vec.js'; import type { ContractClassLogData } from '../noir-structs/contract_class_log_data.js'; import type { EmbeddedCurvePoint } from '../noir-structs/embedded_curve_point.js'; @@ -118,6 +123,23 @@ export type SlotShape = 'scalar' | { len: number } | 'variable' | { lenFrom: (si * Either side is optional — output-only types omit `deserialization`, input-only types omit `serialization`. */ export interface TypeMapping { + /** + * Discriminant for this mapping's shape: which scalar or combinator it is. The `is*Mapping` guards switch on it, and + * the combinators tag it (`'array'`, `'option'`, `'struct'`, ...). For a scalar it also names the Noir-declared type + * (`'field'`, `'u32'`), which is then its {@link label}. + */ + kind: string; + /** + * The wire-structural Noir type string the oracle interface hash is computed from: + * - a scalar's is its bare `kind` (`'field'`); + * - a combinator's is built from its inner mappings' labels (`'array(field,4)'`, `'option(u32)'`); + * - a struct's is a nameless `'{field,u32}'`, with nested struct labels spliced into the parent. + * + * The label encodes exactly what the wire does, so anything the wire tolerates (field names, struct-in-struct + * nesting, a TS wrapper over a Noir primitive like `TX_HASH` over `Field`) shares a label — and mappings sharing a + * label must be wire-equivalent. + */ + label: string; serialization?: { /** Convert a typed value to ACVM output slot(s). */ fn: (value: T) => (Fr | Fr[])[]; @@ -154,78 +176,91 @@ export function assertReadersConsumed(readers: FieldReader[]): void { // ─── Scalar Type Mappings ──────────────────────────────────────────────────── -export const FIELD: TypeMapping = { +/** + * A leaf mapping whose {@link TypeMapping.label} is just its bare {@link TypeMapping.kind}. Its wire shape is arbitrary + * (several fields, or a variable-width run); a single-field scalar uses the shorter {@link SCALAR} instead. + */ +export function LEAF(mapping: Omit, 'label'>): TypeMapping { + return { ...mapping, label: mapping.kind }; +} + +/** A {@link LEAF} occupying a single `'scalar'` wire slot, so callers omit the shape. */ +export function SCALAR(mapping: Omit, 'label' | 'shape'>): TypeMapping { + return LEAF({ ...mapping, shape: ['scalar'] }); +} + +export const FIELD: TypeMapping = SCALAR({ + kind: 'field', serialization: { fn: v => [v] }, deserialization: { fn: ([reader]) => reader.readField() }, - shape: ['scalar'], -}; +}); -export const BOOL: TypeMapping = { +export const BOOL: TypeMapping = SCALAR({ + kind: 'bool', serialization: { fn: v => [new Fr(v ? 1n : 0n)] }, deserialization: { fn: ([reader]) => !reader.readField().isZero() }, - shape: ['scalar'], -}; +}); -export const U32: TypeMapping = { +export const U32: TypeMapping = SCALAR({ + kind: 'u32', serialization: { fn: v => [new Fr(v)] }, - deserialization: { - fn: ([reader]) => { - const value = reader.readField().toBigInt(); - if (value > 0xffffffffn) { - throw new Error(`U32 overflow: value ${value} exceeds u32 max (${0xffffffffn})`); - } - return Number(value); - }, - }, - shape: ['scalar'], -}; + deserialization: { fn: ([reader]) => Number(uintFromField(reader.readField(), 32)) }, +}); -export const BLOCK_NUMBER: TypeMapping = { - serialization: { fn: v => [new Fr(v)] }, - deserialization: { fn: ([reader]) => BlockNumber(reader.readField().toNumber()) }, - shape: ['scalar'], -}; +export const BLOCK_NUMBER: TypeMapping = ALIAS(U32, { + wrap: v => BlockNumber(v), + unwrap: v => v, +}); -/** A u8 byte: serializes to a single Fr; deserializes from a single Fr to a number in [0, 255]. */ -export const BYTE: TypeMapping = { +export const U8: TypeMapping = SCALAR({ + kind: 'u8', serialization: { fn: byte => [new Fr(byte)] }, - deserialization: { - fn: ([reader]) => { - const value = reader.readField().toBigInt(); - if (value > 0xffn) { - throw new Error(`BYTE overflow: value ${value} exceeds u8 max (255)`); - } - return Number(value); - }, - }, - shape: ['scalar'], -}; + deserialization: { fn: ([reader]) => Number(uintFromField(reader.readField(), 8)) }, +}); // Noir passes `MessageDelivery` onchain variants here. -export const DELIVERY_MODE: TypeMapping = { +export const DELIVERY_MODE: TypeMapping = SCALAR({ + kind: 'onchain-delivery-mode', deserialization: { - fn: readers => appTaggingSecretKindFromDeliveryMode(BYTE.deserialization!.fn(readers)), + fn: readers => appTaggingSecretKindFromDeliveryMode(U8.deserialization!.fn(readers)), }, - shape: BYTE.shape, -}; +}); -export const RESOLVED_TAGGING_STRATEGY: TypeMapping = { +export const RESOLVED_TAGGING_STRATEGY: TypeMapping = LEAF({ + kind: 'resolved-tagging-strategy', serialization: { fn: resolved => resolvedTaggingStrategyToFields(resolved) }, deserialization: { fn: ([kindReader, secretReader]) => resolvedTaggingStrategyFromFields(kindReader.readField().toNumber(), secretReader.readField()), }, shape: ['scalar', 'scalar'], -}; +}); -export const BIGINT: TypeMapping = { +export const BIGINT: TypeMapping = ALIAS(FIELD, { + wrap: f => f.toBigInt(), + unwrap: v => new Fr(v), +}); + +export const U64: TypeMapping = SCALAR({ + kind: 'u64', serialization: { fn: v => [new Fr(v)] }, - deserialization: { fn: ([reader]) => reader.readField().toBigInt() }, - shape: ['scalar'], -}; + deserialization: { fn: ([reader]) => uintFromField(reader.readField(), 64) }, +}); + +export const U128: TypeMapping = SCALAR({ + kind: 'u128', + serialization: { fn: v => [new Fr(v)] }, + deserialization: { fn: ([reader]) => uintFromField(reader.readField(), 128) }, +}); + +export const LEAF_INDEX: TypeMapping = ALIAS(FIELD, { + wrap: f => f.toNumber(), + unwrap: v => new Fr(v), +}); /** Reads every field in the slot as a UTF-8 character code. */ -export const STR: TypeMapping = { +export const STR: TypeMapping = LEAF({ + kind: 'str', serialization: { fn: str => [Array.from(Buffer.from(str, 'utf-8')).map(b => new Fr(b))] }, deserialization: { fn: ([reader]) => { @@ -237,69 +272,64 @@ export const STR: TypeMapping = { }, }, shape: ['variable'], -}; +}); -export const AZTEC_ADDRESS: TypeMapping = { +export const AZTEC_ADDRESS: TypeMapping = SCALAR({ + kind: 'aztec-address', serialization: { fn: v => [v.toField()] }, deserialization: { fn: ([reader]) => AztecAddress.fromFieldUnsafe(reader.readField()) }, - shape: ['scalar'], -}; +}); -export const BLOCK_HASH: TypeMapping = { - serialization: { fn: v => [new Fr(v.toBuffer())] }, - deserialization: { fn: ([reader]) => new BlockHash(reader.readField()) }, - shape: ['scalar'], -}; +export const BLOCK_HASH: TypeMapping = ALIAS(FIELD, { + wrap: f => new BlockHash(f), + unwrap: v => new Fr(v.toBuffer()), +}); -export const FUNCTION_SELECTOR: TypeMapping = { +export const FUNCTION_SELECTOR: TypeMapping = SCALAR({ + kind: 'function-selector', serialization: { fn: v => [v.toField()] }, deserialization: { fn: ([reader]) => FunctionSelector.fromField(reader.readField()) }, - shape: ['scalar'], -}; +}); -export const NOTE_SELECTOR: TypeMapping = { - serialization: { fn: v => [v.toField()] }, - deserialization: { fn: ([reader]) => NoteSelector.fromField(reader.readField()) }, - shape: ['scalar'], -}; +export const NOTE_SELECTOR: TypeMapping = ALIAS(FIELD, { + wrap: f => NoteSelector.fromField(f), + unwrap: v => v.toField(), +}); -export const TX_HASH: TypeMapping = { - serialization: { fn: v => [v.hash] }, - deserialization: { fn: ([reader]) => TxHash.fromField(reader.readField()) }, - shape: ['scalar'], -}; +export const TX_HASH: TypeMapping = ALIAS(FIELD, { + wrap: f => TxHash.fromField(f), + unwrap: v => v.hash, +}); -const TAG: TypeMapping = { - serialization: { fn: v => [v.value] }, - deserialization: { fn: ([reader]) => new Tag(reader.readField()) }, - shape: ['scalar'], -}; +const TAG: TypeMapping = ALIAS(FIELD, { + wrap: f => new Tag(f), + unwrap: v => v.value, +}); export const POINT: TypeMapping = STRUCT([ { name: 'x', type: FIELD }, { name: 'y', type: FIELD }, ]); -const LOG_SOURCE: TypeMapping = { +const LOG_SOURCE: TypeMapping = SCALAR({ + kind: 'log-source', serialization: { fn: v => [new Fr(v)] }, deserialization: { fn: ([reader]) => logSourceFromField(reader.readField()) }, - shape: ['scalar'], -}; +}); -export const ETH_ADDRESS: TypeMapping = { +export const ETH_ADDRESS: TypeMapping = SCALAR({ + kind: 'eth-address', serialization: { fn: v => [v.toField()] }, deserialization: { fn: ([reader]) => EthAddress.fromField(reader.readField()) }, - shape: ['scalar'], -}; +}); -export const SLOT_NUMBER: TypeMapping = { - serialization: { fn: v => [new Fr(v)] }, - shape: ['scalar'], -}; +export const SLOT_NUMBER: TypeMapping = ALIAS(FIELD, { + unwrap: v => new Fr(v), +}); const APPEND_ONLY_TREE_SNAPSHOT: TypeMapping = STRUCT([ { name: 'root', type: FIELD }, - { name: 'nextAvailableLeafIndex', type: U32 }, + { name: 'nextAvailableLeafIndex', type: LEAF_INDEX }, ]); const PARTIAL_STATE_REFERENCE: TypeMapping = STRUCT([ @@ -314,8 +344,8 @@ const STATE_REFERENCE: TypeMapping = STRUCT([ ]); const GAS_FEES: TypeMapping = STRUCT([ - { name: 'feePerDaGas', type: BIGINT }, - { name: 'feePerL2Gas', type: BIGINT }, + { name: 'feePerDaGas', type: U128 }, + { name: 'feePerL2Gas', type: U128 }, ]); const GLOBAL_VARIABLES: TypeMapping = STRUCT([ @@ -323,7 +353,7 @@ const GLOBAL_VARIABLES: TypeMapping = STRUCT([ { name: 'version', type: FIELD }, { name: 'blockNumber', type: BLOCK_NUMBER }, { name: 'slotNumber', type: SLOT_NUMBER }, - { name: 'timestamp', type: BIGINT }, + { name: 'timestamp', type: U64 }, { name: 'coinbase', type: ETH_ADDRESS }, { name: 'feeRecipient', type: AZTEC_ADDRESS }, { name: 'gasFees', type: GAS_FEES }, @@ -427,11 +457,6 @@ export const CONTRACT_CLASS_LOG: TypeMapping = STRUCT([ { name: 'emittedLength', type: U32 }, ]); -const REVERT_CODE: TypeMapping = { - serialization: { fn: rc => [rc.toField()] }, - shape: ['scalar'], -}; - const PUBLIC_DATA_WRITE: TypeMapping = STRUCT([ { name: 'leafSlot', type: FIELD }, { name: 'value', type: FIELD }, @@ -459,7 +484,7 @@ const CONTRACT_CLASS_LOGS: TypeMapping = FIXED_ARRAY( ); export const TX_EFFECT: TypeMapping = STRUCT([ - { name: 'revertCode', type: REVERT_CODE }, + { name: 'revertCode', type: U8 }, { name: 'txHash', type: TX_HASH }, { name: 'transactionFee', type: FIELD }, { name: 'noteHashes', type: FIXED_ARRAY(FIELD, MAX_NOTE_HASHES_PER_TX) }, @@ -471,7 +496,8 @@ export const TX_EFFECT: TypeMapping = STRUCT([ { name: 'contractClassLogs', type: CONTRACT_CLASS_LOGS }, ]); -export const NOTE: TypeMapping = { +export const NOTE: TypeMapping = LEAF({ + kind: 'note', serialization: { fn: noteData => packAsHintedNote({ @@ -487,21 +513,23 @@ export const NOTE: TypeMapping = { // A packed note is the note's (variable-count) field items followed by 6 metadata scalars, emitted as one field // output per element. Its length depends on the note, so it is described as a single variable-width run. shape: ['variable'], -}; +}); -export const NOTE_VALIDATION_REQUEST: TypeMapping = { +export const NOTE_VALIDATION_REQUEST: TypeMapping = LEAF({ + kind: 'note-validation-request', deserialization: { fn: ([reader]) => NoteValidationRequest.fromFields(reader), }, shape: ['variable'], -}; +}); -export const EVENT_VALIDATION_REQUEST: TypeMapping = { +export const EVENT_VALIDATION_REQUEST: TypeMapping = LEAF({ + kind: 'event-validation-request', deserialization: { fn: ([reader]) => EventValidationRequest.fromFields(reader), }, shape: ['variable'], -}; +}); export const LOG_RETRIEVAL_REQUEST: TypeMapping = STRUCT([ { name: 'contractAddress', type: AZTEC_ADDRESS }, @@ -517,82 +545,88 @@ export const LOG_RETRIEVAL_RESPONSE: TypeMapping = STRUCT< { name: 'uniqueNoteHashesInTx', type: FIXED_BOUNDED_VEC(FIELD, MAX_NOTE_HASHES_PER_TX) }, { name: 'firstNullifierInTx', type: FIELD }, { name: 'blockNumber', type: BLOCK_NUMBER }, - { name: 'blockTimestamp', type: BIGINT }, + { name: 'blockTimestamp', type: U64 }, { name: 'blockHash', type: BLOCK_HASH }, ]); -// `ResolvedTx.toFields()` packs the whole struct into a single slot: txHash, the uniqueNoteHashesInTx BoundedVec -// (MAX_NOTE_HASHES_PER_TX storage fields + length), firstNullifierInTx, blockNumber and blockHash. -export const RESOLVED_TX: TypeMapping = { - serialization: { fn: resolved => [resolved.toFields()] }, - shape: [{ len: MAX_NOTE_HASHES_PER_TX + 5 }], -}; +export const RESOLVED_TX: TypeMapping = STRUCT([ + { name: 'txHash', type: TX_HASH }, + { name: 'uniqueNoteHashesInTx', type: FIXED_BOUNDED_VEC(FIELD, MAX_NOTE_HASHES_PER_TX) }, + { name: 'firstNullifierInTx', type: FIELD }, + { name: 'blockNumber', type: U32 }, + { name: 'blockHash', type: FIELD }, +]); export const PENDING_TAGGED_LOG: TypeMapping = STRUCT([ { name: 'log', type: FIXED_BOUNDED_VEC(FIELD, PRIVATE_LOG_SIZE_IN_FIELDS) }, { name: 'context', type: RESOLVED_TX }, ]); -export const ORIGIN_BLOCK: TypeMapping = { - serialization: { fn: ob => [new Fr(ob.blockNumber), ob.blockHash] }, - deserialization: { - fn: ([blockNumberReader, blockHashReader]) => ({ - blockNumber: blockNumberReader.readField().toNumber(), - blockHash: blockHashReader.readField(), - }), - }, - shape: ['scalar', 'scalar'], -}; +export const ORIGIN_BLOCK: TypeMapping = STRUCT([ + { name: 'blockNumber', type: U32 }, + { name: 'blockHash', type: FIELD }, +]); + +const ORIGIN_BLOCK_STATE: TypeMapping = SCALAR({ + kind: 'origin-block-state', + serialization: { fn: v => [new Fr(v)] }, + deserialization: { fn: ([reader]) => originBlockStateFromNumber(reader.readField().toNumber()) }, +}); /** Read-side origin of a retractable fact, carrying the chain state of its origin block. */ -export const RETRACTABLE_FACT_ORIGIN: TypeMapping = { - serialization: { fn: o => [new Fr(o.blockNumber), o.blockHash, new Fr(o.blockState)] }, - shape: ['scalar', 'scalar', 'scalar'], -}; +export const RETRACTABLE_FACT_ORIGIN: TypeMapping = STRUCT([ + { name: 'blockNumber', type: U32 }, + { name: 'blockHash', type: FIELD }, + { name: 'blockState', type: ORIGIN_BLOCK_STATE }, +]); -// `facts` and `payload` each materialize to a single service-slot id, so a Fact occupies: factTypeId, the payload -// array slot, and `OPTION(RETRACTABLE_FACT_ORIGIN)` (its discriminant plus RETRACTABLE_FACT_ORIGIN's three slots). -export const FACT: TypeMapping = { - serialization: { - fn: f => [ - f.factTypeId, - f.payload.materializeSlot(v => FIELD.serialization!.fn(v).flat() as Fr[]), - ...OPTION(RETRACTABLE_FACT_ORIGIN).serialization!.fn(f.originBlock), - ], - }, - shape: ['scalar', 'scalar', 'scalar', 'scalar', 'scalar', 'scalar'], -}; +export const FACT: TypeMapping = STRUCT([ + { name: 'factTypeId', type: FIELD }, + { name: 'payload', type: EPHEMERAL_ARRAY(FIELD) }, + { name: 'originBlock', type: OPTION(RETRACTABLE_FACT_ORIGIN) }, +]); -export const FACT_COLLECTION: TypeMapping = { - serialization: { - fn: c => [ - c.contractAddress.toField(), - c.scope.toField(), - c.factCollectionTypeId, - c.factCollectionId, - c.facts.materializeSlot(v => FACT.serialization!.fn(v).flat() as Fr[]), - ], - }, - shape: ['scalar', 'scalar', 'scalar', 'scalar', 'scalar'], -}; +export const FACT_COLLECTION: TypeMapping = STRUCT([ + { name: 'contractAddress', type: AZTEC_ADDRESS }, + { name: 'scope', type: AZTEC_ADDRESS }, + { name: 'factCollectionTypeId', type: FIELD }, + { name: 'factCollectionId', type: FIELD }, + { name: 'facts', type: EPHEMERAL_ARRAY(FACT) }, +]); -export const PROVIDED_SECRET: TypeMapping = { - deserialization: { - fn: ([reader]) => ({ - secret: reader.readField(), - mode: appTaggingSecretKindFromDeliveryMode(BYTE.deserialization!.fn([reader])), - }), - }, - shape: [{ len: 2 }], -}; +export const PROVIDED_SECRET: TypeMapping = STRUCT([ + { name: 'secret', type: FIELD }, + { name: 'mode', type: DELIVERY_MODE }, +]); // ─── Combinator Type Mappings ──────────────────────────────────────────────── -function SIBLING_PATH(height: N): TypeMapping> { +/** + * A TS-side alias of `base`: the same Noir type on the wire (it inherits `base`'s `kind`, `label`, and `shape`) but a + * richer TS value. `wrap` maps a base value to the alias, `unwrap` back; a serialize-only alias passes only `unwrap`. + */ +export function ALIAS( + base: TypeMapping, + conversions: { wrap?: (value: B) => T; unwrap?: (value: T) => B }, +): TypeMapping { + const { wrap, unwrap } = conversions; return { + kind: base.kind, + label: base.label, + serialization: base.serialization && unwrap ? { fn: value => base.serialization!.fn(unwrap(value)) } : undefined, + deserialization: + base.deserialization && wrap ? { fn: readers => wrap(base.deserialization!.fn(readers)) } : undefined, + shape: base.shape, + }; +} + +function SIBLING_PATH(height: N): TypeMapping> { + return LEAF({ + // On the wire (and in Noir) a sibling path is a plain `[Field; height]`, so it shares the fixed-array kind. + kind: `array(field,${height})`, serialization: { fn: sp => [sp.toFields()] }, shape: [{ len: height }], - }; + }); } export function MEMBERSHIP_WITNESS(height: N): TypeMapping> { @@ -602,9 +636,10 @@ export function MEMBERSHIP_WITNESS(height: N): TypeMapping(inner: TypeMapping): TypeMapping & { kind: 'array'; inner: TypeMapping } { +export function ARRAY(inner: TypeMapping): ArrayMapping { return { kind: 'array', + label: `array(${inner.label})`, inner, serialization: inner.serialization ? { fn: values => [packElements(inner, values)] } : undefined, deserialization: inner.deserialization @@ -623,13 +658,11 @@ export function ARRAY(inner: TypeMapping): TypeMapping & { kind: 'arr * 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, - length: number, -): TypeMapping & { kind: 'fixed-array'; inner: TypeMapping; length: number } { +export function FIXED_ARRAY(element: TypeMapping, length: number): FixedArrayMapping { const elementWidth = fieldWidth(element.shape); return { kind: 'fixed-array', + label: `array(${element.label},${length})`, inner: element, length, serialization: element.serialization @@ -649,17 +682,16 @@ export function FIXED_ARRAY( * * Both directions are derived from `element`: bidirectional iff `element` has both serialization and deserialization. * - * @example Serializing `BoundedVec.from({ data: [0x41, 0x42], maxLength: 4 })` with `BOUNDED_VEC(BYTE)`: + * @example Serializing `BoundedVec.from({ data: [0x41, 0x42], maxLength: 4 })` with `BOUNDED_VEC(U8)`: * ``` * slot 0: [Fr(0x41), Fr(0x42), Fr(0), Fr(0)] // data padded to maxLength * slot 1: Fr(2) // actual length * ``` */ -export function BOUNDED_VEC( - inner: TypeMapping, -): TypeMapping> & { kind: 'bounded-vec'; inner: TypeMapping } { +export function BOUNDED_VEC(inner: TypeMapping): BoundedVecMapping { return { kind: 'bounded-vec', + label: `bounded-vec(${inner.label})`, inner, serialization: inner.serialization ? { @@ -698,13 +730,11 @@ 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 & { kind: 'fixed-bounded-vec'; inner: TypeMapping; maxLength: number } { +export function FIXED_BOUNDED_VEC(element: TypeMapping, maxLength: number): FixedBoundedVecMapping { const width = fieldWidth(element.shape); return { kind: 'fixed-bounded-vec', + label: `bounded-vec(${element.label},${maxLength})`, inner: element, maxLength, serialization: element.serialization @@ -740,9 +770,10 @@ export function FIXED_BOUNDED_VEC( * slot 1: Fr(0) // zero-filled from AZTEC_ADDRESS.shape * ``` */ -export function OPTION(inner: TypeMapping): TypeMapping> & { kind: 'option'; inner: TypeMapping } { +export function OPTION(inner: TypeMapping): OptionMapping { return { kind: 'option', + label: `option(${inner.label})`, inner, serialization: inner.serialization ? { @@ -770,32 +801,14 @@ export function OPTION(inner: TypeMapping): TypeMapping> & { kin }; } -/** - * A fixed packed uint buffer (Noir `[u8; length]` at `bitSize` 8): one slot of `length` packed uint values ↔ `Buffer`. - * `length` is the field count, which equals the byte count at `bitSize` 8. - */ -export function BUFFER(bitSize: number, length: number): TypeMapping { - return { - serialization: { - fn: buf => [Array.from(buf).map(b => new Fr(b))], - }, - deserialization: { - fn: ([reader]) => - fromUintArray( - reader.readFieldArray(length).map(f => f.toString()), - bitSize, - ), - }, - shape: [{ len: length }], - }; -} - -export function EPHEMERAL_ARRAY(element: TypeMapping): TypeMapping> { +export function EPHEMERAL_ARRAY(element: TypeMapping): EphemeralArrayMapping { // EphemeralArray.readAll hands each row's flat fields in as a single reader; reconstruct the element's per-slot // readers from its shape, deserialize, and assert the row was fully consumed so a row with trailing fields is // rejected. const rowElement: TypeMapping | undefined = element.deserialization ? { + kind: element.kind, + label: element.label, deserialization: { fn: ([rowReader]) => deserializeElement(element, rowReader.readFieldArray(rowReader.remainingFields())), }, @@ -805,6 +818,9 @@ export function EPHEMERAL_ARRAY(element: TypeMapping): TypeMapping [ea.materializeSlot(v => serializeElement(element, v))] } : undefined, @@ -824,11 +840,10 @@ export type StructField = { name: TName; * `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 & { kind: 'struct'; fields: readonly StructField[] } { +export function STRUCT(fields: readonly StructField[]): StructMapping { return { kind: 'struct', + label: `{${structFieldLabels(fields)}}`, fields, serialization: fields.every(f => f.type.serialization) ? { @@ -856,6 +871,79 @@ export function STRUCT( }; } +/** + * The composite `TypeMapping`s (`ARRAY`/`BOUNDED_VEC`/`OPTION`/`STRUCT`/`FIXED_ARRAY`/`FIXED_BOUNDED_VEC`/ + * `EPHEMERAL_ARRAY`), 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. + */ +export type ArrayMapping = TypeMapping & { kind: 'array'; inner: TypeMapping }; +export type BoundedVecMapping = TypeMapping> & { kind: 'bounded-vec'; inner: TypeMapping }; +export type OptionMapping = TypeMapping> & { kind: 'option'; inner: TypeMapping }; +export type StructMapping = TypeMapping & { kind: 'struct'; fields: readonly StructField[] }; +export type FixedArrayMapping = TypeMapping & { + kind: 'fixed-array'; + inner: TypeMapping; + length: number; +}; +export type FixedBoundedVecMapping = TypeMapping & { + kind: 'fixed-bounded-vec'; + inner: TypeMapping; + maxLength: number; +}; +export type EphemeralArrayMapping = TypeMapping> & { + kind: 'ephemeral-array'; + inner: TypeMapping; +}; +export type CompositeMapping = + | ArrayMapping + | BoundedVecMapping + | OptionMapping + | StructMapping + | FixedArrayMapping + | FixedBoundedVecMapping + | EphemeralArrayMapping; + +export function isArrayMapping(type: TypeMapping): type is ArrayMapping { + return type.kind === 'array'; +} +export function isBoundedVecMapping(type: TypeMapping): type is BoundedVecMapping { + return type.kind === 'bounded-vec'; +} +export function isOptionMapping(type: TypeMapping): type is OptionMapping { + return type.kind === 'option'; +} +export function isStructMapping(type: TypeMapping): type is StructMapping { + return type.kind === 'struct'; +} +export function isFixedArrayMapping(type: TypeMapping): type is FixedArrayMapping { + return type.kind === 'fixed-array'; +} +export function isFixedBoundedVecMapping(type: TypeMapping): type is FixedBoundedVecMapping { + return type.kind === 'fixed-bounded-vec'; +} +export function isEphemeralArrayMapping(type: TypeMapping): type is EphemeralArrayMapping { + return type.kind === 'ephemeral-array'; +} + +/** + * The comma-joined labels of a struct's fields, with nested struct labels spliced into the parent: nesting affects + * neither the wire (struct slots concatenate) nor the interface, so the label treats nested and flat declarations as + * the same type. + */ +function structFieldLabels(fields: readonly StructField[]): string { + return fields.map(f => (isStructMapping(f.type) ? structFieldLabels(f.type.fields) : f.type.label)).join(','); +} + +/** The field's value as a bigint, asserted to fit a `bits`-wide uint. */ +function uintFromField(field: Fr, bits: number): bigint { + const max = 2n ** BigInt(bits) - 1n; + const value = field.toBigInt(); + if (value > max) { + throw new Error(`u${bits} overflow: value ${value} exceeds max (${max})`); + } + return value; +} + /** Number of InputSlots a deserializable type spans, derived from its {@link TypeMapping.shape}. */ export function slotsOf(mapping: TypeMapping): number { return mapping.shape.length; diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution.test.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution.test.ts index d6f3358693f5..3150ab06beb1 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution.test.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution.test.ts @@ -68,7 +68,6 @@ import type { EmbeddedCurvePoint } from '../noir-structs/embedded_curve_point.js import { EphemeralArray } from '../noir-structs/ephemeral_array.js'; import { Option } from '../noir-structs/option.js'; import type { ProvidedSecret } from '../noir-structs/provided_secret.js'; -import { ResolvedTx } from '../noir-structs/resolved_tx.js'; import { TransientArrayService } from '../transient_array_service.js'; import { UtilityExecutionOracle, type UtilityExecutionOracleArgs } from './utility_execution_oracle.js'; @@ -655,13 +654,13 @@ describe('Utility Execution test suite', () => { expect(resultLogs).toEqual([ { log: log.logData, - context: new ResolvedTx( - log.txHash, - log.noteHashes, - log.nullifiers[0], - log.blockNumber, - log.blockHash.toFr(), - ), + context: { + txHash: log.txHash, + uniqueNoteHashesInTx: log.noteHashes, + firstNullifierInTx: log.nullifiers[0], + blockNumber: log.blockNumber, + blockHash: log.blockHash.toFr(), + }, }, ]); }); 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 1a99ab102c3d..d23247aefe54 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 @@ -859,13 +859,17 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra // TODO(#11849): consider replacing this oracle with a pure Noir implementation of aes decryption. public async decryptAes128( ciphertext: BoundedVec, - iv: Buffer, - symKey: Buffer, + iv: number[], + symKey: number[], ): Promise>> { const capacity = ciphertext.maxLength; try { const aes128 = new Aes128(); - const plaintext = await aes128.decryptBufferCBC(Buffer.from(ciphertext.data), iv, symKey); + const plaintext = await aes128.decryptBufferCBC( + Buffer.from(ciphertext.data), + Buffer.from(iv), + Buffer.from(symKey), + ); return Option.some(BoundedVec.from({ data: [...plaintext], maxLength: capacity })); } catch { return Option.none({ maxLength: capacity }); @@ -1143,6 +1147,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra #toTxEffectData(txEffect: TxEffect): TxEffectData { return { ...txEffect, + revertCode: txEffect.revertCode.getCode(), publicLogs: FlatPublicLogs.fromLogs(txEffect.publicLogs), contractClassLogs: txEffect.contractClassLogs.map(log => ({ contractAddress: log.contractAddress, diff --git a/yarn-project/pxe/src/logs/log_service.ts b/yarn-project/pxe/src/logs/log_service.ts index 2654f928fa3e..797458555059 100644 --- a/yarn-project/pxe/src/logs/log_service.ts +++ b/yarn-project/pxe/src/logs/log_service.ts @@ -22,7 +22,6 @@ import { } from '../contract_function_simulator/noir-structs/log_retrieval_request.js'; import type { LogRetrievalResponse } from '../contract_function_simulator/noir-structs/log_retrieval_response.js'; import type { PendingTaggedLog } from '../contract_function_simulator/noir-structs/pending_tagged_log.js'; -import { ResolvedTx } from '../contract_function_simulator/noir-structs/resolved_tx.js'; import { AddressStore } from '../storage/address_store/address_store.js'; import { assertAllowedScope } from '../storage/allowed_scopes.js'; import type { RecipientTaggingStore } from '../storage/tagging_store/recipient_tagging_store.js'; @@ -225,7 +224,13 @@ export class LogService { } return { log: log.logData, - context: new ResolvedTx(log.txHash, noteHashes, nullifiers[0], log.blockNumber, log.blockHash.toFr()), + context: { + txHash: log.txHash, + uniqueNoteHashesInTx: noteHashes, + firstNullifierInTx: nullifiers[0], + blockNumber: log.blockNumber, + blockHash: log.blockHash.toFr(), + }, }; }); } diff --git a/yarn-project/pxe/src/messages/tx_resolver_service.test.ts b/yarn-project/pxe/src/messages/tx_resolver_service.test.ts index 26d7fdbf9020..f703fc286a56 100644 --- a/yarn-project/pxe/src/messages/tx_resolver_service.test.ts +++ b/yarn-project/pxe/src/messages/tx_resolver_service.test.ts @@ -6,7 +6,6 @@ import { DroppedTxReceipt, MinedTxReceipt, TxEffect, TxExecutionResult, TxHash, import { mock } from 'jest-mock-extended'; -import { ResolvedTx } from '../contract_function_simulator/noir-structs/resolved_tx.js'; import { TxResolverService } from './tx_resolver_service.js'; describe('TxResolverService', () => { @@ -118,7 +117,15 @@ describe('TxResolverService', () => { const results = await service.resolveTxs([txHash.hash], anchorBlockNumber); - expect(results).toEqual([new ResolvedTx(txHash, noteHashes, firstNullifier, blockNumber, blockHash.toFr())]); + expect(results).toEqual([ + { + txHash, + uniqueNoteHashesInTx: noteHashes, + firstNullifierInTx: firstNullifier, + blockNumber, + blockHash: blockHash.toFr(), + }, + ]); }); it('resolves tx hashes in different situations', async () => { @@ -166,7 +173,13 @@ describe('TxResolverService', () => { expect(results).toEqual([ null, - new ResolvedTx(validTxHash, validNoteHashes, validNullifier, anchorBlockNumber, validBlockHash.toFr()), + { + txHash: validTxHash, + uniqueNoteHashesInTx: validNoteHashes, + firstNullifierInTx: validNullifier, + blockNumber: anchorBlockNumber, + blockHash: validBlockHash.toFr(), + }, null, null, ]); @@ -202,13 +215,13 @@ describe('TxResolverService', () => { anchorBlockNumber, ); - const expected = new ResolvedTx( - txEffect.txHash, - txEffect.noteHashes, - txEffect.nullifiers[0], + const expected = { + txHash: txEffect.txHash, + uniqueNoteHashesInTx: txEffect.noteHashes, + firstNullifierInTx: txEffect.nullifiers[0], blockNumber, - blockHash.toFr(), - ); + blockHash: blockHash.toFr(), + }; expect(results).toEqual([expected, expected, expected]); expect(aztecNode.getTxReceipt).toHaveBeenCalledTimes(1); }); diff --git a/yarn-project/pxe/src/messages/tx_resolver_service.ts b/yarn-project/pxe/src/messages/tx_resolver_service.ts index bb733ddff4ff..da5033b27c14 100644 --- a/yarn-project/pxe/src/messages/tx_resolver_service.ts +++ b/yarn-project/pxe/src/messages/tx_resolver_service.ts @@ -3,7 +3,7 @@ import { Fr } from '@aztec/foundation/curves/bn254'; import type { AztecNode } from '@aztec/stdlib/interfaces/server'; import { type IndexedTxEffect, TxHash } from '@aztec/stdlib/tx'; -import { ResolvedTx } from '../contract_function_simulator/noir-structs/resolved_tx.js'; +import type { ResolvedTx } from '../contract_function_simulator/noir-structs/resolved_tx.js'; /** Resolves transaction hashes into their on-chain context (note hashes, first nullifier, and mined block). */ export class TxResolverService { @@ -57,13 +57,13 @@ export class TxResolverService { throw new Error(`Tx effect for ${txHash} has no nullifiers`); } - return new ResolvedTx( - data.txHash, - data.noteHashes, - data.nullifiers[0], - txEffect.l2BlockNumber, - txEffect.l2BlockHash.toFr(), - ); + return { + txHash: data.txHash, + uniqueNoteHashesInTx: data.noteHashes, + firstNullifierInTx: data.nullifiers[0], + blockNumber: txEffect.l2BlockNumber, + blockHash: txEffect.l2BlockHash.toFr(), + }; }); } } diff --git a/yarn-project/pxe/src/oracle_version.ts b/yarn-project/pxe/src/oracle_version.ts index 92cf5a9d3aa1..b40b9932e3c2 100644 --- a/yarn-project/pxe/src/oracle_version.ts +++ b/yarn-project/pxe/src/oracle_version.ts @@ -13,10 +13,10 @@ export const ORACLE_VERSION_MAJOR = 30; export const ORACLE_VERSION_MINOR = 8; -/// This hash is computed from the `ORACLE_REGISTRY` declaration (each oracle's name, ordered parameter names and +/// This hash is computed from `ORACLE_REGISTRY` (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: /// - increment `ORACLE_VERSION_MAJOR` and reset `ORACLE_VERSION_MINOR` to zero if the change is breaking, or /// - 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 = 'b302a8e65d37043c0a0dc08a39895603122dce334843f0442462edb3f56e32e2'; +export const ORACLE_INTERFACE_HASH = 'fec4d3a95fc57a62a20bfeb659bb3bfd91b31cd93e07f8cf97b368f9f55cc67b'; diff --git a/yarn-project/pxe/src/storage/fact_store/index.ts b/yarn-project/pxe/src/storage/fact_store/index.ts index f4cb3b9269fa..aa65ed5e4ca2 100644 --- a/yarn-project/pxe/src/storage/fact_store/index.ts +++ b/yarn-project/pxe/src/storage/fact_store/index.ts @@ -5,6 +5,7 @@ export type { Fact } from './stored_fact.js'; export { OriginBlockState, classifyOriginBlockState, + originBlockStateFromNumber, anchoredTipBlockNumbers, toFactWithOriginState, type TipBlockNumbers, diff --git a/yarn-project/pxe/src/storage/fact_store/origin_state.ts b/yarn-project/pxe/src/storage/fact_store/origin_state.ts index 38eca7574c47..d498e8f184d5 100644 --- a/yarn-project/pxe/src/storage/fact_store/origin_state.ts +++ b/yarn-project/pxe/src/storage/fact_store/origin_state.ts @@ -21,6 +21,15 @@ export enum OriginBlockState { Finalized = 3, } +/** Parses a numeric origin-block-state discriminant, rejecting unknown values. */ +export function originBlockStateFromNumber(value: number): OriginBlockState { + if (!(value in OriginBlockState)) { + const validNames = Object.keys(OriginBlockState).filter(k => isNaN(Number(k))); + throw new Error(`Invalid OriginBlockState value ${value}, expected one of ${validNames.join(', ')}`); + } + return value as OriginBlockState; +} + /** The two chain-tip block numbers needed to classify an origin block (`finalized <= proven` always holds). */ export type TipBlockNumbers = { provenBlockNumber: number; finalizedBlockNumber: number }; diff --git a/yarn-project/txe/src/bin/check_txe_oracle_version.ts b/yarn-project/txe/src/bin/check_txe_oracle_version.ts index 5a4a9ee02ad9..9e54da8552d7 100644 --- a/yarn-project/txe/src/bin/check_txe_oracle_version.ts +++ b/yarn-project/txe/src/bin/check_txe_oracle_version.ts @@ -1,32 +1,29 @@ import { keccak256String } from '@aztec/foundation/crypto/keccak'; -import { getOracleInterfaceSignature, readNumericGlobal } from '@aztec/pxe/bin'; +import { getOracleRegistrySignature, readNumericGlobal } from '@aztec/pxe/bin'; +import { ORACLE_REGISTRY, type OracleRegistryEntry } from '@aztec/pxe/simulator'; import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; +import { TXE_ORACLE_REGISTRY } from '../oracle/txe_oracle_registry.js'; import { TXE_ORACLE_INTERFACE_HASH, TXE_ORACLE_VERSION_MAJOR } from '../oracle/txe_oracle_version.js'; /** * Verifies that the TXE oracle interfaces match the expected interface hash. * * The TXE oracle interfaces need to be versioned to ensure compatibility between Aztec.nr tests and TXE. This function - * computes a hash of the TXE oracle interfaces and compares it against a known hash. If they don't match, it means an + * computes a hash of the TXE-specific entries in `TXE_ORACLE_REGISTRY` (the shared PXE entries are covered by the PXE + * oracle version check) and compares it against a known hash. If they don't match, it means an * interface has changed and the TXE oracle version needs to be bumped: * - If the change is backward-breaking (e.g. removing/renaming an oracle), bump TXE_ORACLE_VERSION_MAJOR. * - If the change is an oracle addition (non-breaking), bump TXE_ORACLE_VERSION_MINOR. */ function assertTxeOracleInterfaceMatches(): void { - const currentDir = dirname(fileURLToPath(import.meta.url)); - const packageRoot = dirname(dirname(currentDir)); - const interfacesSourcePath = join(packageRoot, 'src/oracle/interfaces.ts'); - - const targets = ['IAvmExecutionOracle', 'ITxeExecutionOracle']; - // Not an oracle foreign call handler (see TODO(F-335) in interfaces.ts). - const excludedMembers = ['syncContractNonOracleMethod']; - - const txeOracleInterfaceSignature = getOracleInterfaceSignature(interfacesSourcePath, targets, excludedMembers); + const txeOnlyEntries: Record = Object.fromEntries( + Object.entries(TXE_ORACLE_REGISTRY).filter(([name]) => !(name in ORACLE_REGISTRY)), + ); - const txeOracleInterfaceHash = keccak256String(txeOracleInterfaceSignature); + const txeOracleInterfaceHash = keccak256String(getOracleRegistrySignature(txeOnlyEntries)); if (txeOracleInterfaceHash !== TXE_ORACLE_INTERFACE_HASH) { throw new Error( `The TXE oracle interface has changed. Update TXE_ORACLE_INTERFACE_HASH to ${txeOracleInterfaceHash} in txe/src/oracle/txe_oracle_version.ts and bump the TXE oracle version (TXE_ORACLE_VERSION_MAJOR for breaking changes, TXE_ORACLE_VERSION_MINOR for oracle additions).`, diff --git a/yarn-project/txe/src/oracle/oracle_kinds.test.ts b/yarn-project/txe/src/oracle/oracle_kinds.test.ts new file mode 100644 index 000000000000..7edfd6432a52 --- /dev/null +++ b/yarn-project/txe/src/oracle/oracle_kinds.test.ts @@ -0,0 +1,33 @@ +import type { Fr } from '@aztec/foundation/curves/bn254'; +import type { TypeMapping } from '@aztec/pxe/simulator'; + +import { SCALAR_MAPPINGS, testValueFor } from './test-resolver/default_fixtures.js'; + +describe('oracle type-mapping labels', () => { + it('mappings sharing a label are wire-equivalent', () => { + // The interface hash treats mappings with equal labels as the same wire type, so two scalar mappings sharing a + // label (e.g. FIELD and TX_HASH, both a Noir `Field`) must serialize the same canonical value for the same seed. + const byLabel = new Map[]>(); + // A deserialize-only mapping has no wire form to compare. + for (const mapping of SCALAR_MAPPINGS.filter(m => m.serialization !== undefined)) { + byLabel.set(mapping.label, [...(byLabel.get(mapping.label) ?? []), mapping]); + } + + for (const [label, mappings] of byLabel.entries()) { + const serialized = mappings.map(mapping => canonicalRows(mapping)); + for (const rows of serialized.slice(1)) { + expect({ label, rows }).toEqual({ label, rows: serialized[0] }); + } + } + }); +}); + +/** Serialized canonical values at a few seeds. */ +function canonicalRows(mapping: TypeMapping): string[][] { + return [10, 11, 12].map(seed => + mapping + .serialization!.fn(testValueFor(mapping, seed)) + .flat() + .map((f: Fr) => f.toString()), + ); +} diff --git a/yarn-project/txe/src/oracle/test-resolver/default_fixtures.test.ts b/yarn-project/txe/src/oracle/test-resolver/default_fixtures.test.ts index 9d3ba9b77d03..1a418d415afc 100644 --- a/yarn-project/txe/src/oracle/test-resolver/default_fixtures.test.ts +++ b/yarn-project/txe/src/oracle/test-resolver/default_fixtures.test.ts @@ -1,12 +1,12 @@ /* eslint-disable camelcase */ import { BOUNDED_VEC, - BYTE, type BoundedVec, FIELD, OPTION, type Option, type OracleRegistryEntry, + U8, makeEntry, } from '@aztec/pxe/simulator'; @@ -16,7 +16,7 @@ describe('synthesizeDefaultFixtures', () => { it('synthesizes a BoundedVec param, padding data below capacity', () => { const registry: Record = { push_bytes: makeEntry({ - params: [{ name: 'ciphertext', type: BOUNDED_VEC(BYTE) }], + params: [{ name: 'ciphertext', type: BOUNDED_VEC(U8) }], }), }; @@ -31,7 +31,7 @@ describe('synthesizeDefaultFixtures', () => { it('synthesizes an Option> return as two cases named some/none', () => { const registry: Record = { decrypt_bytes: makeEntry({ - returnType: OPTION(BOUNDED_VEC(BYTE)), + returnType: OPTION(BOUNDED_VEC(U8)), }), }; 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 600ffbd980a4..53b4309409cf 100644 --- a/yarn-project/txe/src/oracle/test-resolver/default_fixtures.ts +++ b/yarn-project/txe/src/oracle/test-resolver/default_fixtures.ts @@ -8,19 +8,29 @@ import { BLOCK_HASH, BLOCK_NUMBER, BOOL, - BYTE, BoundedVec, + type CompositeMapping, DELIVERY_MODE, ETH_ADDRESS, FIELD, FUNCTION_SELECTOR, + LEAF_INDEX, NOTE_SELECTOR, Option, type OracleRegistryEntry, SLOT_NUMBER, - type StructField, + type StructMapping, type TypeMapping, + U8, U32, + U64, + U128, + isArrayMapping, + isBoundedVecMapping, + isFixedArrayMapping, + isFixedBoundedVecMapping, + isOptionMapping, + isStructMapping, tryFieldWidth, } from '@aztec/pxe/simulator'; import { FunctionSelector, NoteSelector } from '@aztec/stdlib/abi'; @@ -50,15 +60,18 @@ export function synthesizeDefaultFixtures( } /** - * 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. + * The scalar test-value implementations, mirroring the Noir synthesizer's `scalars` table in + * `noir-projects/aztec-nr/.../macros/oracle_testing.nr` exactly. */ -const TEST_VALUE_IMPLS: TestValueImpl[] = [ +const SCALAR_IMPLS: ScalarImpl[] = [ scalar(FIELD, seed => new Fr(seed)), scalar(U32, seed => seed), scalar(BLOCK_NUMBER, seed => BlockNumber(seed)), scalar(BIGINT, seed => BigInt(seed)), - scalar(BYTE, seed => seed), + scalar(U64, seed => BigInt(seed)), + scalar(U128, seed => BigInt(seed)), + scalar(LEAF_INDEX, seed => seed), + scalar(U8, seed => seed), scalar(BOOL, seed => seed % 2 !== 0), scalar(AZTEC_ADDRESS, seed => AztecAddress.fromNumberUnsafe(seed)), scalar(ETH_ADDRESS, seed => EthAddress.fromField(new Fr(seed))), @@ -70,12 +83,18 @@ const TEST_VALUE_IMPLS: TestValueImpl[] = [ scalar(DELIVERY_MODE, seed => seed % 2 === 0 ? AppTaggingSecretKind.UNCONSTRAINED : AppTaggingSecretKind.CONSTRAINED, ), - composite(isOption, (type, seed) => [ - named(Option.some(firstValue(type.inner, seed)), 'some'), - named(Option.none(firstValue(type.inner, seed)), 'none'), +]; + +/** + * The composite test-value implementations, one per combinator, mirroring the Noir synthesizer's `synthesizers` table. + */ +const COMPOSITE_IMPLS: CompositeImpl[] = [ + composite(isOptionMapping, (type, seed) => [ + named(Option.some(testValueFor(type.inner, seed)), 'some'), + named(Option.none(testValueFor(type.inner, seed)), 'none'), ]), - composite(isArray, (type, seed) => [unnamed(collectionData(type.inner, seed, DEFAULT_ARRAY_LENGTH))]), - composite(isBoundedVec, (type, seed) => [ + composite(isArrayMapping, (type, seed) => [unnamed(collectionData(type.inner, seed, DEFAULT_ARRAY_LENGTH))]), + composite(isBoundedVecMapping, (type, seed) => [ unnamed( BoundedVec.from({ data: collectionData(type.inner, seed, DEFAULT_ARRAY_LENGTH), @@ -83,17 +102,20 @@ const TEST_VALUE_IMPLS: TestValueImpl[] = [ }), ), ]), - composite(isStruct, (type, seed) => [unnamed(structValue(type, seed))]), + composite(isStructMapping, (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))]), + composite(isFixedArrayMapping, (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) => [ + composite(isFixedBoundedVecMapping, (type, seed) => [ unnamed(collectionData(type.inner, seed, Math.min(DEFAULT_ARRAY_LENGTH, type.maxLength))), ]), ]; +/** The scalar mappings with a registered test-value impl. */ +export const SCALAR_MAPPINGS: TypeMapping[] = SCALAR_IMPLS.map(impl => impl.mapping); + /** * The base every seed is offset by, applied once where a parameter's position becomes its seed, so generated values * stay clear of 0. Must match `SEED_BASE` in the Noir `oracle_testing.nr`. @@ -125,32 +147,34 @@ function named(value: unknown, name: string): Scenario { return { value, name }; } -/** - * 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 { +/** An impl for the single scalar `mapping` it synthesizes for. */ +type ScalarImpl = { + mapping: TypeMapping; + scenarios: (type: TypeMapping, seed: number) => Scenario[]; +}; + +/** An impl for a whole family of composite mappings, selected by its `kind` type guard. */ +type CompositeImpl = { match: (type: TypeMapping) => boolean; scenarios: (type: TypeMapping, seed: number) => Scenario[]; -} +}; -/** A scalar impl: matches the singleton `type` by identity and yields a single unnamed scenario. */ -function scalar(type: TypeMapping, value: (seed: number) => unknown): TestValueImpl { - return { match: t => t === type, scenarios: (_type, seed) => [unnamed(value(seed))] }; +/** A scalar impl yielding a single unnamed scenario. */ +function scalar(mapping: TypeMapping, value: (seed: number) => unknown): ScalarImpl { + return { mapping, scenarios: (_type, seed) => [unnamed(value(seed))] }; } -/** A composite impl: selected by the `kind` type guard, recursing through its element/inner mapping. */ +/** A composite impl, recursing through the matched mapping's element/inner mapping. */ function composite( guard: (type: TypeMapping) => type is K, scenarios: (type: K, seed: number) => Scenario[], -): TestValueImpl { +): CompositeImpl { return { match: guard, scenarios: (type, seed) => scenarios(type as K, seed) }; } /** Synthesized element values for a collection. */ function collectionData(element: TypeMapping, seed: number, length: number): unknown[] { - return Array.from({ length }, (_, i) => firstValue(element, seed + i)); + return Array.from({ length }, (_, i) => testValueFor(element, seed + i)); } /** @@ -161,7 +185,7 @@ 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); + value[field.name] = testValueFor(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. @@ -177,15 +201,18 @@ function structValue(type: StructMapping, seed: number): Record * 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)); + const impl = SCALAR_IMPLS.find(i => i.mapping === type) ?? COMPOSITE_IMPLS.find(i => i.match(type)); if (!impl) { throw new UnsynthesizableTypeError(type); } return impl.scenarios(type, seed); } -/** The first (representative) test value of `type`, used for collection elements and `Option` inners. */ -function firstValue(type: TypeMapping, seed: number): unknown { +/** + * The test value of `type` at `seed`, matching what the Noir synthesizer produces for the same type and seed. Throws + * for types without a test-value impl. + */ +export function testValueFor(type: TypeMapping, seed: number): unknown { return scenariosForType(type, seed)[0].value; } @@ -242,41 +269,3 @@ function synthesizeScenariosOrThrow(entry: OracleRegistryEntry): OracleTestScena } return scenarios; } - -/** - * 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 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'; -} -function isBoundedVec(type: TypeMapping): type is BoundedVecMapping { - return 'kind' in type && type.kind === 'bounded-vec'; -} -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 a8d1c2393252..f74cb2742aab 100644 --- a/yarn-project/txe/src/oracle/test-resolver/resolver.ts +++ b/yarn-project/txe/src/oracle/test-resolver/resolver.ts @@ -2,7 +2,7 @@ import type { Logger } from '@aztec/foundation/log'; import { createLogger } from '@aztec/foundation/log'; import { withoutHexPrefix } from '@aztec/foundation/string'; -import { BOUNDED_VEC, BYTE, BoundedVec, Option, type OracleRegistryEntry, makeEntry } from '@aztec/pxe/simulator'; +import { BOUNDED_VEC, BoundedVec, Option, type OracleRegistryEntry, U8, makeEntry } from '@aztec/pxe/simulator'; import type { ForeignCallArgs, ForeignCallResult } from '../../utils/encoding.js'; import { toInputSlots } from '../txe_oracle_registry.js'; @@ -10,7 +10,7 @@ import { synthesizeDefaultFixtures } from './default_fixtures.js'; /** Name of the meta-oracle that Noir tests call to announce the next call's scenario by name. */ const SET_SCENARIO_ORACLE = 'aztec_oracle_test_set_scenario'; -export const SET_SCENARIO_ENTRY = makeEntry({ params: [{ name: 'name', type: BOUNDED_VEC(BYTE) }] }); +export const SET_SCENARIO_ENTRY = makeEntry({ params: [{ name: 'name', type: BOUNDED_VEC(U8) }] }); export type OracleTestCallInput = { session_id: number; diff --git a/yarn-project/txe/src/oracle/txe_oracle_registry.ts b/yarn-project/txe/src/oracle/txe_oracle_registry.ts index 104ccde275d2..6af26eaedf93 100644 --- a/yarn-project/txe/src/oracle/txe_oracle_registry.ts +++ b/yarn-project/txe/src/oracle/txe_oracle_registry.ts @@ -14,7 +14,6 @@ import type { TaggingSecretStrategy } from '@aztec/pxe/server'; import { ARRAY, AZTEC_ADDRESS, - BIGINT, BLOCK_NUMBER, BOOL, ETH_ADDRESS, @@ -22,17 +21,20 @@ import { FIXED_ARRAY, FUNCTION_SELECTOR, type InputSlot, + LEAF, type MaybePromise, OPTION, ORACLE_REGISTRY, type OracleRegistryEntry, type OutputSlot, type ParamTypes, + SCALAR, STR, STRUCT, type SlotShape, type TypeMapping, U32, + U64, buildACIRCallback, makeEntry, } from '@aztec/pxe/simulator'; @@ -59,12 +61,13 @@ export type { BlockHash } from '@aztec/stdlib/block'; export type { MembershipWitness } from '@aztec/foundation/trees'; export type { NullifierMembershipWitness, PublicDataWitness } from '@aztec/stdlib/trees'; -const GAS_SETTINGS: TypeMapping = { +const GAS_SETTINGS: TypeMapping = LEAF({ + kind: 'gas-settings', deserialization: { fn: ([reader]) => GasSettings.fromFields(reader.readFieldArray(GAS_SETTINGS_LENGTH)), }, shape: [{ len: GAS_SETTINGS_LENGTH }], -}; +}); // Tagging secret strategy discriminants. Must match the Noir test helper `TaggingSecretStrategy` in // aztec-nr `test/helpers/tagging_secret_strategy.nr`. This is a test-only oracle (only `set_tagging_secret_strategies` @@ -74,7 +77,8 @@ const STRATEGY_ARBITRARY_SECRET = 2; const STRATEGY_ADDRESS_DERIVED = 3; const STRATEGY_INTERACTIVE_HANDSHAKE = 4; -const TAGGING_SECRET_STRATEGY: TypeMapping = { +const TAGGING_SECRET_STRATEGY: TypeMapping = LEAF({ + kind: 'tagging-secret-strategy', serialization: { fn: strategy => { switch (strategy.type) { @@ -108,24 +112,27 @@ const TAGGING_SECRET_STRATEGY: TypeMapping = { }, }, shape: ['scalar', 'scalar', 'scalar'], -}; +}); -const PRIVATE_CONTEXT_INPUTS: TypeMapping = { +const PRIVATE_CONTEXT_INPUTS: TypeMapping = LEAF({ + kind: 'private-context-inputs', serialization: { fn: v => v.toFields() }, shape: Array(PRIVATE_CONTEXT_INPUTS_LENGTH).fill('scalar'), -}; +}); -const COMPLETE_ADDRESS: TypeMapping = { +const COMPLETE_ADDRESS: TypeMapping = LEAF({ + kind: 'complete-address', serialization: { fn: v => [v.address.toField(), ...v.publicKeys.toFields()] }, shape: Array(8).fill('scalar'), // address + 7 public-key fields -}; +}); const TXE_TX_EFFECTS: TypeMapping<{ txHash: TxHash; noteHashes: Fr[]; nullifiers: Fr[]; privateLogs: PrivateLog[]; -}> = { +}> = LEAF({ + kind: 'txe-tx-effects', serialization: { fn: ({ txHash, noteHashes, nullifiers, privateLogs }) => { const emittedLogs = privateLogs.map(log => log.getEmittedFields()); @@ -164,9 +171,10 @@ const TXE_TX_EFFECTS: TypeMapping<{ { len: MAX_PRIVATE_LOGS_PER_TX }, 'scalar', ], -}; +}); -const TXE_OFFCHAIN_EFFECTS: TypeMapping<{ effects: Fr[][] }> = { +const TXE_OFFCHAIN_EFFECTS: TypeMapping<{ effects: Fr[][] }> = LEAF({ + kind: 'txe-offchain-effects', serialization: { fn: ({ effects }) => { const rawArrayStorage = effects @@ -188,9 +196,10 @@ const TXE_OFFCHAIN_EFFECTS: TypeMapping<{ effects: Fr[][] }> = { { len: MAX_OFFCHAIN_EFFECTS_PER_TXE_QUERY }, 'scalar', ], -}; +}); -const TXE_CALL_CONTEXT: TypeMapping<{ txHash: Fr; anchorBlockTimestamp: bigint }> = { +const TXE_CALL_CONTEXT: TypeMapping<{ txHash: Fr; anchorBlockTimestamp: bigint }> = LEAF({ + kind: 'txe-call-context', serialization: { fn: ({ txHash, anchorBlockTimestamp }) => { const isSome = txHash.isZero() ? 0 : 1; @@ -198,7 +207,7 @@ const TXE_CALL_CONTEXT: TypeMapping<{ txHash: Fr; anchorBlockTimestamp: bigint } }, }, shape: ['scalar', 'scalar', 'scalar'], // discriminant, txHash, anchor block timestamp -}; +}); const CONTRACT_INSTANCE_MEMBER: TypeMapping<{ exists: boolean; member: Fr }[]> = FIXED_ARRAY( STRUCT([ @@ -208,13 +217,14 @@ const CONTRACT_INSTANCE_MEMBER: TypeMapping<{ exists: boolean; member: Fr }[]> = 1, ); -const EVENT_SELECTOR: TypeMapping = { +const EVENT_SELECTOR: TypeMapping = SCALAR({ + kind: 'event-selector', serialization: { fn: v => [v.toField()] }, deserialization: { fn: ([reader]) => EventSelector.fromField(reader.readField()) }, - shape: ['scalar'], -}; +}); -const TXE_PRIVATE_EVENTS: TypeMapping = { +const TXE_PRIVATE_EVENTS: TypeMapping = LEAF({ + kind: 'txe-private-events', serialization: { fn: events => { const rawArrayStorage = events @@ -235,7 +245,7 @@ const TXE_PRIVATE_EVENTS: TypeMapping = { { len: MAX_PRIVATE_EVENTS_PER_TXE_QUERY }, 'scalar', ], -}; +}); export const TXE_ORACLE_REGISTRY = { ...ORACLE_REGISTRY, @@ -270,14 +280,14 @@ export const TXE_ORACLE_REGISTRY = { aztec_txe_getNextBlockNumber: makeEntry({ returnType: BLOCK_NUMBER }), - aztec_txe_getNextBlockTimestamp: makeEntry({ returnType: BIGINT }), + aztec_txe_getNextBlockTimestamp: makeEntry({ returnType: U64 }), aztec_txe_advanceBlocksBy: makeEntry({ params: [{ name: 'blocks', type: U32 }], }), aztec_txe_advanceTimestampBy: makeEntry({ - params: [{ name: 'duration', type: BIGINT }], + params: [{ name: 'duration', type: U64 }], }), aztec_txe_deploy: makeEntry({ @@ -335,7 +345,7 @@ export const TXE_ORACLE_REGISTRY = { }), aztec_txe_getLastBlockTimestamp: makeEntry({ - returnType: BIGINT, + returnType: U64, }), aztec_txe_getLastTxEffects: makeEntry({ returnType: TXE_TX_EFFECTS }), @@ -394,7 +404,7 @@ export const TXE_ORACLE_REGISTRY = { aztec_avm_blockNumber: makeEntry({ returnType: BLOCK_NUMBER }), - aztec_avm_timestamp: makeEntry({ returnType: BIGINT }), + aztec_avm_timestamp: makeEntry({ returnType: U64 }), aztec_avm_isStaticCall: makeEntry({ returnType: BOOL }), diff --git a/yarn-project/txe/src/oracle/txe_oracle_version.ts b/yarn-project/txe/src/oracle/txe_oracle_version.ts index 3fc51aca2b67..95d98d84fdad 100644 --- a/yarn-project/txe/src/oracle/txe_oracle_version.ts +++ b/yarn-project/txe/src/oracle/txe_oracle_version.ts @@ -9,9 +9,9 @@ export const TXE_ORACLE_VERSION_MAJOR = 4; export const TXE_ORACLE_VERSION_MINOR = 1; /** - * This hash is computed from the TXE oracle interfaces (IAvmExecutionOracle and ITxeExecutionOracle) and is used to + * This hash is computed from the TXE-specific entries in `TXE_ORACLE_REGISTRY` and is used to * detect when those interfaces change. When it does, bump: * - TXE_ORACLE_VERSION_MAJOR (and reset MINOR to 0) for breaking changes, or * - TXE_ORACLE_VERSION_MINOR for additive changes (new oracle method added). */ -export const TXE_ORACLE_INTERFACE_HASH = '52ed86292711cb13cb64d333207e0c1993c008f1aa5d68cab4f6b67a9ce29e8e'; +export const TXE_ORACLE_INTERFACE_HASH = '1a6c578d0a47cd8dd7dae07fd00abe781225a73920c596d873afcc061c0e4284';