From 482a6b86532bb8fb5f83c57d37230025c581fcce Mon Sep 17 00:00:00 2001 From: aminsammara Date: Thu, 9 Jul 2026 09:50:05 +0000 Subject: [PATCH 1/4] 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. --- 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 8a3f7e6b63ccce028fef95faa7a2a25423a4b158 Mon Sep 17 00:00:00 2001 From: Martin Verzilli Date: Thu, 9 Jul 2026 14:21:44 +0200 Subject: [PATCH 2/4] 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 --- .../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 9055e2864ea5..4672d421cfc9 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()}.`); @@ -509,16 +512,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; @@ -664,7 +667,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(); } @@ -1077,9 +1080,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] => { @@ -1113,7 +1114,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 736f39189c0beb73ef4a514e7dfa09cfdb84ec3f Mon Sep 17 00:00:00 2001 From: Maxim Vezenov Date: Thu, 9 Jul 2026 10:02:13 -0400 Subject: [PATCH 3/4] 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) ## What changed 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. ## Migration strategy (breaking) 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. ## Tests - `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). --- .../docs/resources/migration_notes.md | 6 ++ .../__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 ++--- 9 files changed, 137 insertions(+), 64 deletions(-) diff --git a/docs/docs-developers/docs/resources/migration_notes.md b/docs/docs-developers/docs/resources/migration_notes.md index 821acf763150..0ff37ad3c221 100644 --- a/docs/docs-developers/docs/resources/migration_notes.md +++ b/docs/docs-developers/docs/resources/migration_notes.md @@ -9,6 +9,12 @@ Aztec is in active development. Each version may introduce breaking changes that ## TBD +### [PXE] Local PXE database is reset on upgrade + +The persisted tagging stores now key every entry by the self-describing `::` form of `AppTaggingSecret`; unconstrained secrets previously used a two-part `:` key. This bumps the PXE data schema version, and there is no forward migration for the old keys: on first open the PXE clears any database whose stored schema version differs from the current one. The wipe resets the entire PXE store, not just the tagging data, because all of it shares one backing database. + +**Impact**: On upgrade your local PXE state is reset. You must re-register accounts and re-sync from genesis. Wallets should surface a "your local state was reset, please re-register accounts and re-sync" path. + ### [Aztec.nr] `TestEnvironmentOptions::with_tagging_secret_strategy` replaced `TestEnvironmentOptions::with_tagging_secret_strategy` is now `with_default_tag_secret_strategy_all_modes` for tests 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 296717987f169251ade417a503e39395da7a4520 Mon Sep 17 00:00:00 2001 From: Martin Verzilli Date: Thu, 9 Jul 2026 17:03:00 +0200 Subject: [PATCH 4/4] 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 --- .../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 6e5c956302f8..3f7e6838ef02 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 @@ -741,6 +741,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 4672d421cfc9..983df2003f32 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 @@ -273,6 +273,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 0a8cbb71759f..4190dc9c65d4 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({