diff --git a/yarn-project/aztec.js/src/contract/wait_opts.ts b/yarn-project/aztec.js/src/contract/wait_opts.ts index bf7dfeae2b0f..90b9391b5e55 100644 --- a/yarn-project/aztec.js/src/contract/wait_opts.ts +++ b/yarn-project/aztec.js/src/contract/wait_opts.ts @@ -4,7 +4,7 @@ import type { TxStatus } from '@aztec/stdlib/tx'; export type WaitOpts = { /** The amount of time to ignore TxStatus.DROPPED receipts (in seconds) due to the presumption that it is being propagated by the p2p network. Defaults to 5. */ ignoreDroppedReceiptsFor?: number; - /** The maximum time (in seconds) to wait for the transaction to be mined. Defaults to 60. */ + /** The maximum time (in seconds) to wait for the transaction to be mined. Defaults to 300 (5 min). */ timeout?: number; /** The time interval (in seconds) between retries to fetch the transaction receipt. Defaults to 1. */ interval?: number; @@ -12,6 +12,11 @@ export type WaitOpts = { dontThrowOnRevert?: boolean; /** The minimum inclusion status to wait for. If set, waits until the receipt reaches this status or higher. Defaults to CHECKPOINTED. */ waitForStatus?: TxStatus; + /** + * The time (in seconds) to wait before the first receipt poll. Defaults to 0. Used to avoid checking for a receipt + * right after sending a tx, when we know it cannot have been mined yet. Counts against `timeout`. + */ + initialDelay?: number; }; export const DefaultWaitOpts: WaitOpts = { diff --git a/yarn-project/aztec.js/src/utils/node.test.ts b/yarn-project/aztec.js/src/utils/node.test.ts index deec8998aba5..99ec6aac4277 100644 --- a/yarn-project/aztec.js/src/utils/node.test.ts +++ b/yarn-project/aztec.js/src/utils/node.test.ts @@ -78,6 +78,45 @@ describe('waitForTx', () => { }); }); + describe('initialDelay option', () => { + it('delays the first receipt poll', async () => { + const start = Date.now(); + let firstPollAt: number | undefined; + node.getTxReceipt.mockImplementation(() => { + firstPollAt ??= Date.now(); + return Promise.resolve(minedReceipt(TxStatus.CHECKPOINTED)); + }); + + const receipt = await waitForTx(node, txHash, { timeout: 1, interval: 0.05, initialDelay: 0.2 }); + + expect(receipt.status).toBe(TxStatus.CHECKPOINTED); + expect(node.getTxReceipt).toHaveBeenCalledTimes(1); + expect(firstPollAt! - start).toBeGreaterThanOrEqual(150); + }); + + it('does not consume the dropped-receipt grace period', async () => { + node.getTxReceipt.mockResolvedValue(new DroppedTxReceipt(txHash, 'Tx dropped')); + const start = Date.now(); + + await expect( + waitForTx(node, txHash, { timeout: 2, interval: 0.05, initialDelay: 0.3, ignoreDroppedReceiptsFor: 0.2 }), + ).rejects.toThrow(/dropped/); + + expect(Date.now() - start).toBeGreaterThanOrEqual(450); + }); + + it('counts against the timeout', async () => { + node.getTxReceipt.mockResolvedValue(new PendingTxReceipt(txHash, undefined)); + const start = Date.now(); + + await expect(waitForTx(node, txHash, { timeout: 0.3, interval: 0.05, initialDelay: 1 })).rejects.toThrow( + /Timeout/, + ); + + expect(Date.now() - start).toBeLessThan(1000); + }); + }); + describe('waitForStatus option', () => { it('returns immediately when receipt status matches requested status', async () => { node.getTxReceipt.mockResolvedValue(minedReceipt(TxStatus.CHECKPOINTED)); diff --git a/yarn-project/aztec.js/src/utils/node.ts b/yarn-project/aztec.js/src/utils/node.ts index 7f92a1a77948..3b2e65fc1923 100644 --- a/yarn-project/aztec.js/src/utils/node.ts +++ b/yarn-project/aztec.js/src/utils/node.ts @@ -1,5 +1,6 @@ import type { Logger } from '@aztec/foundation/log'; import { retryUntil } from '@aztec/foundation/retry'; +import { sleep } from '@aztec/foundation/sleep'; import type { AztecNode } from '@aztec/stdlib/interfaces/client'; import type { TxHash, TxReceipt } from '@aztec/stdlib/tx'; import { SortedTxStatuses, TxStatus } from '@aztec/stdlib/tx'; @@ -47,9 +48,20 @@ function hasReachedStatus(receipt: TxReceipt, desiredStatus: TxStatus): boolean * @throws If the transaction fails and dontThrowOnRevert is not set */ export async function waitForTx(node: AztecNode, txHash: TxHash, opts?: WaitOpts): Promise { - const startTime = Date.now(); const ignoreDroppedReceiptsFor = opts?.ignoreDroppedReceiptsFor ?? DefaultWaitOpts.ignoreDroppedReceiptsFor; const waitForStatus = opts?.waitForStatus ?? TxStatus.CHECKPOINTED; + const timeout = opts?.timeout ?? DefaultWaitOpts.timeout; + // The initial delay counts against the timeout: the deadline is fixed before sleeping, and the sleep never + // exceeds it. + const deadline = timeout ? { deadline: new Date(Date.now() + timeout * 1000) } : undefined; + + if (opts?.initialDelay) { + const delay = Math.min(opts.initialDelay, timeout ?? Infinity); + await sleep(delay * 1000); + } + + // The grace period for DROPPED receipts starts at the first poll, so the initial delay does not consume it. + const startTime = Date.now(); const receipt = await retryUntil( async () => { @@ -75,7 +87,7 @@ export async function waitForTx(node: AztecNode, txHash: TxHash, opts?: WaitOpts return txReceipt; }, 'isMined', - opts?.timeout ?? DefaultWaitOpts.timeout, + deadline, opts?.interval ?? DefaultWaitOpts.interval, ); diff --git a/yarn-project/aztec.js/src/wallet/wallet.ts b/yarn-project/aztec.js/src/wallet/wallet.ts index 40b1f444c132..0337d2a24a79 100644 --- a/yarn-project/aztec.js/src/wallet/wallet.ts +++ b/yarn-project/aztec.js/src/wallet/wallet.ts @@ -333,6 +333,7 @@ export const WaitOptsSchema = z.object({ timeout: optional(z.number()), interval: optional(z.number()), dontThrowOnRevert: optional(z.boolean()), + initialDelay: optional(z.number()), }); const FromSchema = z.union([schemas.AztecAddress, z.literal(NO_FROM)]); diff --git a/yarn-project/end-to-end/src/automine/double_spend.test.ts b/yarn-project/end-to-end/src/automine/double_spend.test.ts index edf0d35e6b7f..f9cf70e9c7a6 100644 --- a/yarn-project/end-to-end/src/automine/double_spend.test.ts +++ b/yarn-project/end-to-end/src/automine/double_spend.test.ts @@ -1,6 +1,7 @@ import { AztecAddress } from '@aztec/aztec.js/addresses'; import { Fr } from '@aztec/aztec.js/fields'; import type { Logger } from '@aztec/aztec.js/log'; +import type { AztecNode } from '@aztec/aztec.js/node'; import { TxExecutionResult } from '@aztec/aztec.js/tx'; import type { Wallet } from '@aztec/aztec.js/wallet'; import { TestContract } from '@aztec/noir-test-contracts.js/Test'; @@ -11,6 +12,7 @@ import { AutomineTestContext } from './automine_test_context.js'; // Uses setup(1, AUTOMINE_E2E_OPTS) with one node, automine sequencer, one funded account. describe('automine/double_spend', () => { let wallet: Wallet; + let aztecNode: AztecNode; let defaultAccountAddress: AztecAddress; let logger: Logger; @@ -20,12 +22,14 @@ describe('automine/double_spend', () => { beforeAll(async () => { // Setup environment + const ctx = await AutomineTestContext.setup({ numberOfAccounts: 1 }); ({ teardown, wallet, accounts: [defaultAccountAddress], logger, - } = (await AutomineTestContext.setup({ numberOfAccounts: 1 })).context); + } = ctx.context); + aztecNode = ctx.aztecNode; ({ contract } = await TestContract.deploy(wallet).send({ from: defaultAccountAddress })); @@ -53,5 +57,16 @@ describe('automine/double_spend', () => { contract.methods.emit_nullifier_public(nullifier).send({ from: defaultAccountAddress }), ).rejects.toThrow(TxExecutionResult.REVERTED); }); + + it('rejects re-sending a tx that was already mined', async () => { + const { receipt } = await contract.methods.emit_nullifier_public(new Fr(2)).send({ + from: defaultAccountAddress, + }); + + const minedTx = await aztecNode.getTxByHash(receipt.txHash, { includeProof: true }); + expect(minedTx).toBeDefined(); + + await expect(aztecNode.sendTx(minedTx!)).rejects.toThrow(/Existing nullifier/); + }); }); }); diff --git a/yarn-project/pxe/src/block_synchronizer/block_synchronizer.test.ts b/yarn-project/pxe/src/block_synchronizer/block_synchronizer.test.ts index f7bffe2afae6..1255c8ced5d5 100644 --- a/yarn-project/pxe/src/block_synchronizer/block_synchronizer.test.ts +++ b/yarn-project/pxe/src/block_synchronizer/block_synchronizer.test.ts @@ -24,8 +24,8 @@ import { TxHash } from '@aztec/stdlib/tx'; import { type MockProxy, mock } from 'jest-mock-extended'; import type { BlockSynchronizerConfig } from '../config/index.js'; -import type { ContractClassService } from '../contract/contract_class_service.js'; import type { ContractSyncService } from '../contract/contract_sync_service.js'; +import { type CachingAztecNode, withCache } from '../node/caching_aztec_node.js'; import { AnchorBlockStore } from '../storage/anchor_block_store/anchor_block_store.js'; import { FactStore } from '../storage/fact_store/fact_store.js'; import { FactCollectionKey, FactCollectionTypeKey } from '../storage/fact_store/fact_store_keys.js'; @@ -51,7 +51,7 @@ describe('BlockSynchronizer', () => { let getBlock: NodeGetBlockMock; let blockStream: MockProxy; let contractSyncService: MockProxy; - let contractClassService: MockProxy; + let cachedNode: CachingAztecNode; const TestSynchronizer = class extends BlockSynchronizer { protected override createBlockStream(): L2BlockStream { @@ -61,7 +61,7 @@ describe('BlockSynchronizer', () => { const createSynchronizer = (config: Partial = {}) => { return new TestSynchronizer( - aztecNode, + cachedNode, store, anchorBlockStore, noteStore, @@ -69,7 +69,6 @@ describe('BlockSynchronizer', () => { factStore, tipsStore, contractSyncService, - contractClassService, config, ); }; @@ -130,7 +129,7 @@ describe('BlockSynchronizer', () => { privateEventStore = new PrivateEventStore(store); factStore = new FactStore(store); contractSyncService = mock(); - contractClassService = mock(); + cachedNode = withCache(aztecNode); synchronizer = createSynchronizer(); }); @@ -169,13 +168,23 @@ describe('BlockSynchronizer', () => { expect(obtainedHeader.equals(block.header)).toBe(true); }); - it('wipes the contract sync and contract class caches when the anchor block changes', async () => { + it('wipes the contract sync and node read caches when the anchor block changes', async () => { const block = await L2Block.random(BlockNumber(1)); await serveBlockDataByHash(block); + const referenceBlock = BlockHash.random(); + const contractAddress = await AztecAddress.random(); + const storageSlot = Fr.random(); + aztecNode.getPublicStorageAt.mockResolvedValue(new Fr(1)); + await cachedNode.getPublicStorageAt(referenceBlock, contractAddress, storageSlot); + await cachedNode.getPublicStorageAt(referenceBlock, contractAddress, storageSlot); + expect(aztecNode.getPublicStorageAt).toHaveBeenCalledTimes(1); + await synchronizer.handleBlockStreamEvent(await proposedEvent(block)); expect(contractSyncService.wipe).toHaveBeenCalled(); - expect(contractClassService.wipe).toHaveBeenCalled(); + // The anchor update wiped the node read cache: the same read reaches the node again. + await cachedNode.getPublicStorageAt(referenceBlock, contractAddress, storageSlot); + expect(aztecNode.getPublicStorageAt).toHaveBeenCalledTimes(2); }); it('updates anchor block on a reorg', async () => { @@ -801,7 +810,7 @@ describe('BlockSynchronizer', () => { ); realSynchronizer = new BlockSynchronizer( - aztecNode, + withCache(aztecNode), store, anchorBlockStore, noteStore, @@ -809,7 +818,6 @@ describe('BlockSynchronizer', () => { factStore, tipsStore, contractSyncService, - contractClassService, { syncChainTip: 'proposed' }, ); }); diff --git a/yarn-project/pxe/src/block_synchronizer/block_synchronizer.ts b/yarn-project/pxe/src/block_synchronizer/block_synchronizer.ts index 5b1e2a9b9285..c2dae73bd2ad 100644 --- a/yarn-project/pxe/src/block_synchronizer/block_synchronizer.ts +++ b/yarn-project/pxe/src/block_synchronizer/block_synchronizer.ts @@ -4,12 +4,11 @@ import { SerialQueue } from '@aztec/foundation/queue'; import type { AztecAsyncKVStore } from '@aztec/kv-store'; import type { L2TipsKVStore } from '@aztec/kv-store/stores'; import { BlockHash, L2BlockStream, type L2BlockStreamEvent, type L2BlockStreamEventHandler } from '@aztec/stdlib/block'; -import type { AztecNode } from '@aztec/stdlib/interfaces/client'; import type { BlockHeader } from '@aztec/stdlib/tx'; import type { BlockSynchronizerConfig } from '../config/index.js'; -import type { ContractClassService } from '../contract/contract_class_service.js'; import type { ContractSyncService } from '../contract/contract_sync_service.js'; +import type { CachingAztecNode } from '../node/caching_aztec_node.js'; import type { AnchorBlockStore } from '../storage/anchor_block_store/index.js'; import type { FactStore } from '../storage/fact_store/fact_store.js'; import type { NoteStore } from '../storage/note_store/index.js'; @@ -28,7 +27,7 @@ export class BlockSynchronizer implements L2BlockStreamEventHandler { protected readonly blockStream: L2BlockStream; constructor( - private readonly node: AztecNode, + private readonly node: CachingAztecNode, private readonly store: AztecAsyncKVStore, private readonly anchorBlockStore: AnchorBlockStore, private readonly noteStore: NoteStore, @@ -36,7 +35,6 @@ export class BlockSynchronizer implements L2BlockStreamEventHandler { private readonly factStore: FactStore, private readonly l2TipsStore: L2TipsKVStore, private readonly contractSyncService: ContractSyncService, - private readonly contractClassService: ContractClassService, private readonly config: Partial = {}, bindings?: LoggerBindings, ) { @@ -180,10 +178,9 @@ export class BlockSynchronizer implements L2BlockStreamEventHandler { // execution. this.contractSyncService.wipe(); - // The contract class service keeps a per-block cache - since updating our anchor means it is very unlikely we'd - // ever re-simulate at past anchors, we wipe its cache to prevent runaway memory growth on very long-lived PXE - // instances. - this.contractClassService.wipe(); + // Cached node reads stay correct indefinitely, so this wipe is only about bounding memory growth. An anchor + // update is the recurring trigger PXE already has. + this.node.wipeCache(); this.log.verbose(`Updated pxe last block to ${blockHeader.getBlockNumber()}`, blockHeader.toInspect()); await this.anchorBlockStore.setHeader(blockHeader); diff --git a/yarn-project/pxe/src/contract/contract_class_service.test.ts b/yarn-project/pxe/src/contract/contract_class_service.test.ts index c38fa53890ce..8b2f9770f043 100644 --- a/yarn-project/pxe/src/contract/contract_class_service.test.ts +++ b/yarn-project/pxe/src/contract/contract_class_service.test.ts @@ -68,27 +68,6 @@ describe('ContractClassService', () => { expect(await service.getCurrentClassId(address, anchorWithHash(hashB))).toEqual(classAtB); }); - it('caches per (address, anchor) so the node is queried once per anchor', async () => { - const hash = new BlockHash(new Fr(1n)); - node.getContract.mockResolvedValue({ currentContractClassId: new Fr(7n) } as ContractInstanceWithAddress); - - await service.getCurrentClassId(address, anchorWithHash(hash)); - await service.getCurrentClassId(address, anchorWithHash(hash)); - - expect(node.getContract).toHaveBeenCalledTimes(1); - }); - - it('re-queries after wipe', async () => { - const hash = new BlockHash(new Fr(1n)); - node.getContract.mockResolvedValue({ currentContractClassId: new Fr(7n) } as ContractInstanceWithAddress); - - await service.getCurrentClassId(address, anchorWithHash(hash)); - service.wipe(); - await service.getCurrentClassId(address, anchorWithHash(hash)); - - expect(node.getContract).toHaveBeenCalledTimes(2); - }); - it('falls back to the original class when the node does not know the instance', async () => { node.getContract.mockResolvedValue(undefined); diff --git a/yarn-project/pxe/src/contract/contract_class_service.ts b/yarn-project/pxe/src/contract/contract_class_service.ts index 457f964c994e..e1ce66f73973 100644 --- a/yarn-project/pxe/src/contract/contract_class_service.ts +++ b/yarn-project/pxe/src/contract/contract_class_service.ts @@ -14,14 +14,6 @@ import type { ContractStore } from '../storage/contract_store/contract_store.js' * falling back to a local instance if no upgrades were scheduled. */ export class ContractClassService { - /** - * Class ids are cached per `(address, anchorHash)`. This avoid unnecessary network roundtrips in scenarios where - * multiple executions are done on the same anchor block (e.g. simulation followed by witgen), or when the same - * contract is invoked multiple times in an execution (e.g. authwit checks). - * It also means the callers don't need to worry about caching this service's return values, simplifying callsites. - */ - #cache: Map> = new Map(); - constructor( private node: AztecNode, private contractStore: ContractStore, @@ -40,44 +32,14 @@ export class ContractClassService { return instance?.originalContractClassId; } - const key = `${address.toString()}:${(await anchorBlockHeader.hash()).toString()}`; - let promise = this.#cache.get(key); - if (!promise) { - promise = (async () => { - // The node resolves the current class from the same scheduled value change the AVM enforces against the public - // data tree. If the contract was upgraded the node returns a non-undefined instance; an undefined result means - // no upgrade happened (or the node has no record of it, e.g. it was never publicly deployed), so the original - // class is current. - - const nodeInstance = await this.node.getContract(address, await anchorBlockHeader.hash()); - - if (nodeInstance) { - return nodeInstance.currentContractClassId; - } else { - return (await this.contractStore.getContractInstance(address))?.originalContractClassId; - } - })().catch(err => { - this.#cache.delete(key); - throw err; - }); - this.#cache.set(key, promise); - } - - const classId = await promise; - if (classId === undefined) { - // Don't memoize a missing instance: it may be registered later in this PXE (e.g. via contract sync - // mid-execution), and a cached miss would then hide it. Only successful resolutions stay cached. - this.#cache.delete(key); + // The node resolves the current class from the same scheduled value change the AVM enforces against the public + // data tree. If the contract was upgraded the node returns a non-undefined instance; an undefined result means no + // upgrade happened (or the node has no record of it, e.g. it was never publicly deployed), so the original class + // is current. + const nodeInstance = await this.node.getContract(address, await anchorBlockHeader.hash()); + if (nodeInstance) { + return nodeInstance.currentContractClassId; } - return classId; - } - - /** - * Clears the cache. - * - * This is not required for correctness, only to limit how much memory the cache uses. The cache is resilient against - * reorgs etc. as it is based on block hashes, not block numbers. */ - wipe(): void { - this.#cache.clear(); + return (await this.contractStore.getContractInstance(address))?.originalContractClassId; } } 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 deleted file mode 100644 index 133602298e3c..000000000000 --- a/yarn-project/pxe/src/contract_function_simulator/aztec_node_read_cache.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -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 deleted file mode 100644 index ce7ff940fe61..000000000000 --- a/yarn-project/pxe/src/contract_function_simulator/aztec_node_read_cache.ts +++ /dev/null @@ -1,89 +0,0 @@ -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/benchmarked_node.ts b/yarn-project/pxe/src/contract_function_simulator/benchmarked_node.ts deleted file mode 100644 index 7d0582b26704..000000000000 --- a/yarn-project/pxe/src/contract_function_simulator/benchmarked_node.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { Timer } from '@aztec/foundation/timer'; -import type { AztecNode } from '@aztec/stdlib/interfaces/client'; -import type { NodeStats, RoundTripStats } from '@aztec/stdlib/tx'; - -/* - * Proxy generator for an AztecNode that tracks the time taken for each RPC call and the number of round trips (actual - * blocking waits for node responses). - * - * A round trip is counted when we transition from 0 to 1 in-flight calls, and ends when all concurrent calls complete. - * This means parallel calls in Promise.all count as a single round trip. - * - * Note that batching of RPC calls in `safe_json_rpc_client.ts` could affect the round trip counts but in places we - * currently use this information we do not even use HTTP as we have direct access to the Aztec Node instance in TS - * (i.e. not running against external node) so this is not a problem for now. - * - * If you want to use this against external node and the info gets skewed by batching you can set the `maxBatchSize` - * value in `safe_json_rpc_client.ts` to 1 (the main motivation for batching was to get around parallel http requests - * limits in web browsers which is not a problem when debugging in node.js). - */ -export type BenchmarkedNode = AztecNode & { getStats(): NodeStats }; - -export class BenchmarkedNodeFactory { - static create(node: AztecNode): BenchmarkedNode { - // Per-method call stats - const perMethod: Partial> = {}; - - // Round trip tracking - let inFlightCount = 0; - let currentRoundTripTimer: Timer | null = null; - let currentRoundTripMethods: string[] = []; - const roundTrips: RoundTripStats = { - roundTrips: 0, - totalBlockingTime: 0, - roundTripDurations: [], - roundTripMethods: [], - }; - - return new Proxy(node, { - get(target, prop: keyof BenchmarkedNode) { - if (prop === 'getStats') { - return (): NodeStats => { - return { perMethod, roundTrips }; - }; - } else { - return function (...args: any[]) { - // Track per-method stats - if (!perMethod[prop]) { - perMethod[prop] = { times: [] }; - } - - // Start of a new round trip batch? - if (inFlightCount === 0) { - roundTrips.roundTrips++; - currentRoundTripTimer = new Timer(); - currentRoundTripMethods = []; - } - inFlightCount++; - currentRoundTripMethods.push(prop); - - const callTimer = new Timer(); - const result = (target[prop] as any).apply(target, args); - - // Handle completion - called when the call finishes (after Promise resolves) - const handleCompletion = () => { - const callTime = callTimer.ms(); - perMethod[prop]!.times.push(callTime); - - inFlightCount--; - - // End of round trip batch - all concurrent calls completed - if (inFlightCount === 0 && currentRoundTripTimer) { - const roundTripTime = currentRoundTripTimer.ms(); - roundTrips.totalBlockingTime += roundTripTime; - roundTrips.roundTripDurations.push(roundTripTime); - roundTrips.roundTripMethods.push(currentRoundTripMethods); - currentRoundTripTimer = null; - currentRoundTripMethods = []; - } - }; - - // If the result is a Promise, chain the completion handler - if (result && typeof result.then === 'function') { - return result.then( - (value: any) => { - handleCompletion(); - return value; - }, - (error: any) => { - handleCompletion(); - throw error; - }, - ); - } else { - // Synchronous method - handle completion immediately - handleCompletion(); - return result; - } - }; - } - }, - }) as BenchmarkedNode; - } -} diff --git a/yarn-project/pxe/src/contract_function_simulator/contract_function_simulator.ts b/yarn-project/pxe/src/contract_function_simulator/contract_function_simulator.ts index a69c2781eae1..feafb6fd0ddd 100644 --- a/yarn-project/pxe/src/contract_function_simulator/contract_function_simulator.ts +++ b/yarn-project/pxe/src/contract_function_simulator/contract_function_simulator.ts @@ -108,7 +108,6 @@ import type { RecipientTaggingStore } from '../storage/tagging_store/recipient_t import type { SenderTaggingStore } from '../storage/tagging_store/sender_tagging_store.js'; import type { TaggingSecretSourcesStore } from '../storage/tagging_store/tagging_secret_sources_store.js'; import { AnchoredContractData } from './anchored_contract_data.js'; -import type { BenchmarkedNode } from './benchmarked_node.js'; import { ExecutionNoteCache } from './execution_note_cache.js'; import { ExecutionTaggingIndexCache } from './execution_tagging_index_cache.js'; import { HashedValuesCache } from './hashed_values_cache.js'; @@ -443,22 +442,6 @@ export class ContractFunctionSimulator { throw createSimulationError(err instanceof Error ? err : new Error('Unknown error during private execution')); } } - - /** - * Returns the execution statistics collected during the simulator run. - * @returns The execution statistics. - */ - getStats() { - const nodeRPCCalls = - typeof (this.aztecNode as BenchmarkedNode).getStats === 'function' - ? (this.aztecNode as BenchmarkedNode).getStats() - : { - perMethod: {}, - roundTrips: { roundTrips: 0, totalBlockingTime: 0, roundTripDurations: [], roundTripMethods: [] }, - }; - - return { nodeRPCCalls }; - } } class OrderedSideEffect { 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 774a4ead6349..94fe9df98819 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 @@ -38,7 +38,7 @@ import { 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 { logQueryAnchorOf, syncSenderTaggingIndexes } from '../../tagging/index.js'; import type { ExecutionNoteCache } from '../execution_note_cache.js'; import { ExecutionTaggingIndexCache } from '../execution_tagging_index_cache.js'; import type { HashedValuesCache } from '../hashed_values_cache.js'; @@ -375,16 +375,16 @@ export class PrivateExecutionOracle extends UtilityExecutionOracle implements IP // This is a tagging secret we've not yet used in this tx, so first sync our store to make sure its indices // are up to date. We do this here because this store is not synced as part of the global sync because // that'd be wasteful as most tagging secrets are not used in each tx. - const [{ finalized }, anchorBlockHash] = await Promise.all([ + const [{ finalized }, anchor] = await Promise.all([ this.l2TipsStore.getL2Tips(), - this.anchorBlockHeader.hash(), + logQueryAnchorOf(this.anchorBlockHeader), ]); await syncSenderTaggingIndexes( secret, this.aztecNode, this.senderTaggingStore, finalized.block.number, - anchorBlockHash, + anchor, this.jobId, ); 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 d883e0d57aeb..f22cd6106787 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 @@ -28,6 +28,7 @@ import { type ContractInstanceWithAddress, computeContractAddressFromInstance, } from '@aztec/stdlib/contract'; +import { computeUniqueNoteHash, siloNoteHash } from '@aztec/stdlib/hash'; import type { AztecNode } from '@aztec/stdlib/interfaces/server'; import { PublicKeys, deriveKeys, hashPublicKey } from '@aztec/stdlib/keys'; import { AppTaggingSecret, AppTaggingSecretKind, SiloedTag } from '@aztec/stdlib/logs'; @@ -69,6 +70,8 @@ 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'; import { EphemeralArray } from '../noir-structs/ephemeral_array.js'; +import type { EventValidationRequest } from '../noir-structs/event_validation_request.js'; +import { NoteValidationRequest } from '../noir-structs/note_validation_request.js'; import { Option } from '../noir-structs/option.js'; import type { ProvidedSecret } from '../noir-structs/provided_secret.js'; import { TransientArrayService } from '../transient_array_service.js'; @@ -702,7 +705,28 @@ describe('Utility Execution test suite', () => { }); }); - describe('node read cache', () => { + describe('areBlockHashesInArchive', () => { + it('maps archive membership to booleans by position', async () => { + const service = new EphemeralArrayService(); + 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 utilityExecutionOracle.areBlockHashesInArchive( + referenceBlockHash, + EphemeralArray.fromValues(service, [presentBlockHash, missingBlockHash, presentBlockHash]), + ); + + expect(result.readAll(service)).toEqual([true, false, true]); + }); + }); + + describe('getTxEffects', () => { const makeTxEffect = (txHash: TxHash) => TxEffect.from({ ...TxEffect.empty(), txHash }); const makeMinedReceipt = ( txHash: TxHash, @@ -807,54 +831,106 @@ describe('Utility Execution test suite', () => { 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(); - 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); - }); + describe('validateAndStoreEnqueuedNotesAndEvents', () => { + const service = new EphemeralArrayService(); - 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); + it('validates notes of a tx seen by log retrieval without reading its receipt', async () => { + const oracle = makeOracle({ contractAddress, scopes: [owner] }); + const txHash = TxHash.random(); + const { request, uniqueNoteHash } = await makeNoteRequest(txHash); - aztecNode.getBlockHashMembershipWitness.mockImplementation((_referenceBlockHash, blockHash) => - Promise.resolve(blockHash.equals(presentBlockHash) ? witness : undefined), + const secret = Fr.random(); + const mode = AppTaggingSecretKind.CONSTRAINED; + const tag = await SiloedTag.compute({ + extendedSecret: new AppTaggingSecret(secret, contractAddress, mode), + index: 0, + }); + const log = { + logData: [tag.value, Fr.random()], + blockNumber: anchorBlockHeader.globalVariables.blockNumber, + blockHash: await anchorBlockHeader.hash(), + blockTimestamp: anchorBlockHeader.globalVariables.timestamp, + txHash, + txIndexWithinBlock: 3, + logIndexWithinTx: 0, + noteHashes: [uniqueNoteHash], + nullifiers: [Fr.random()], + }; + aztecNode.getPrivateLogsByTags.mockImplementation(query => + Promise.resolve(query.tags.map(entry => (('tag' in entry ? entry.tag : entry).equals(tag) ? [log] : []))), ); - const result = await oracle.areBlockHashesInArchive( - referenceBlockHash, - EphemeralArray.fromValues(service, [presentBlockHash, missingBlockHash, presentBlockHash]), + await oracle.getPendingTaggedLogsV2( + owner, + EphemeralArray.fromValues(service, [{ secret, mode }]), + ); + await oracle.validateAndStoreEnqueuedNotesAndEvents( + EphemeralArray.fromValues(service, [request]), + EphemeralArray.fromValues(service, []), + owner, ); - expect(result.readAll(service)).toEqual([true, false, true]); - expect(aztecNode.getBlockHashMembershipWitness).toHaveBeenCalledTimes(2); + expect(aztecNode.getTxReceipt).not.toHaveBeenCalled(); + const [storedNotes] = noteStore.addNotes.mock.calls[0]; + expect(storedNotes.map(note => [note.noteHash, note.l2BlockNumber, note.txIndexInBlock])).toEqual([ + [request.noteHash, log.blockNumber, log.txIndexWithinBlock], + ]); }); - 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)); + it('reads the receipt once for a tx that log retrieval never returned', async () => { + const oracle = makeOracle({ contractAddress, scopes: [owner] }); + const txHash = TxHash.random(); + const first = await makeNoteRequest(txHash); + const second = await makeNoteRequest(txHash); + aztecNode.getTxReceipt.mockResolvedValue( + makeMinedReceiptWithNoteHashes(txHash, [first.uniqueNoteHash, second.uniqueNoteHash]), + ); - const first = await oracle.getFromPublicStorage(blockHash, contractAddress, startStorageSlot, 2); - const second = await oracle.getFromPublicStorage(blockHash, contractAddress, startStorageSlot, 2); + await oracle.validateAndStoreEnqueuedNotesAndEvents( + EphemeralArray.fromValues(service, [first.request, second.request]), + EphemeralArray.fromValues(service, []), + owner, + ); - expect(first).toEqual(second); - expect(aztecNode.getPublicStorageAt).toHaveBeenCalledTimes(2); + expect(aztecNode.getTxReceipt).toHaveBeenCalledTimes(1); + const [storedNotes] = noteStore.addNotes.mock.calls[0]; + expect(storedNotes.map(note => note.noteHash)).toEqual([first.request.noteHash, second.request.noteHash]); }); + + async function makeNoteRequest(txHash: TxHash) { + const noteNonce = Fr.random(); + const noteHash = Fr.random(); + const request = new NoteValidationRequest( + contractAddress, + owner, + Fr.random(), + Fr.random(), + noteNonce, + [Fr.random()], + noteHash, + Fr.random(), + txHash, + ); + const uniqueNoteHash = await computeUniqueNoteHash(noteNonce, await siloNoteHash(contractAddress, noteHash)); + return { request, uniqueNoteHash }; + } + + function makeMinedReceiptWithNoteHashes(txHash: TxHash, noteHashes: Fr[]) { + return new MinedTxReceipt( + txHash, + TxStatus.FINALIZED, + TxExecutionResult.SUCCESS, + 0n, + BlockHash.random(), + BlockNumber(syncedBlockNumber), + SlotNumber(1), + 0, + EpochNumber(1), + TxEffect.from({ ...TxEffect.empty(), txHash, noteHashes }), + ); + } }); describe('fact store', () => { 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 af9efcb43d46..c5f7e0470219 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 @@ -1,4 +1,4 @@ -import { ARCHIVE_HEIGHT, type NOTE_HASH_TREE_HEIGHT } from '@aztec/constants'; +import { ARCHIVE_HEIGHT, type NOTE_HASH_TREE_HEIGHT, PRIVATE_LOG_CIPHERTEXT_LEN } from '@aztec/constants'; import type { BlockNumber } from '@aztec/foundation/branded-types'; import { uniqueBy } from '@aztec/foundation/collection'; import { Aes128 } from '@aztec/foundation/crypto/aes128'; @@ -36,20 +36,20 @@ import { type BlockHeader, CallContext, type Capsule, - type IndexedTxEffect, type OffchainEffect, type TxEffect, type TxHash, + type TxReceipt, } from '@aztec/stdlib/tx'; import type { ContractSyncService } from '../../contract/contract_sync_service.js'; import { createContractLogger, logContractMessage, stripAztecnrLogPrefix } from '../../contract_logging.js'; -import { EventService } from '../../events/event_service.js'; +import { EventService, type EventValidationTxData } from '../../events/event_service.js'; import type { UtilityCallAuthorizationRequest } from '../../hooks/authorize_utility_call.js'; import type { ExecutionHooks } from '../../hooks/index.js'; -import { LogService } from '../../logs/log_service.js'; -import { TxResolverService } from '../../messages/tx_resolver_service.js'; -import { NoteService } from '../../notes/note_service.js'; +import { LogService, type RetrievedTaggedLog } from '../../logs/log_service.js'; +import { type TxOnchainContext, TxResolverService } from '../../messages/tx_resolver_service.js'; +import { NoteService, type NoteValidationTxData } 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'; @@ -61,7 +61,6 @@ 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'; @@ -125,7 +124,15 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra private offchainEffects: OffchainEffect[] = []; private readonly ephemeralArrayService = new EphemeralArrayService(); protected readonly transientArrayService: TransientArrayService; - private readonly aztecNodeReadCache: AztecNodeReadCache; + /** Keyed by tx hash string. */ + private readonly txReceiptsCache = new Map>>(); + /** + * Information that can be used to validate the existence of a note or an event, keyed by tx hash string. It is + * populated by the node queries that precede validation (tagged log retrieval, tx resolution), which already return + * everything validation needs, so validating a note or event created in one of those txs costs no node roundtrip. + * Notes and events reached through other paths (e.g. offchain inbox messages) still need a receipt. + */ + private readonly validationTxDataCache = new Map(); // 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; @@ -179,7 +186,6 @@ 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 { @@ -273,7 +279,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.aztecNodeReadCache.getBlockHashMembershipWitness(referenceBlockHash, blockHash), + this.aztecNode.getBlockHashMembershipWitness(referenceBlockHash, blockHash), ); return witness ? Option.some(witness) : Option.none(); } @@ -287,7 +293,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra const memberships = await this.#queryWithBlockHashNotAfterAnchor(referenceBlockHash, () => Promise.all( hashes.map(blockHash => - this.aztecNodeReadCache.getBlockHashMembershipWitness(referenceBlockHash, blockHash).then(Boolean), + this.aztecNode.getBlockHashMembershipWitness(referenceBlockHash, blockHash).then(Boolean), ), ), ); @@ -342,7 +348,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra */ public async getPublicDataWitness(blockHash: BlockHash, leafSlot: Fr): Promise { const witness = await this.#queryWithBlockHashNotAfterAnchor(blockHash, () => - this.aztecNodeReadCache.getPublicDataWitness(blockHash, leafSlot), + this.aztecNode.getPublicDataWitness(blockHash, leafSlot), ); if (!witness) { throw new Error(`Public data witness not found for slot ${leafSlot} at block hash ${blockHash.toString()}.`); @@ -533,11 +539,11 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra numberOfElements: number, ) { return this.#queryWithBlockHashNotAfterAnchor(blockHash, async () => { - const values = await this.aztecNodeReadCache.getPublicStorageRange( - blockHash, - contractAddress, - startStorageSlot, - numberOfElements, + const slots = Array(numberOfElements) + .fill(0) + .map((_, i) => new Fr(startStorageSlot.toBigInt() + BigInt(i))); + const values = await Promise.all( + slots.map(storageSlot => this.aztecNode.getPublicStorageAt(blockHash, contractAddress, storageSlot)), ); this.logger.debug( @@ -604,8 +610,11 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra .map(ps => new AppTaggingSecret(ps.secret, this.contractAddress, ps.mode)); const logService = this.#createLogService(); - const logs = await logService.fetchTaggedLogs(this.contractAddress, scope, secrets); - return EphemeralArray.fromValues(this.ephemeralArrayService, logs); + const retrievedLogs = await logService.fetchTaggedLogs(this.contractAddress, scope, secrets); + + this.#cacheValidationTxData(retrievedLogs); + + return EphemeralArray.fromValues(this.ephemeralArrayService, retrievedLogs.map(toPendingTaggedLog)); } #createLogService(): LogService { @@ -640,7 +649,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra eventValidationRequests: EventValidationRequest[], scope: AztecAddress, ) { - const txEffects = await this.#fetchTxEffects([ + const validationTxData = await this.#getValidationTxData([ ...noteValidationRequests.map(r => r.txHash), ...eventValidationRequests.map(r => r.txHash), ]); @@ -649,8 +658,8 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra const eventService = new EventService(this.anchorBlockHeader, this.aztecNode, this.privateEventStore, this.jobId); await Promise.all([ - noteService.validateAndStoreNotes(noteValidationRequests, scope, txEffects), - eventService.validateAndStoreEvents(eventValidationRequests, scope, txEffects), + noteService.validateAndStoreNotes(noteValidationRequests, scope, validationTxData), + eventService.validateAndStoreEvents(eventValidationRequests, scope, validationTxData), ]); } @@ -660,11 +669,13 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra const logRetrievalRequests = requests.readAll(this.ephemeralArrayService); const logService = this.#createLogService(); - const logRetrievalResponses = await logService.fetchLogsByTag(this.contractAddress, logRetrievalRequests); + const retrievedLogsPerRequest = await logService.fetchLogsByTag(this.contractAddress, logRetrievalRequests); + + this.#cacheValidationTxData(retrievedLogsPerRequest.flat()); // Create an inner ephemeral array for each request's matching logs, then wrap all slots in an outer array. - const innerArrays = logRetrievalResponses.map(responses => - EphemeralArray.fromValues(this.ephemeralArrayService, responses), + const innerArrays = retrievedLogsPerRequest.map(retrievedLogs => + EphemeralArray.fromValues(this.ephemeralArrayService, retrievedLogs.map(toLogRetrievalResponse)), ); return EphemeralArray.fromValues(this.ephemeralArrayService, innerArrays); @@ -676,7 +687,9 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra const resolved = await this.txResolver.resolveTxs(txHashes, this.anchorBlockHeader.getBlockNumber()); - const options = resolved.map(r => (r ? Option.some(r) : Option.none())); + this.#cacheValidationTxData(resolved.filter(tx => tx !== null)); + + const options = resolved.map(tx => (tx ? Option.some(toResolvedTx(tx)) : Option.none())); return EphemeralArray.fromValues(this.ephemeralArrayService, options); } @@ -1110,16 +1123,34 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra return this.offchainEffects; } + /** Stores the onchain context of the given txs, so that validating the notes and events they created is free. */ + #cacheValidationTxData(txs: TxOnchainContext[]) { + txs.forEach(tx => this.validationTxDataCache.set(tx.txHash.toString(), toValidationTxData(tx))); + } + /** - * Fetches tx effects for the given hashes in parallel, deduplicating repeated hashes so each tx is only requested - * once. Returns a map keyed by `TxHash.toString()`; hashes for which the node has no tx effect are omitted. + * Returns the information needed to validate the notes and events created in the given txs, keyed by + * `TxHash.toString()`. Txs already in {@link validationTxDataCache} cost no node request, and the rest are read from + * the node. Txs with no tx effect are absent from the returned map. */ - async #fetchTxEffects(txHashes: TxHash[]): Promise> { - const uniqueTxHashes = uniqueBy(txHashes, h => h.toString()); - const fetched = await Promise.all(uniqueTxHashes.map(h => this.aztecNodeReadCache.getTxReceiptWithEffect(h))); - return new Map( - uniqueTxHashes - .map((h, i): [string, IndexedTxEffect | undefined] => { + async #getValidationTxData(txHashes: TxHash[]): Promise> { + const known: [string, ValidationTxData][] = []; + const misses: TxHash[] = []; + for (const txHash of uniqueBy(txHashes, h => h.toString())) { + const key = txHash.toString(); + const cached = this.validationTxDataCache.get(key); + if (cached) { + known.push([key, cached]); + } else { + misses.push(txHash); + } + } + + const fetched = await Promise.all(misses.map(h => this.#getTxReceiptWithEffect(h))); + return new Map([ + ...known, + ...misses + .map((h, i): [string, ValidationTxData | undefined] => { const receipt = fetched[i]; if (!receipt.isMined() || !receipt.txEffect) { return [h.toString(), undefined]; @@ -1127,20 +1158,41 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra return [ h.toString(), { - data: receipt.txEffect, + noteHashes: receipt.txEffect.noteHashes, + nullifiers: receipt.txEffect.nullifiers, l2BlockNumber: receipt.blockNumber, l2BlockHash: receipt.blockHash, txIndexInBlock: receipt.txIndexInBlock, - slotNumber: receipt.slotNumber, }, ]; }) - .filter((entry): entry is [string, IndexedTxEffect] => entry[1] !== undefined), - ); + .filter((entry): entry is [string, ValidationTxData] => entry[1] !== undefined), + ]); + } + + /** + * Reads a receipt with its effect, at most once per tx for the lifetime of this execution. + * + * A receipt is not cacheable in general, since pending, mined and dropped are all correct answers to the same call + * over time. Within one execution it is: the execution is anchored at a fixed block, and validation runs in several + * batches that name overlapping tx hashes, so re-reading would both cost extra requests and let one execution see a + * tx as included in one batch and absent in the next. + */ + #getTxReceiptWithEffect(txHash: TxHash) { + const key = txHash.toString(); + let receipt = this.txReceiptsCache.get(key); + if (!receipt) { + receipt = this.aztecNode.getTxReceipt(txHash, { includeTxEffect: true }).catch(err => { + this.txReceiptsCache.delete(key); + throw err; + }); + this.txReceiptsCache.set(key, receipt); + } + return receipt; } async #getTxEffectOption(txHash: TxHash): Promise> { - const receipt = await this.aztecNodeReadCache.getTxReceiptWithEffect(txHash); + const receipt = await this.#getTxReceiptWithEffect(txHash); if (!receipt.isMined() || !receipt.txEffect || receipt.blockNumber > this.anchorBlockHeader.getBlockNumber()) { return Option.none(); } @@ -1171,7 +1223,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra const [response] = await Promise.all([ query(), (async () => { - const block = await this.aztecNodeReadCache.getBlock(blockHash); + const block = await this.aztecNode.getBlock(blockHash); const header = block?.header; if (!header) { throw new Error(`Could not find block header for block hash ${blockHash}`); @@ -1240,3 +1292,41 @@ async function isStandardHandshakeRegistryUtilityRead( ); return matches.some(Boolean); } + +function toPendingTaggedLog(retrievedLog: RetrievedTaggedLog): PendingTaggedLog { + return { log: retrievedLog.logData, context: toResolvedTx(retrievedLog) }; +} + +function toResolvedTx(tx: TxOnchainContext): ResolvedTx { + const { txHash, blockNumber, blockHash, noteHashes, nullifiers } = tx; + return { + txHash, + uniqueNoteHashesInTx: noteHashes, + firstNullifierInTx: nullifiers[0], + blockNumber, + blockHash: blockHash.toFr(), + }; +} + +function toLogRetrievalResponse(retrievedLog: RetrievedTaggedLog): LogRetrievalResponse { + const { logData, txHash, blockNumber, blockHash, blockTimestamp, noteHashes, nullifiers } = retrievedLog; + return { + // Skip the tag, and clip to the wire cap: public logs can exceed PRIVATE_LOG_CIPHERTEXT_LEN, which is the fixed + // size of the oracle's BoundedVec slot. A no-op for private logs, which are already within the cap. + logPayload: logData.slice(1, 1 + PRIVATE_LOG_CIPHERTEXT_LEN), + txHash, + uniqueNoteHashesInTx: noteHashes, + firstNullifierInTx: nullifiers[0], + blockNumber, + blockTimestamp, + blockHash, + }; +} + +function toValidationTxData(tx: TxOnchainContext): ValidationTxData { + const { blockNumber, blockHash, txIndexInBlock, noteHashes, nullifiers } = tx; + return { noteHashes, nullifiers, l2BlockNumber: blockNumber, l2BlockHash: blockHash, txIndexInBlock }; +} + +/** The onchain context of a tx served to note and event validation: the union of what the two services read. */ +type ValidationTxData = NoteValidationTxData & EventValidationTxData; diff --git a/yarn-project/pxe/src/events/event_service.test.ts b/yarn-project/pxe/src/events/event_service.test.ts index cabbeffdbd4c..d65303b75cd8 100644 --- a/yarn-project/pxe/src/events/event_service.test.ts +++ b/yarn-project/pxe/src/events/event_service.test.ts @@ -1,4 +1,4 @@ -import { BlockNumber, SlotNumber } from '@aztec/foundation/branded-types'; +import { BlockNumber } from '@aztec/foundation/branded-types'; import { Fr } from '@aztec/foundation/curves/bn254'; import type { Logger } from '@aztec/foundation/log'; import { openTmpStore } from '@aztec/kv-store/lmdb-v2'; @@ -8,13 +8,13 @@ import { BlockHash } from '@aztec/stdlib/block'; import { computePrivateEventCommitment, siloNullifier } from '@aztec/stdlib/hash'; import type { AztecNode } from '@aztec/stdlib/interfaces/server'; import { makeBlockHeader } from '@aztec/stdlib/testing'; -import { type IndexedTxEffect, TxEffect } from '@aztec/stdlib/tx'; +import { TxEffect } from '@aztec/stdlib/tx'; import { mock } from 'jest-mock-extended'; import type { EventValidationRequest } from '../contract_function_simulator/noir-structs/event_validation_request.js'; import { PrivateEventStore } from '../storage/private_event_store/private_event_store.js'; -import { EventService } from './event_service.js'; +import { EventService, type EventValidationTxData } from './event_service.js'; describe('validateAndStoreEvents', () => { let blockNumber: BlockNumber; @@ -24,7 +24,7 @@ describe('validateAndStoreEvents', () => { let eventCommitment: Fr; let eventNullifier: Fr; let txEffect: TxEffect; - let indexedTxEffect: IndexedTxEffect; + let validationTxData: EventValidationTxData; let contractAddress: AztecAddress; let recipient: AztecAddress; @@ -58,12 +58,11 @@ describe('validateAndStoreEvents', () => { nullifiers: [eventNullifier], }); - indexedTxEffect = { + validationTxData = { l2BlockNumber: blockNumber, l2BlockHash: BlockHash.random(), - data: txEffect, + nullifiers: txEffect.nullifiers, txIndexInBlock: 0, - slotNumber: SlotNumber(Number(blockNumber)), }; /* Happy path context conditions: @@ -81,7 +80,7 @@ describe('validateAndStoreEvents', () => { overrides: { eventContent?: Fr[]; eventCommitment?: Fr; - txEffectsMap?: Map; + validationTxDataMap?: Map; } = {}, ) { const request: EventValidationRequest = { @@ -93,21 +92,21 @@ describe('validateAndStoreEvents', () => { txHash: txEffect.txHash, }; - const map = overrides.txEffectsMap ?? defaultTxEffectsMap(); + const map = overrides.validationTxDataMap ?? defaultValidationTxDataMap(); await eventService.validateAndStoreEvents([request], recipient, map); await privateEventStore.commit('test'); } it('should throw when tx does not exist or has no effects', async () => { - const txEffectsMap = new Map(); - await expect(() => runStoreEvent({ txEffectsMap })).rejects.toThrow(/Could not find tx effect for tx hash/); + const validationTxDataMap = new Map(); + await expect(() => runStoreEvent({ validationTxDataMap })).rejects.toThrow(/Could not find tx effect for tx hash/); }); it('should throw when tx block has not yet been synchronized', async () => { - const laterIndexedTxEffect = { ...indexedTxEffect, l2BlockNumber: BlockNumber(blockNumber + 1) }; - const txEffectsMap = new Map([[txEffect.txHash.toString(), laterIndexedTxEffect]]); - await expect(() => runStoreEvent({ txEffectsMap })).rejects.toThrow( + const laterTxData = { ...validationTxData, l2BlockNumber: BlockNumber(blockNumber + 1) }; + const validationTxDataMap = new Map([[txEffect.txHash.toString(), laterTxData]]); + await expect(() => runStoreEvent({ validationTxDataMap })).rejects.toThrow( /Obtained a newer tx effect for .* for an event validation request than the anchor block/, ); }); @@ -160,7 +159,7 @@ describe('validateAndStoreEvents', () => { expect(result[0].packedEvent).toEqual(eventContent); }); - function defaultTxEffectsMap() { - return new Map([[txEffect.txHash.toString(), indexedTxEffect]]); + function defaultValidationTxDataMap() { + return new Map([[txEffect.txHash.toString(), validationTxData]]); } }); diff --git a/yarn-project/pxe/src/events/event_service.ts b/yarn-project/pxe/src/events/event_service.ts index 6f717a22f584..773148b47402 100644 --- a/yarn-project/pxe/src/events/event_service.ts +++ b/yarn-project/pxe/src/events/event_service.ts @@ -1,8 +1,10 @@ +import type { Fr } from '@aztec/foundation/curves/bn254'; import { createLogger } from '@aztec/foundation/log'; import type { AztecAddress } from '@aztec/stdlib/aztec-address'; +import type { InBlock } from '@aztec/stdlib/block'; import { computePrivateEventCommitment, siloNullifier } from '@aztec/stdlib/hash'; import type { AztecNode } from '@aztec/stdlib/interfaces/server'; -import type { BlockHeader, IndexedTxEffect } from '@aztec/stdlib/tx'; +import type { BlockHeader } from '@aztec/stdlib/tx'; import type { EventValidationRequest } from '../contract_function_simulator/noir-structs/event_validation_request.js'; import { PrivateEventStore } from '../storage/private_event_store/private_event_store.js'; @@ -17,17 +19,17 @@ export class EventService { ) {} /** - * Validates and stores a batch of private events against pre-fetched tx effects. + * Validates and stores a batch of private events against the pre-fetched onchain context of their txs. * * @param requests - The events to validate and store. * @param scope - The scope under which the events are being stored. - * @param txEffects - Pre-fetched tx effects keyed by `TxHash.toString()`. Must contain entries for every request's - * txHash; missing entries are treated as a node bug and cause an error. + * @param validationTxData - The onchain context of each request's tx, keyed by `TxHash.toString()`. Must contain + * entries for every request's txHash; missing entries are treated as a node bug and cause an error. */ public async validateAndStoreEvents( requests: EventValidationRequest[], scope: AztecAddress, - txEffects: Map, + validationTxData: ReadonlyMap, ): Promise { if (requests.length === 0) { return; @@ -35,13 +37,15 @@ export class EventService { const anchorBlockNumber = this.anchorBlockHeader.getBlockNumber(); - await Promise.all(requests.map(req => this.#validateAndStoreEvent(req, scope, txEffects, anchorBlockNumber))); + await Promise.all( + requests.map(req => this.#validateAndStoreEvent(req, scope, validationTxData, anchorBlockNumber)), + ); } async #validateAndStoreEvent( request: EventValidationRequest, scope: AztecAddress, - txEffects: Map, + validationTxData: ReadonlyMap, anchorBlockNumber: number, ): Promise { const { @@ -71,15 +75,15 @@ export class EventService { // since `fetchTaggedLogs` only processes logs up to the synced block. const siloedEventCommitment = await siloNullifier(contractAddress, eventCommitment); - const txEffect = txEffects.get(txHash.toString()); - if (!txEffect) { + const txData = validationTxData.get(txHash.toString()); + if (!txData) { // We error out instead of just logging a warning and skipping the event because this would indicate a bug. This // is because the node has already served info about this tx either when obtaining the log (LogResult carries // the tx info) or when getting metadata for the offchain message (before the message got passed to `process_log`). throw new Error(`Could not find tx effect for tx hash ${txHash} when processing an event.`); } - if (txEffect.l2BlockNumber > anchorBlockNumber) { + if (txData.l2BlockNumber > anchorBlockNumber) { // We should never process a message from a tx past the anchor block. If we got here, a preprocessing step made // a mistake. throw new Error( @@ -88,7 +92,7 @@ export class EventService { } // Find the index of the event commitment in the nullifiers array to determine event ordering within the tx - const eventIndexInTx = txEffect.data.nullifiers.findIndex(n => n.equals(siloedEventCommitment)); + const eventIndexInTx = txData.nullifiers.findIndex(n => n.equals(siloedEventCommitment)); if (eventIndexInTx === -1) { // Unlike in NoteService, this might not be a bug since the commitment hasn't been verified yet in the message // processing pipeline. A malformed or malicious message could trigger this condition. Because of this we don't @@ -108,12 +112,18 @@ export class EventService { contractAddress, scope, txHash, - l2BlockNumber: txEffect.l2BlockNumber, - l2BlockHash: txEffect.l2BlockHash, - txIndexInBlock: txEffect.txIndexInBlock, + l2BlockNumber: txData.l2BlockNumber, + l2BlockHash: txData.l2BlockHash, + txIndexInBlock: txData.txIndexInBlock, eventIndexInTx, }, this.jobId, ); } } + +/** The onchain context of the tx an event validation request points at: where it was mined and its nullifiers. */ +export type EventValidationTxData = InBlock & { + nullifiers: Fr[]; + txIndexInBlock: number; +}; diff --git a/yarn-project/pxe/src/logs/log_service.test.ts b/yarn-project/pxe/src/logs/log_service.test.ts index b3dc03c6005b..865fd6ecde15 100644 --- a/yarn-project/pxe/src/logs/log_service.test.ts +++ b/yarn-project/pxe/src/logs/log_service.test.ts @@ -352,7 +352,7 @@ describe('LogService', () => { const logs = await logService.fetchTaggedLogs(contractAddress, recipient, []); - const txHashes = logs.map(l => l.context.txHash); + const txHashes = logs.map(l => l.txHash); expect(txHashes).toContainEqual(unconstrainedLog.txHash); expect(txHashes).toContainEqual(constrainedLog.txHash); }); @@ -374,7 +374,7 @@ describe('LogService', () => { const logs = await logService.fetchTaggedLogs(contractAddress, recipient, []); - const txHashes = logs.map(l => l.context.txHash); + const txHashes = logs.map(l => l.txHash); expect(txHashes).toContainEqual(directionalLog.txHash); expect(txHashes).not.toContainEqual(handshakeStreamLog.txHash); }); @@ -395,7 +395,7 @@ describe('LogService', () => { const logs = await logService.fetchTaggedLogs(contractAddress, recipient, []); - const txHashes = logs.map(l => l.context.txHash); + const txHashes = logs.map(l => l.txHash); expect(txHashes).toContainEqual(handshakeStreamLog.txHash); expect(txHashes).not.toContainEqual(directionalLog.txHash); }); @@ -477,11 +477,15 @@ describe('LogService', () => { const discovered = await logService.fetchTaggedLogs(contractAddress, recipient, []); expect(discovered).toHaveLength(1); - expect(discovered[0].context.txHash).toEqual(senderLog.txHash); + expect(discovered[0].txHash).toEqual(senderLog.txHash); }); }); }); +// Tag queries are bounded at the anchor block, so the anchor has to sit above every `toBlock` these tests pass or +// their forwarding assertions would see a clamped value. +const ANCHOR_BLOCK_ABOVE_TEST_RANGES = BlockNumber(1000); + async function createTestLogService( l2TipsProvider: MockProxy = mock(), scopes: AztecAddress[] = [], @@ -492,7 +496,7 @@ async function createTestLogService( const addressStore = new AddressStore(await openTmpStore('test')); const aztecNode = mock(); // Anchor block header is required for bulkRetrieveLogs. - const anchorBlockHeader = makeBlockHeader(randomInt(1000), { blockNumber: BlockNumber(INITIAL_L2_BLOCK_NUM) }); + const anchorBlockHeader = makeBlockHeader(randomInt(1000), { blockNumber: ANCHOR_BLOCK_ABOVE_TEST_RANGES }); const logService = new LogService( aztecNode, diff --git a/yarn-project/pxe/src/logs/log_service.ts b/yarn-project/pxe/src/logs/log_service.ts index 797458555059..fb0529054f15 100644 --- a/yarn-project/pxe/src/logs/log_service.ts +++ b/yarn-project/pxe/src/logs/log_service.ts @@ -1,10 +1,10 @@ -import { PRIVATE_LOG_CIPHERTEXT_LEN } from '@aztec/constants'; import type { BlockNumber } from '@aztec/foundation/branded-types'; +import type { Fr } from '@aztec/foundation/curves/bn254'; import type { GrumpkinScalar, Point } from '@aztec/foundation/curves/grumpkin'; import { type Logger, type LoggerBindings, createLogger } from '@aztec/foundation/log'; import type { KeyStore } from '@aztec/key-store'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; -import type { BlockHash, L2TipsProvider } from '@aztec/stdlib/block'; +import type { L2TipsProvider } from '@aztec/stdlib/block'; import type { CompleteAddress } from '@aztec/stdlib/contract'; import type { AztecNode } from '@aztec/stdlib/interfaces/server'; import { @@ -15,20 +15,22 @@ import { computeSharedTaggingSecret, } from '@aztec/stdlib/logs'; import type { BlockHeader } from '@aztec/stdlib/tx'; +import type { UInt64 } from '@aztec/stdlib/types'; import { type LogRetrievalRequest, LogSource, } 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 type { TxOnchainContext } from '../messages/tx_resolver_service.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 { + type LogQueryAnchor, getAllPrivateLogsByTags, getAllPublicLogsByTagsFromContract, + logQueryAnchorOf, syncTaggedPrivateLogs, } from '../tagging/index.js'; @@ -58,7 +60,7 @@ export class LogService { public async fetchLogsByTag( contractAddress: AztecAddress, logRetrievalRequests: LogRetrievalRequest[], - ): Promise { + ): Promise { for (const request of logRetrievalRequests) { if (!contractAddress.equals(request.contractAddress)) { throw new Error(`Got a log retrieval request from ${request.contractAddress}, expected ${contractAddress}`); @@ -69,23 +71,23 @@ export class LogService { return []; } - const anchorBlockHash = await this.anchorBlockHeader.hash(); + const anchor = await logQueryAnchorOf(this.anchorBlockHeader); const [publicLogsPerRequest, privateLogsPerRequest] = await Promise.all([ - this.#fetchPublicLogs(contractAddress, logRetrievalRequests, anchorBlockHash), - this.#fetchPrivateLogs(logRetrievalRequests, anchorBlockHash), + this.#fetchPublicLogs(contractAddress, logRetrievalRequests, anchor), + this.#fetchPrivateLogs(logRetrievalRequests, anchor), ]); return logRetrievalRequests.map((_request, i) => [ - ...publicLogsPerRequest[i].map(LogService.#toLogRetrievalResponse), - ...privateLogsPerRequest[i].map(LogService.#toLogRetrievalResponse), + ...publicLogsPerRequest[i].map(LogService.#toRetrievedTaggedLog), + ...privateLogsPerRequest[i].map(LogService.#toRetrievedTaggedLog), ]); } async #fetchPublicLogs( contractAddress: AztecAddress, requests: LogRetrievalRequest[], - anchorBlockHash: BlockHash, + anchor: LogQueryAnchor, ): Promise { const indices = requests.flatMap((r, i) => (r.source !== LogSource.PRIVATE ? [i] : [])); if (indices.length === 0) { @@ -98,13 +100,11 @@ export class LogService { await Promise.all( Array.from(groups.values()).map(async group => { const tags = group.entries.map(e => e.request.tag); - const results = await getAllPublicLogsByTagsFromContract( - this.aztecNode, - contractAddress, - tags, - anchorBlockHash, - { fromBlock: group.fromBlock, toBlock: group.toBlock, includeEffects: true }, - ); + const results = await getAllPublicLogsByTagsFromContract(this.aztecNode, contractAddress, tags, anchor, { + fromBlock: group.fromBlock, + toBlock: group.toBlock, + includeEffects: true, + }); group.entries.forEach((entry, i) => { resultsPerRequest[entry.index] = results[i]; }); @@ -114,7 +114,7 @@ export class LogService { return resultsPerRequest; } - async #fetchPrivateLogs(requests: LogRetrievalRequest[], anchorBlockHash: BlockHash): Promise { + async #fetchPrivateLogs(requests: LogRetrievalRequest[], anchor: LogQueryAnchor): Promise { const indices = requests.flatMap((r, i) => (r.source !== LogSource.PUBLIC ? [i] : [])); if (indices.length === 0) { return requests.map(() => []); @@ -128,7 +128,7 @@ export class LogService { const siloedTags = await Promise.all( group.entries.map(e => SiloedTag.computeFromTagAndApp(e.request.tag, e.request.contractAddress)), ); - const results = await getAllPrivateLogsByTags(this.aztecNode, siloedTags, anchorBlockHash, { + const results = await getAllPrivateLogsByTags(this.aztecNode, siloedTags, anchor, { fromBlock: group.fromBlock, toBlock: group.toBlock, includeEffects: true, @@ -164,7 +164,7 @@ export class LogService { return groups; } - static #toLogRetrievalResponse(log: LogResult): LogRetrievalResponse { + static #toRetrievedTaggedLog(log: LogResult): RetrievedTaggedLog { // includeEffects: true was used, so noteHashes and nullifiers are populated. Every tx has at least one nullifier // (the first nullifier derived from the tx hash); empty here would indicate a buggy node. const noteHashes = log.noteHashes!; @@ -173,23 +173,25 @@ export class LogService { throw new Error(`Log for tx ${log.txHash} returned no nullifiers from the node`); } return { - // Skip the tag, and clip to the wire cap: public logs can exceed PRIVATE_LOG_CIPHERTEXT_LEN, which is the fixed - // size of the oracle's BoundedVec slot. A no-op for private logs, which are already within the cap. - logPayload: log.logData.slice(1, 1 + PRIVATE_LOG_CIPHERTEXT_LEN), + logData: log.logData, txHash: log.txHash, - uniqueNoteHashesInTx: noteHashes, - firstNullifierInTx: nullifiers[0], + noteHashes, + nullifiers, blockNumber: log.blockNumber, blockTimestamp: log.blockTimestamp, blockHash: log.blockHash, + // The log index and the tx receipt both count the tx's position in `block.body.txEffects`, so this is the same + // index a receipt reports. Note ordering depends on the two staying in agreement. + txIndexInBlock: log.txIndexWithinBlock, }; } + /** Fetches the pending tagged logs for a recipient across all its tagging secrets for the contract. */ public async fetchTaggedLogs( contractAddress: AztecAddress, recipient: AztecAddress, providedSecrets: AppTaggingSecret[], - ): Promise { + ): Promise { assertAllowedScope(recipient, this.scopes); this.log.verbose( @@ -216,23 +218,7 @@ export class LogService { this.jobId, ); - return logs.map(log => { - const noteHashes = log.noteHashes!; - const nullifiers = log.nullifiers!; - if (nullifiers.length === 0) { - throw new Error(`Log for tx ${log.txHash} returned no nullifiers from the node`); - } - return { - log: log.logData, - context: { - txHash: log.txHash, - uniqueNoteHashesInTx: noteHashes, - firstNullifierInTx: nullifiers[0], - blockNumber: log.blockNumber, - blockHash: log.blockHash.toFr(), - }, - }; - }); + return logs.map(log => LogService.#toRetrievedTaggedLog(log)); } /** @@ -298,3 +284,10 @@ export class LogService { ); } } + +/** A tagged log fetched from the node, together with the onchain context of the tx that emitted it. */ +export type RetrievedTaggedLog = TxOnchainContext & { + /** The raw log payload, tag included. */ + logData: Fr[]; + blockTimestamp: UInt64; +}; 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 f703fc286a56..5393f5c93a93 100644 --- a/yarn-project/pxe/src/messages/tx_resolver_service.test.ts +++ b/yarn-project/pxe/src/messages/tx_resolver_service.test.ts @@ -98,10 +98,10 @@ describe('TxResolverService', () => { ); }); - it('resolves a valid tx hash into a ResolvedTx', async () => { + it('resolves a valid tx hash into its onchain context', async () => { const txHash = TxHash.random(); const noteHashes = [Fr.random(), Fr.random()]; - const firstNullifier = Fr.random(); + const nullifiers = [Fr.random(), Fr.random()]; const blockHash = BlockHash.random(); const blockNumber = anchorBlockNumber - 1; @@ -109,23 +109,16 @@ describe('TxResolverService', () => { await minedReceipt({ txHash, noteHashes, - nullifiers: [firstNullifier, Fr.random()], + nullifiers, blockNumber, blockHash, + txIndexInBlock: 3, }), ); const results = await service.resolveTxs([txHash.hash], anchorBlockNumber); - expect(results).toEqual([ - { - txHash, - uniqueNoteHashesInTx: noteHashes, - firstNullifierInTx: firstNullifier, - blockNumber, - blockHash: blockHash.toFr(), - }, - ]); + expect(results).toEqual([{ txHash, noteHashes, nullifiers, blockNumber, blockHash, txIndexInBlock: 3 }]); }); it('resolves tx hashes in different situations', async () => { @@ -164,7 +157,7 @@ describe('TxResolverService', () => { const results = await service.resolveTxs( [ Fr.ZERO, // zero → null - validTxHash.hash, // valid → ResolvedTx + validTxHash.hash, // valid → resolved notFoundTxHash.hash, // not found → null futureTxHash.hash, // beyond anchor → null ], @@ -175,10 +168,11 @@ describe('TxResolverService', () => { null, { txHash: validTxHash, - uniqueNoteHashesInTx: validNoteHashes, - firstNullifierInTx: validNullifier, + noteHashes: validNoteHashes, + nullifiers: [validNullifier], blockNumber: anchorBlockNumber, - blockHash: validBlockHash.toFr(), + blockHash: validBlockHash, + txIndexInBlock: 0, }, null, null, @@ -217,10 +211,11 @@ describe('TxResolverService', () => { const expected = { txHash: txEffect.txHash, - uniqueNoteHashesInTx: txEffect.noteHashes, - firstNullifierInTx: txEffect.nullifiers[0], + noteHashes: txEffect.noteHashes, + nullifiers: txEffect.nullifiers, blockNumber, - blockHash: blockHash.toFr(), + blockHash, + txIndexInBlock: 0, }; 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 da5033b27c14..7fdba63421cb 100644 --- a/yarn-project/pxe/src/messages/tx_resolver_service.ts +++ b/yarn-project/pxe/src/messages/tx_resolver_service.ts @@ -1,22 +1,22 @@ +import type { BlockNumber } from '@aztec/foundation/branded-types'; import { uniqueBy } from '@aztec/foundation/collection'; import { Fr } from '@aztec/foundation/curves/bn254'; +import type { BlockHash } from '@aztec/stdlib/block'; import type { AztecNode } from '@aztec/stdlib/interfaces/server'; import { type IndexedTxEffect, TxHash } from '@aztec/stdlib/tx'; -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). */ +/** Resolves transaction hashes into their on-chain context (note hashes, nullifiers, and mined position). */ export class TxResolverService { constructor(private readonly aztecNode: AztecNode) {} /** * Resolves a list of tx hashes into their on-chain context. * - * For each tx hash, looks up the corresponding tx effect and extracts the note hashes, first nullifier, and the - * number and hash of the block it was mined in. Returns `null` for tx hashes that are zero, not yet available, or in - * blocks beyond the anchor block. + * For each tx hash, looks up the corresponding tx effect and extracts its note hashes and nullifiers, and the + * position it was mined at. Returns `null` for tx hashes that are zero, not yet available, or in blocks beyond the + * anchor block. */ - async resolveTxs(txHashes: Fr[], anchorBlockNumber: number): Promise<(ResolvedTx | null)[]> { + async resolveTxs(txHashes: Fr[], anchorBlockNumber: number): Promise<(TxOnchainContext | null)[]> { const nonZeroTxHashes = txHashes.filter(h => !h.isZero()).map(h => TxHash.fromField(h)); const uniqueTxHashes = uniqueBy(nonZeroTxHashes, h => h.toString()); const fetched = await Promise.all( @@ -51,7 +51,7 @@ export class TxResolverService { } // Every tx has at least one nullifier (the first nullifier derived from the tx hash). Hitting this condition - // would mean a buggy node, but since we need to access data.nullifiers[0], the defensive check does no harm. + // would mean a buggy node, but since consumers rely on nullifiers[0], the defensive check does no harm. const data = txEffect.data; if (data.nullifiers.length === 0) { throw new Error(`Tx effect for ${txHash} has no nullifiers`); @@ -59,11 +59,22 @@ export class TxResolverService { return { txHash: data.txHash, - uniqueNoteHashesInTx: data.noteHashes, - firstNullifierInTx: data.nullifiers[0], + noteHashes: data.noteHashes, + nullifiers: data.nullifiers, blockNumber: txEffect.l2BlockNumber, - blockHash: txEffect.l2BlockHash.toFr(), + blockHash: txEffect.l2BlockHash, + txIndexInBlock: txEffect.txIndexInBlock, }; }); } } + +/** The onchain context of a tx: the position it was mined at, and the effects it produced. */ +export type TxOnchainContext = { + txHash: TxHash; + noteHashes: Fr[]; + nullifiers: Fr[]; + blockNumber: BlockNumber; + blockHash: BlockHash; + txIndexInBlock: number; +}; diff --git a/yarn-project/pxe/src/node/benchmarked_node.test.ts b/yarn-project/pxe/src/node/benchmarked_node.test.ts new file mode 100644 index 000000000000..1a111b448718 --- /dev/null +++ b/yarn-project/pxe/src/node/benchmarked_node.test.ts @@ -0,0 +1,76 @@ +import { BlockNumber } from '@aztec/foundation/branded-types'; +import { Fr } from '@aztec/foundation/curves/bn254'; +import { AztecAddress } from '@aztec/stdlib/aztec-address'; +import { BlockHash } from '@aztec/stdlib/block'; +import type { AztecNode } from '@aztec/stdlib/interfaces/server'; + +import { mock } from 'jest-mock-extended'; + +import { type BenchmarkedAztecNode, withRecording } from './benchmarked_node.js'; + +describe('withRecording', () => { + let aztecNode: ReturnType>; + let node: BenchmarkedAztecNode; + + beforeEach(() => { + aztecNode = mock(); + aztecNode.getPublicStorageAt.mockResolvedValue(new Fr(1)); + aztecNode.getBlockNumber.mockResolvedValue(BlockNumber(42)); + node = withRecording(aztecNode); + }); + + it('counts a round trip per blocking wait, timing its reads per method', async () => { + const read = await publicStorageRead(); + + const recording = node.startRecording(); + await node.getPublicStorageAt(read.blockHash, read.contractAddress, read.storageSlot); + await node.getPublicStorageAt(read.blockHash, read.contractAddress, read.storageSlot); + + const { perMethod, roundTrips } = recording.stop(); + expect(roundTrips.roundTrips).toBe(2); + expect(roundTrips.roundTripMethods).toEqual([['getPublicStorageAt'], ['getPublicStorageAt']]); + expect(perMethod.getPublicStorageAt!.times).toHaveLength(2); + }); + + it('counts parallel reads awaited together as a single round trip', async () => { + const read = await publicStorageRead(); + + const recording = node.startRecording(); + await Promise.all([ + node.getPublicStorageAt(read.blockHash, read.contractAddress, read.storageSlot), + node.getBlockNumber(), + ]); + + const { roundTrips } = recording.stop(); + expect(roundTrips.roundTrips).toBe(1); + expect(roundTrips.roundTripMethods).toEqual([['getPublicStorageAt', 'getBlockNumber']]); + }); + + it('gives concurrent recordings of the same wrapper their own view', async () => { + const read = await publicStorageRead(); + + const wholeRun = node.startRecording(); + await node.getPublicStorageAt(read.blockHash, read.contractAddress, read.storageSlot); + const secondHalf = node.startRecording(); + await node.getPublicStorageAt(read.blockHash, read.contractAddress, read.storageSlot); + + expect(secondHalf.stop().roundTrips.roundTrips).toBe(1); + expect(wholeRun.stop().roundTrips.roundTrips).toBe(2); + }); + + it('records nothing once stopped', async () => { + const read = await publicStorageRead(); + + const recording = node.startRecording(); + recording.stop(); + await node.getPublicStorageAt(read.blockHash, read.contractAddress, read.storageSlot); + + const { perMethod, roundTrips } = recording.stop(); + expect(perMethod).toEqual({}); + expect(roundTrips.roundTrips).toBe(0); + }); +}); + +async function publicStorageRead() { + return { blockHash: BlockHash.random(), contractAddress: await AztecAddress.random(), storageSlot: Fr.random() }; +} diff --git a/yarn-project/pxe/src/node/benchmarked_node.ts b/yarn-project/pxe/src/node/benchmarked_node.ts new file mode 100644 index 000000000000..34831655e9c6 --- /dev/null +++ b/yarn-project/pxe/src/node/benchmarked_node.ts @@ -0,0 +1,140 @@ +import { Timer } from '@aztec/foundation/timer'; +import type { AztecNode } from '@aztec/stdlib/interfaces/client'; +import type { NodeStats } from '@aztec/stdlib/tx'; + +/* + * Proxy generator for an AztecNode that tracks the time taken for each RPC call and the number of round trips (actual + * blocking waits for node responses). + * + * A round trip is counted when we transition from 0 to 1 in-flight calls, and ends when all concurrent calls complete. + * This means parallel calls in Promise.all count as a single round trip. + * + * Note that batching of RPC calls in `safe_json_rpc_client.ts` could affect the round trip counts but in places we + * currently use this information we do not even use HTTP as we have direct access to the Aztec Node instance in TS + * (i.e. not running against external node) so this is not a problem for now. + * + * If you want to use this against external node and the info gets skewed by batching you can set the `maxBatchSize` + * value in `safe_json_rpc_client.ts` to 1 (the main motivation for batching was to get around parallel http requests + * limits in web browsers which is not a problem when debugging in node.js). + */ +export interface Recording { + /** Closes the recording and returns what it saw. Reads served after this are not recorded. */ + stop(): NodeStats; +} + +/** An {@link AztecNode} wrapper that can report the reads it serves. */ +export interface BenchmarkedAztecNode extends AztecNode { + /** + * Opens a recording of the reads this wrapper serves, until {@link Recording.stop}. Recordings are independent, so + * several may run at once: one measuring a whole run sees the same reads as one measuring a single operation. + * + * Nothing is measured while none is open, which is what keeps a long-lived wrapper from accumulating a run's worth + * of timings nobody asked for. + */ + startRecording(): Recording; +} + +/** Wraps `node` so that the reads it answers can be recorded. */ +export function withRecording(node: AztecNode): BenchmarkedAztecNode { + // The stats of every recording currently open + const open = new Set(); + + // Round trip tracking + let inFlightCount = 0; + let currentRoundTripTimer: Timer | null = null; + let currentRoundTripMethods: string[] = []; + + return new Proxy(node, { + get(target, prop) { + if (prop === 'startRecording') { + return (): Recording => { + const stats: NodeStats = { + perMethod: {}, + roundTrips: { roundTrips: 0, totalBlockingTime: 0, roundTripDurations: [], roundTripMethods: [] }, + }; + open.add(stats); + return { + stop: () => { + open.delete(stats); + return stats; + }, + }; + }; + } + + const value = Reflect.get(target, prop); + if (typeof value !== 'function' || typeof prop !== 'string') { + return value; + } + + return (...args: unknown[]) => { + // With no recording open there is nobody to report to, so the read is left untimed. + if (open.size === 0) { + return value.apply(target, args); + } + + // Start of a new round trip batch? + if (inFlightCount === 0) { + currentRoundTripTimer = new Timer(); + currentRoundTripMethods = []; + } + inFlightCount++; + currentRoundTripMethods.push(prop); + + const callTimer = new Timer(); + const result = value.apply(target, args); + + // Handle completion - called when the call finishes (after Promise resolves) + const handleCompletion = () => { + const callTime = callTimer.ms(); + for (const stats of open) { + timesFor(stats, prop).push(callTime); + } + + inFlightCount--; + + // End of round trip batch - all concurrent calls completed + if (inFlightCount === 0 && currentRoundTripTimer) { + const roundTripTime = currentRoundTripTimer.ms(); + for (const { roundTrips } of open) { + roundTrips.roundTrips++; + roundTrips.totalBlockingTime += roundTripTime; + roundTrips.roundTripDurations.push(roundTripTime); + roundTrips.roundTripMethods.push(currentRoundTripMethods); + } + currentRoundTripTimer = null; + currentRoundTripMethods = []; + } + }; + + // If the result is a Promise, chain the completion handler + if (isThenable(result)) { + return result.then( + resolved => { + handleCompletion(); + return resolved; + }, + error => { + handleCompletion(); + throw error; + }, + ); + } else { + // Synchronous method - handle completion immediately + handleCompletion(); + return result; + } + }; + }, + }) as BenchmarkedAztecNode; +} + +/** The times `stats` holds for `method`, added on first use. */ +function timesFor(stats: NodeStats, method: string) { + return (stats.perMethod[method as keyof AztecNode] ??= { times: [] }).times; +} + +/** Whether completion can be chained onto `value` with `.then`, which a promise from any implementation allows. */ +function isThenable(value: unknown): value is PromiseLike { + return typeof value === 'object' && value !== null && 'then' in value && typeof value.then === 'function'; +} diff --git a/yarn-project/pxe/src/node/caching_aztec_node.test.ts b/yarn-project/pxe/src/node/caching_aztec_node.test.ts new file mode 100644 index 000000000000..019451c9fc59 --- /dev/null +++ b/yarn-project/pxe/src/node/caching_aztec_node.test.ts @@ -0,0 +1,678 @@ +import { ARCHIVE_HEIGHT } from '@aztec/constants'; +import { BlockNumber, CheckpointNumber, IndexWithinCheckpoint } 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, type BlockParameter, randomInBlock } from '@aztec/stdlib/block'; +import type { BlockResponse } from '@aztec/stdlib/interfaces/client'; +import type { AztecNode } from '@aztec/stdlib/interfaces/server'; +import { LogCursor, type PrivateLogsQuery, SiloedTag, Tag, randomLogResult } from '@aztec/stdlib/logs'; +import { AppendOnlyTreeSnapshot, MerkleTreeId, PublicDataWitness } from '@aztec/stdlib/trees'; +import { BlockHeader, type NodeStats } from '@aztec/stdlib/tx'; + +import { mock } from 'jest-mock-extended'; + +import { type CachingAztecNode, withCache } from './caching_aztec_node.js'; + +describe('withCache', () => { + let aztecNode: ReturnType>; + let cachedNode: CachingAztecNode; + + beforeEach(() => { + aztecNode = mock(); + cachedNode = withCache(aztecNode); + }); + + 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(cachedNode.getBlockHashMembershipWitness(blockHash, blockHash)).resolves.toBe(blockWitness); + await expect(cachedNode.getPublicDataWitness(blockHash, leafSlot)).resolves.toBe(publicDataWitness); + + expect(aztecNode.getBlockHashMembershipWitness).toHaveBeenCalledTimes(1); + expect(aztecNode.getPublicDataWitness).toHaveBeenCalledTimes(1); + }); + + it('gives each wrapper its own cache', async () => { + const otherWrapper = withCache(aztecNode); + const blockHash = BlockHash.random(); + const contractAddress = await AztecAddress.random(); + aztecNode.getPublicStorageAt.mockResolvedValue(new Fr(1)); + + await cachedNode.getPublicStorageAt(blockHash, contractAddress, new Fr(100)); + await otherWrapper.getPublicStorageAt(blockHash, contractAddress, new Fr(100)); + + expect(aztecNode.getPublicStorageAt).toHaveBeenCalledTimes(2); + }); + + describe('cache semantics', () => { + it('shares one in-flight read', async () => { + const { blockHash, contractAddress, storageSlot } = await publicStorageRead(); + const deferred = promiseWithResolvers(); + aztecNode.getPublicStorageAt.mockReturnValue(deferred.promise); + + const first = cachedNode.getPublicStorageAt(blockHash, contractAddress, storageSlot); + const second = cachedNode.getPublicStorageAt(blockHash, contractAddress, storageSlot); + deferred.resolve(new Fr(7)); + + await expect(Promise.all([first, second])).resolves.toEqual([new Fr(7), new Fr(7)]); + expect(aztecNode.getPublicStorageAt).toHaveBeenCalledTimes(1); + }); + + it('shares an in-flight read whose value is then turned away', async () => { + const blockHash = BlockHash.random(); + const deferred = promiseWithResolvers(); + aztecNode.getBlock.mockReturnValue(deferred.promise); + + const first = cachedNode.getBlock(blockHash); + const second = cachedNode.getBlock(blockHash); + deferred.resolve(undefined); + + await expect(Promise.all([first, second])).resolves.toEqual([undefined, undefined]); + expect(aztecNode.getBlock).toHaveBeenCalledTimes(1); + }); + + it('evicts rejected reads so callers can retry', async () => { + const { blockHash, contractAddress, storageSlot } = await publicStorageRead(); + aztecNode.getPublicStorageAt.mockRejectedValueOnce(new Error('temporary failure')); + aztecNode.getPublicStorageAt.mockResolvedValue(new Fr(1)); + + await expect(cachedNode.getPublicStorageAt(blockHash, contractAddress, storageSlot)).rejects.toThrow( + 'temporary failure', + ); + await expect(cachedNode.getPublicStorageAt(blockHash, contractAddress, storageSlot)).resolves.toEqual(new Fr(1)); + await expect(cachedNode.getPublicStorageAt(blockHash, contractAddress, storageSlot)).resolves.toEqual(new Fr(1)); + + expect(aztecNode.getPublicStorageAt).toHaveBeenCalledTimes(2); + }); + + it('a wiped read that fails does not evict the entry of a newer read', async () => { + const { blockHash, contractAddress, storageSlot } = await publicStorageRead(); + const wiped = promiseWithResolvers(); + aztecNode.getPublicStorageAt.mockReturnValueOnce(wiped.promise); + aztecNode.getPublicStorageAt.mockResolvedValue(new Fr(2)); + + const wipedRead = cachedNode.getPublicStorageAt(blockHash, contractAddress, storageSlot); + cachedNode.wipeCache(); + await expect(cachedNode.getPublicStorageAt(blockHash, contractAddress, storageSlot)).resolves.toEqual(new Fr(2)); + + wiped.reject(new Error('temporary failure')); + await expect(wipedRead).rejects.toThrow('temporary failure'); + + // The failed read belonged to a wiped entry, so it left the newer cached value in place. + await expect(cachedNode.getPublicStorageAt(blockHash, contractAddress, storageSlot)).resolves.toEqual(new Fr(2)); + expect(aztecNode.getPublicStorageAt).toHaveBeenCalledTimes(2); + }); + }); + + describe('getBlock', () => { + it('shares repeated reads of a block pinned by hash', async () => { + const blockHash = BlockHash.random(); + const block = makeBlockResponse(BlockNumber(1)); + aztecNode.getBlock.mockResolvedValue(block); + + await expect(cachedNode.getBlock(blockHash)).resolves.toBe(block); + await expect(cachedNode.getBlock(blockHash)).resolves.toBe(block); + + expect(aztecNode.getBlock).toHaveBeenCalledTimes(1); + }); + + it('keys reads by their options', async () => { + const blockHash = BlockHash.random(); + aztecNode.getBlock.mockResolvedValue(makeBlockResponse(BlockNumber(1))); + + await cachedNode.getBlock(blockHash); + await cachedNode.getBlock(blockHash, { includeTransactions: true }); + + expect(aztecNode.getBlock).toHaveBeenCalledTimes(2); + }); + + it('passes number-referenced reads through to the node uncached', async () => { + aztecNode.getBlock.mockResolvedValue(makeBlockResponse(BlockNumber(1))); + + await cachedNode.getBlock(BlockNumber(1)); + await cachedNode.getBlock(BlockNumber(1)); + + expect(aztecNode.getBlock).toHaveBeenCalledTimes(2); + }); + + it('passes tag-referenced reads through to the node uncached', async () => { + aztecNode.getBlock.mockResolvedValue(makeBlockResponse(BlockNumber(1))); + + await cachedNode.getBlock('latest'); + await cachedNode.getBlock('latest'); + + expect(aztecNode.getBlock).toHaveBeenCalledTimes(2); + }); + + it('passes archive-referenced reads through to the node uncached', async () => { + const archive = Fr.random(); + aztecNode.getBlock.mockResolvedValue(makeBlockResponse(BlockNumber(1))); + + await cachedNode.getBlock({ archive }); + await cachedNode.getBlock({ archive }); + + expect(aztecNode.getBlock).toHaveBeenCalledTimes(2); + }); + + it('passes reads requesting attestations or L1 publish info through to the node uncached', async () => { + const blockHash = BlockHash.random(); + aztecNode.getBlock.mockResolvedValue(makeBlockResponse(BlockNumber(1))); + + await cachedNode.getBlock(blockHash, { includeAttestations: true }); + await cachedNode.getBlock(blockHash, { includeAttestations: true }); + await cachedNode.getBlock(blockHash, { includeL1PublishInfo: true }); + await cachedNode.getBlock(blockHash, { includeL1PublishInfo: true }); + + expect(aztecNode.getBlock).toHaveBeenCalledTimes(4); + }); + + it('evicts undefined results so callers can retry', async () => { + const blockHash = BlockHash.random(); + const block = makeBlockResponse(BlockNumber(1)); + aztecNode.getBlock.mockResolvedValueOnce(undefined); + aztecNode.getBlock.mockResolvedValueOnce(block); + + await expect(cachedNode.getBlock(blockHash)).resolves.toBeUndefined(); + await expect(cachedNode.getBlock(blockHash)).resolves.toBe(block); + + expect(aztecNode.getBlock).toHaveBeenCalledTimes(2); + }); + }); + + buildPinnedReadTests({ + method: 'getContract', + setup: () => { + const address = AztecAddress.fromBigIntUnsafe(1n); + aztecNode.getContract.mockResolvedValue(undefined); + return { + node: aztecNode.getContract, + read: block => cachedNode.getContract(address, block), + }; + }, + extraTests: () => { + it('passes reads with no reference block through to the node uncached', async () => { + const address = await AztecAddress.random(); + aztecNode.getContract.mockResolvedValue(undefined); + + await cachedNode.getContract(address); + await cachedNode.getContract(address); + + expect(aztecNode.getContract).toHaveBeenCalledTimes(2); + }); + }, + }); + + buildPinnedReadTests({ + method: 'getBlockHashMembershipWitness', + setup: () => { + const blockHash = BlockHash.random(); + aztecNode.getBlockHashMembershipWitness.mockResolvedValue(undefined); + return { + node: aztecNode.getBlockHashMembershipWitness, + read: block => cachedNode.getBlockHashMembershipWitness(block, blockHash), + }; + }, + }); + + buildPinnedReadTests({ + method: 'getPublicDataWitness', + setup: () => { + const leafSlot = Fr.random(); + aztecNode.getPublicDataWitness.mockResolvedValue(undefined); + return { + node: aztecNode.getPublicDataWitness, + read: block => cachedNode.getPublicDataWitness(block, leafSlot), + }; + }, + }); + + buildPinnedReadTests({ + method: 'getNoteHashMembershipWitness', + setup: () => { + const noteHash = Fr.random(); + aztecNode.getNoteHashMembershipWitness.mockResolvedValue(undefined); + return { + node: aztecNode.getNoteHashMembershipWitness, + read: block => cachedNode.getNoteHashMembershipWitness(block, noteHash), + }; + }, + }); + + buildPinnedReadTests({ + method: 'getNullifierMembershipWitness', + setup: () => { + const nullifier = Fr.random(); + aztecNode.getNullifierMembershipWitness.mockResolvedValue(undefined); + return { + node: aztecNode.getNullifierMembershipWitness, + read: block => cachedNode.getNullifierMembershipWitness(block, nullifier), + }; + }, + }); + + buildPinnedReadTests({ + method: 'getLowNullifierMembershipWitness', + setup: () => { + const nullifier = Fr.random(); + aztecNode.getLowNullifierMembershipWitness.mockResolvedValue(undefined); + return { + node: aztecNode.getLowNullifierMembershipWitness, + read: block => cachedNode.getLowNullifierMembershipWitness(block, nullifier), + }; + }, + }); + + buildPinnedReadTests({ + method: 'getL1ToL2MessageMembershipWitness', + setup: () => { + const messageHash = Fr.random(); + aztecNode.getL1ToL2MessageMembershipWitness.mockResolvedValue(undefined); + return { + node: aztecNode.getL1ToL2MessageMembershipWitness, + read: block => cachedNode.getL1ToL2MessageMembershipWitness(block, messageHash), + }; + }, + }); + + describe('getPublicStorageAt', () => { + it('caches each slot independently', async () => { + const blockHash = BlockHash.random(); + const contractAddress = await AztecAddress.random(); + aztecNode.getPublicStorageAt.mockImplementation((_block, _contract, slot) => + Promise.resolve(new Fr(slot.toBigInt() + 1n)), + ); + + await expect(cachedNode.getPublicStorageAt(blockHash, contractAddress, new Fr(100))).resolves.toEqual( + new Fr(101), + ); + await expect(cachedNode.getPublicStorageAt(blockHash, contractAddress, new Fr(100))).resolves.toEqual( + new Fr(101), + ); + await expect(cachedNode.getPublicStorageAt(blockHash, contractAddress, new Fr(101))).resolves.toEqual( + new Fr(102), + ); + + expect(aztecNode.getPublicStorageAt).toHaveBeenCalledTimes(2); + }); + + it('passes number-, tag- and archive-referenced reads through to the node uncached', async () => { + const contractAddress = await AztecAddress.random(); + const storageSlot = new Fr(100); + const archive = Fr.random(); + aztecNode.getPublicStorageAt.mockResolvedValue(new Fr(1)); + + await cachedNode.getPublicStorageAt('latest', contractAddress, storageSlot); + await cachedNode.getPublicStorageAt('latest', contractAddress, storageSlot); + await cachedNode.getPublicStorageAt(BlockNumber(1), contractAddress, storageSlot); + await cachedNode.getPublicStorageAt(BlockNumber(1), contractAddress, storageSlot); + await cachedNode.getPublicStorageAt({ archive }, contractAddress, storageSlot); + await cachedNode.getPublicStorageAt({ archive }, contractAddress, storageSlot); + + expect(aztecNode.getPublicStorageAt).toHaveBeenCalledTimes(6); + }); + }); + + describe('findLeavesIndexes', () => { + it('caches leaf index reads per leaf and only fetches the misses of a batch', async () => { + const blockHash = BlockHash.random(); + const leafA = Fr.random(); + const leafB = Fr.random(); + const indexA = { data: 7n, ...randomInBlock() }; + const indexB = { data: 8n, ...randomInBlock() }; + aztecNode.findLeavesIndexes.mockResolvedValueOnce([indexA]); + aztecNode.findLeavesIndexes.mockResolvedValueOnce([indexB]); + + await expect(cachedNode.findLeavesIndexes(blockHash, MerkleTreeId.NULLIFIER_TREE, [leafA])).resolves.toEqual([ + indexA, + ]); + // leafA is served from the cache: the node only receives the missing leafB. + await expect( + cachedNode.findLeavesIndexes(blockHash, MerkleTreeId.NULLIFIER_TREE, [leafA, leafB]), + ).resolves.toEqual([indexA, indexB]); + + expect(aztecNode.findLeavesIndexes).toHaveBeenCalledTimes(2); + expect(aztecNode.findLeavesIndexes).toHaveBeenLastCalledWith(blockHash, MerkleTreeId.NULLIFIER_TREE, [leafB]); + }); + + it('asks the node once for a leaf repeated within a batch', async () => { + const blockHash = BlockHash.random(); + const leafA = Fr.random(); + const leafB = Fr.random(); + const indexA = { data: 7n, ...randomInBlock() }; + const indexB = { data: 8n, ...randomInBlock() }; + aztecNode.findLeavesIndexes.mockResolvedValueOnce([indexA, indexB]); + + await expect( + cachedNode.findLeavesIndexes(blockHash, MerkleTreeId.NULLIFIER_TREE, [leafA, leafB, leafA]), + ).resolves.toEqual([indexA, indexB, indexA]); + + expect(aztecNode.findLeavesIndexes).toHaveBeenCalledWith(blockHash, MerkleTreeId.NULLIFIER_TREE, [leafA, leafB]); + }); + + it('caches undefined reads as non-membership answers', async () => { + const blockHash = BlockHash.random(); + const leafA = Fr.random(); + const leafB = Fr.random(); + const indexB = { data: 8n, ...randomInBlock() }; + aztecNode.findLeavesIndexes.mockResolvedValueOnce([undefined]); + aztecNode.findLeavesIndexes.mockResolvedValueOnce([indexB]); + + await expect(cachedNode.findLeavesIndexes(blockHash, MerkleTreeId.NULLIFIER_TREE, [leafA])).resolves.toEqual([ + undefined, + ]); + await expect( + cachedNode.findLeavesIndexes(blockHash, MerkleTreeId.NULLIFIER_TREE, [leafA, leafB]), + ).resolves.toEqual([undefined, indexB]); + + expect(aztecNode.findLeavesIndexes).toHaveBeenCalledTimes(2); + expect(aztecNode.findLeavesIndexes).toHaveBeenLastCalledWith(blockHash, MerkleTreeId.NULLIFIER_TREE, [leafB]); + }); + + it('passes tag- and archive-referenced reads through to the node uncached', async () => { + const leaf = Fr.random(); + const archive = Fr.random(); + const index = { data: 7n, ...randomInBlock() }; + aztecNode.findLeavesIndexes.mockResolvedValue([index]); + + await expect(cachedNode.findLeavesIndexes('latest', MerkleTreeId.NULLIFIER_TREE, [leaf])).resolves.toEqual([ + index, + ]); + await expect(cachedNode.findLeavesIndexes('latest', MerkleTreeId.NULLIFIER_TREE, [leaf])).resolves.toEqual([ + index, + ]); + await expect(cachedNode.findLeavesIndexes({ archive }, MerkleTreeId.NULLIFIER_TREE, [leaf])).resolves.toEqual([ + index, + ]); + await expect(cachedNode.findLeavesIndexes({ archive }, MerkleTreeId.NULLIFIER_TREE, [leaf])).resolves.toEqual([ + index, + ]); + + expect(aztecNode.findLeavesIndexes).toHaveBeenCalledTimes(4); + }); + + it('rejects and evicts a batch whose response is shorter than the request', async () => { + const blockHash = BlockHash.random(); + const leafA = Fr.random(); + const leafB = Fr.random(); + const indexA = { data: 7n, ...randomInBlock() }; + const indexB = { data: 8n, ...randomInBlock() }; + // One result for two requested leaves: a short response the wrapper must reject rather than cache as + // non-membership for the missing leaf. + aztecNode.findLeavesIndexes.mockResolvedValueOnce([indexA]); + aztecNode.findLeavesIndexes.mockResolvedValueOnce([indexA, indexB]); + + await expect( + cachedNode.findLeavesIndexes(blockHash, MerkleTreeId.NULLIFIER_TREE, [leafA, leafB]), + ).rejects.toThrow('returned 1 results for 2 requested leaves'); + + // The rejected batch evicted its derived entries, so a retry re-fetches both leaves. + await expect( + cachedNode.findLeavesIndexes(blockHash, MerkleTreeId.NULLIFIER_TREE, [leafA, leafB]), + ).resolves.toEqual([indexA, indexB]); + + expect(aztecNode.findLeavesIndexes).toHaveBeenCalledTimes(2); + }); + }); + + describe('log queries by tag', () => { + it('caches a page per tag and only fetches the tags a later query has not seen', async () => { + const anchorHash = BlockHash.random(); + const [tagA, tagB] = [new SiloedTag(Fr.random()), new SiloedTag(Fr.random())]; + const logsForB = [randomLogResult()]; + aztecNode.getPrivateLogsByTags.mockResolvedValueOnce([[]]).mockResolvedValueOnce([logsForB]); + + // An empty page is an answer like any other: no log can appear in a range that is already closed. + await expect(cachedNode.getPrivateLogsByTags(cacheableLogsQuery(anchorHash, [tagA]))).resolves.toEqual([[]]); + await expect(cachedNode.getPrivateLogsByTags(cacheableLogsQuery(anchorHash, [tagA, tagB]))).resolves.toEqual([ + [], + logsForB, + ]); + + expect(aztecNode.getPrivateLogsByTags).toHaveBeenCalledTimes(2); + expect(aztecNode.getPrivateLogsByTags).toHaveBeenLastCalledWith(cacheableLogsQuery(anchorHash, [tagB])); + }); + + it('keys each page of a paginated tag by its cursor', async () => { + const anchorHash = BlockHash.random(); + const tag = new SiloedTag(Fr.random()); + const firstPage = cacheableLogsQuery(anchorHash, [tag]); + const secondPage = { ...firstPage, tags: [{ tag, afterLog: LogCursor.random() }] }; + aztecNode.getPrivateLogsByTags.mockResolvedValue([[randomLogResult()]]); + + await cachedNode.getPrivateLogsByTags(firstPage); + await cachedNode.getPrivateLogsByTags(secondPage); + await cachedNode.getPrivateLogsByTags(firstPage); + await cachedNode.getPrivateLogsByTags(secondPage); + + expect(aztecNode.getPrivateLogsByTags).toHaveBeenCalledTimes(2); + }); + + it('keeps queries that ask for different data apart', async () => { + const anchorHash = BlockHash.random(); + const tag = new SiloedTag(Fr.random()); + const query = cacheableLogsQuery(anchorHash, [tag]); + aztecNode.getPrivateLogsByTags.mockResolvedValue([[randomLogResult()]]); + + await cachedNode.getPrivateLogsByTags(query); + await cachedNode.getPrivateLogsByTags({ ...query, includeEffects: true }); + await cachedNode.getPrivateLogsByTags({ ...query, fromBlock: BlockNumber(50) }); + + expect(aztecNode.getPrivateLogsByTags).toHaveBeenCalledTimes(3); + }); + + it('keys public queries by the contract that emitted the logs', async () => { + const anchorHash = BlockHash.random(); + const tag = new Tag(Fr.random()); + const [contract, otherContract] = await Promise.all([AztecAddress.random(), AztecAddress.random()]); + const query = { ...cacheableLogsQuery(anchorHash, []), tags: [tag], contractAddress: contract }; + aztecNode.getPublicLogsByTags.mockResolvedValue([[]]); + + await cachedNode.getPublicLogsByTags(query); + await cachedNode.getPublicLogsByTags({ ...query, contractAddress: otherContract }); + await cachedNode.getPublicLogsByTags(query); + + expect(aztecNode.getPublicLogsByTags).toHaveBeenCalledTimes(2); + }); + + it('passes queries whose answer can still change through to the node', async () => { + const tags = [new SiloedTag(Fr.random())]; + const anchorOnly = { tags, referenceBlock: BlockHash.random() }; + const boundOnly = { tags, toBlock: BlockNumber(101) }; + aztecNode.getPrivateLogsByTags.mockResolvedValue([[]]); + + // Neither condition is enough on its own, see `hasImmutableAnswer`. + await cachedNode.getPrivateLogsByTags(anchorOnly); + await cachedNode.getPrivateLogsByTags(anchorOnly); + await cachedNode.getPrivateLogsByTags(boundOnly); + await cachedNode.getPrivateLogsByTags(boundOnly); + + expect(aztecNode.getPrivateLogsByTags).toHaveBeenCalledTimes(4); + }); + + it('rejects and evicts a batch whose response is shorter than the request', async () => { + const anchorHash = BlockHash.random(); + const [tagA, tagB] = [new SiloedTag(Fr.random()), new SiloedTag(Fr.random())]; + const logs = [randomLogResult()]; + aztecNode.getPrivateLogsByTags.mockResolvedValueOnce([logs]).mockResolvedValueOnce([logs, []]); + + await expect(cachedNode.getPrivateLogsByTags(cacheableLogsQuery(anchorHash, [tagA, tagB]))).rejects.toThrow( + 'returned 1 results for 2 requested tags', + ); + // Nothing from the rejected batch is kept, so the identical query re-fetches and gets the full response. + await expect(cachedNode.getPrivateLogsByTags(cacheableLogsQuery(anchorHash, [tagA, tagB]))).resolves.toEqual([ + logs, + [], + ]); + + expect(aztecNode.getPrivateLogsByTags).toHaveBeenCalledTimes(2); + }); + + /** + * A tag query whose answer can no longer change: `referenceBlock` names the chain to answer on, and `toBlock` + * stops it at the anchor block, so no later block can add to the answer. The height itself is arbitrary, only + * naming one matters. + */ + function cacheableLogsQuery(anchorHash: BlockHash, tags: SiloedTag[]): PrivateLogsQuery { + return { tags, referenceBlock: anchorHash, toBlock: BlockNumber(101) }; + } + }); + + describe('uncached methods', () => { + it('passes repeated reads through to the node', async () => { + aztecNode.getBlockNumber.mockResolvedValue(BlockNumber(42)); + + await expect(cachedNode.getBlockNumber()).resolves.toBe(42); + await expect(cachedNode.getBlockNumber()).resolves.toBe(42); + + expect(aztecNode.getBlockNumber).toHaveBeenCalledTimes(2); + }); + + it('binds passthrough methods to the node', async () => { + aztecNode.getBlockNumber.mockImplementation(function (this: unknown) { + return this === aztecNode ? Promise.resolve(BlockNumber(42)) : Promise.reject(new Error('unbound call')); + }); + + const { getBlockNumber } = cachedNode; + await expect(getBlockNumber()).resolves.toBe(42); + }); + + it('wipeCache clears the cache so the next read reaches the node', async () => { + const blockHash = BlockHash.random(); + const contractAddress = await AztecAddress.random(); + const storageSlot = Fr.random(); + aztecNode.getPublicStorageAt.mockResolvedValue(new Fr(1)); + + await cachedNode.getPublicStorageAt(blockHash, contractAddress, storageSlot); + cachedNode.wipeCache(); + await cachedNode.getPublicStorageAt(blockHash, contractAddress, storageSlot); + + expect(aztecNode.getPublicStorageAt).toHaveBeenCalledTimes(2); + }); + }); + + describe('startRecording', () => { + it('records only the reads the node answered', async () => { + const { blockHash, contractAddress, storageSlot } = await publicStorageRead(); + aztecNode.getPublicStorageAt.mockResolvedValue(new Fr(1)); + + const recording = cachedNode.startRecording(); + await cachedNode.getPublicStorageAt(blockHash, contractAddress, storageSlot); + await cachedNode.getPublicStorageAt(blockHash, contractAddress, storageSlot); + await cachedNode.getPublicStorageAt(blockHash, contractAddress, storageSlot); + + expect(callCounts(recording.stop())).toEqual({ getPublicStorageAt: 1 }); + }); + + it('records a batched leaf read only when it fetches a missing leaf', async () => { + const blockHash = BlockHash.random(); + const [leafA, leafB] = [Fr.random(), Fr.random()]; + const index = { data: 7n, ...randomInBlock() }; + aztecNode.findLeavesIndexes.mockResolvedValue([index]); + + const recording = cachedNode.startRecording(); + await cachedNode.findLeavesIndexes(blockHash, MerkleTreeId.NULLIFIER_TREE, [leafA]); + await cachedNode.findLeavesIndexes(blockHash, MerkleTreeId.NULLIFIER_TREE, [leafA]); + await cachedNode.findLeavesIndexes(blockHash, MerkleTreeId.NULLIFIER_TREE, [leafA, leafB]); + + expect(callCounts(recording.stop())).toEqual({ findLeavesIndexes: 2 }); + }); + + it('does not count a batch the cache serves in full as a round trip', async () => { + const { blockHash, contractAddress, storageSlot } = await publicStorageRead(); + aztecNode.getPublicStorageAt.mockResolvedValue(new Fr(1)); + + const recording = cachedNode.startRecording(); + await cachedNode.getPublicStorageAt(blockHash, contractAddress, storageSlot); + await cachedNode.getPublicStorageAt(blockHash, contractAddress, storageSlot); + + const { roundTrips } = recording.stop(); + expect(roundTrips.roundTrips).toBe(1); + expect(roundTrips.roundTripMethods).toEqual([['getPublicStorageAt']]); + }); + + it('counts a batch that still reaches the node as a round trip, naming only the reads that did', async () => { + const { blockHash, contractAddress, storageSlot } = await publicStorageRead(); + aztecNode.getPublicStorageAt.mockResolvedValue(new Fr(1)); + aztecNode.getBlockNumber.mockResolvedValue(BlockNumber(42)); + + const recording = cachedNode.startRecording(); + await cachedNode.getPublicStorageAt(blockHash, contractAddress, storageSlot); + await Promise.all([ + cachedNode.getPublicStorageAt(blockHash, contractAddress, storageSlot), + cachedNode.getBlockNumber(), + ]); + + const { roundTrips } = recording.stop(); + expect(roundTrips.roundTrips).toBe(2); + // The second batch waited on `getBlockNumber` alone: its storage read was served without reaching the node. + expect(roundTrips.roundTripMethods).toEqual([['getPublicStorageAt'], ['getBlockNumber']]); + }); + }); +}); + +/** How many reads of each method `stats` saw, dropping the timings. */ +function callCounts(stats: NodeStats) { + return Object.fromEntries(Object.entries(stats.perMethod).map(([method, { times }]) => [method, times.length])); +} + +// A hash-pinned public storage read: the simplest cached read, used to exercise cache semantics that hold for all of +// them (in-flight sharing, eviction on rejection, wiping). +async function publicStorageRead() { + return { blockHash: BlockHash.random(), contractAddress: await AztecAddress.random(), storageSlot: Fr.random() }; +} + +function makeBlockResponse(blockNumber: BlockNumber): BlockResponse { + return { + header: BlockHeader.empty(), + archive: AppendOnlyTreeSnapshot.empty(), + hash: BlockHash.random(), + checkpointNumber: CheckpointNumber.fromBlockNumber(blockNumber), + indexWithinCheckpoint: IndexWithinCheckpoint.ZERO, + number: blockNumber, + }; +} + +// The single-key pinned reads share one signature, so this registers the same two per-method contracts for each: an +// undefined answer is cached as a non-membership fact, and number and tag references bypass the cache. `setup` runs +// inside each test (after `beforeEach` rebuilds the node), serves undefined, and returns the method's mock plus a read +// pinned to a fixed query argument. `extraTests` adds the cases specific to one method, in that method's own group. +function buildPinnedReadTests(opts: { + method: string; + setup: () => { node: jest.Mock; read: (block: BlockParameter) => Promise }; + extraTests?: () => void; +}) { + describe(opts.method, () => { + it('caches undefined reads as non-membership answers', async () => { + const { node, read } = opts.setup(); + const referenceBlockHash = BlockHash.random(); + + await expect(read(referenceBlockHash)).resolves.toBeUndefined(); + await expect(read(referenceBlockHash)).resolves.toBeUndefined(); + + expect(node).toHaveBeenCalledTimes(1); + }); + + it('passes number-, tag- and archive-referenced reads through to the node uncached', async () => { + const { node, read } = opts.setup(); + const archive = Fr.random(); + + await read('latest'); + await read('latest'); + await read(BlockNumber(1)); + await read(BlockNumber(1)); + await read({ archive }); + await read({ archive }); + + expect(node).toHaveBeenCalledTimes(6); + }); + + opts.extraTests?.(); + }); +} diff --git a/yarn-project/pxe/src/node/caching_aztec_node.ts b/yarn-project/pxe/src/node/caching_aztec_node.ts new file mode 100644 index 000000000000..5db4eb82a29d --- /dev/null +++ b/yarn-project/pxe/src/node/caching_aztec_node.ts @@ -0,0 +1,406 @@ +import { Fr } from '@aztec/foundation/curves/bn254'; +import type { AztecAddress } from '@aztec/stdlib/aztec-address'; +import { BlockHash, type BlockParameter, type DataInBlock } from '@aztec/stdlib/block'; +import type { BlockIncludeOptions } from '@aztec/stdlib/interfaces/client'; +import type { AztecNode } from '@aztec/stdlib/interfaces/server'; +import type { + LogResult, + LogsQueryBase, + PrivateLogsQuery, + PublicLogsQuery, + SiloedTag, + Tag, + TagQuery, +} from '@aztec/stdlib/logs'; +import type { MerkleTreeId } from '@aztec/stdlib/trees'; + +import { type BenchmarkedAztecNode, withRecording } from './benchmarked_node.js'; + +/** + * An {@link AztecNode} wrapper that serves repeated reads from a cache it owns. + */ +export interface CachingAztecNode extends BenchmarkedAztecNode { + /** Clears the read cache. The owner calls this periodically to bound memory. Cached entries never go stale. */ + wipeCache(): void; +} + +/** + * Wraps `node`, serving repeated reads from a cache the wrapper owns. Consumers share cached reads by sharing the + * wrapper. + * + * Only hash-pinned reads are cached. A block hash names immutable content, so the same call can never correctly answer + * differently, and even an `undefined` witness or leaf index is kept as a non-membership fact. That makes the wrapper + * safe for any consumer, whatever its anchor block. Uncached methods, and reads that name a block any other way (see + * {@link hashReferenceOf}), pass straight through. A few entries below opt out even when hash-pinned, where the answer + * can still change, and the tag queries opt in only once the request also stops short of blocks yet to be produced + * (see {@link hasImmutableAnswer}). + * + * Wiping (see {@link CachingAztecNode.wipeCache}) bounds memory. Correctness does not depend on it. + */ +export function withCache(node: AztecNode): CachingAztecNode { + const cache = new AztecNodeCache(); + // The recording wrapper sits below the cache, so a recording sees only the reads the node answered: a read served + // from the cache appears nowhere in it. Every read below goes to `source` rather than to `node`, and + // `startRecording` passes through this wrapper untouched. + const source = withRecording(node); + + /** Runs `read` through `cache` when `block` pins a chain position by hash. Everything else goes to the node. */ + const readCachedIfBlockIsHashPinned = ( + block: BlockParameter | undefined, + key: (blockHash: string) => string, + read: () => Promise, + options?: { shouldCache?: (value: T) => boolean }, + ): Promise => { + const blockHash = hashReferenceOf(block); + return blockHash === undefined ? read() : cache.fetch(key(blockHash.toString()), read, options); + }; + + const cachedReads: { [K in keyof AztecNode]?: (...args: Parameters) => ReturnType } = { + getBlock: (block: BlockParameter, options?: BlockIncludeOptions) => { + if (options?.includeL1PublishInfo || options?.includeAttestations) { + // These payloads keep changing after a block is fixed (its L1 publication status, its incoming committee + // attestations), so a repeat can correctly differ; read straight from the node. + return source.getBlock(block, options); + } + return readCachedIfBlockIsHashPinned( + block, + blockHash => `block:${blockHash}:${keyPart(options)}`, + () => source.getBlock(block, options), + { + // PXE only asks for pinned blocks it has already observed, so an undefined answer means the node has not + // served the block yet, not that it never will. + shouldCache: response => response !== undefined, + }, + ); + }, + + getContract: (address: AztecAddress, referenceBlock?: BlockParameter) => + readCachedIfBlockIsHashPinned( + referenceBlock, + blockHash => `contract:${blockHash}:${address.toString()}`, + () => source.getContract(address, referenceBlock), + ), + + getBlockHashMembershipWitness: (referenceBlock: BlockParameter, blockHash: BlockHash) => + readCachedIfBlockIsHashPinned( + referenceBlock, + referenceHash => `block-hash-membership-witness:${referenceHash}:${blockHash.toString()}`, + () => source.getBlockHashMembershipWitness(referenceBlock, blockHash), + ), + + getPublicDataWitness: (referenceBlock: BlockParameter, leafSlot: Fr) => + readCachedIfBlockIsHashPinned( + referenceBlock, + blockHash => `public-data-witness:${blockHash}:${leafSlot.toString()}`, + () => source.getPublicDataWitness(referenceBlock, leafSlot), + ), + + getNoteHashMembershipWitness: (referenceBlock: BlockParameter, noteHash: Fr) => + readCachedIfBlockIsHashPinned( + referenceBlock, + blockHash => `note-hash-membership-witness:${blockHash}:${noteHash.toString()}`, + () => source.getNoteHashMembershipWitness(referenceBlock, noteHash), + ), + + getNullifierMembershipWitness: (referenceBlock: BlockParameter, nullifier: Fr) => + readCachedIfBlockIsHashPinned( + referenceBlock, + blockHash => `nullifier-membership-witness:${blockHash}:${nullifier.toString()}`, + () => source.getNullifierMembershipWitness(referenceBlock, nullifier), + ), + + getLowNullifierMembershipWitness: (referenceBlock: BlockParameter, nullifier: Fr) => + readCachedIfBlockIsHashPinned( + referenceBlock, + blockHash => `low-nullifier-membership-witness:${blockHash}:${nullifier.toString()}`, + () => source.getLowNullifierMembershipWitness(referenceBlock, nullifier), + ), + + getL1ToL2MessageMembershipWitness: (referenceBlock: BlockParameter, l1ToL2Message: Fr) => + readCachedIfBlockIsHashPinned( + referenceBlock, + blockHash => `l1-to-l2-message-membership-witness:${blockHash}:${l1ToL2Message.toString()}`, + () => source.getL1ToL2MessageMembershipWitness(referenceBlock, l1ToL2Message), + ), + + getPublicStorageAt: (referenceBlock: BlockParameter, contractAddress: AztecAddress, storageSlot: Fr) => + readCachedIfBlockIsHashPinned( + referenceBlock, + blockHash => `public-storage:${blockHash}:${contractAddress.toString()}:${storageSlot.toString()}`, + () => source.getPublicStorageAt(referenceBlock, contractAddress, storageSlot), + ), + + findLeavesIndexes: (referenceBlock: BlockParameter, treeId: MerkleTreeId, leafValues: Fr[]) => { + // Cached per leaf: only leaves without a cached result are fetched, in a single batched node call. The per-leaf + // keys don't fit single-key readCachedIfBlockIsHashPinned, so the hash gate is inline. + const referenceHash = hashReferenceOf(referenceBlock); + if (referenceHash === undefined) { + return source.findLeavesIndexes(referenceBlock, treeId, leafValues); + } + return readBatchedPerKey | undefined>( + cache, + leafValues.map(leaf => `leaf-index:${referenceHash.toString()}:${treeId}:${leaf.toString()}`), + missing => + source.findLeavesIndexes( + referenceBlock, + treeId, + missing.map(i => leafValues[i]), + ), + { method: 'findLeavesIndexes', requested: 'leaves' }, + ); + }, + + getPrivateLogsByTags: (query: PrivateLogsQuery) => { + if (!hasImmutableAnswer(query)) { + return source.getPrivateLogsByTags(query); + } + const keyPrefix = `private-logs:${privateLogsQueryKey(query)}`; + return readBatchedPerKey( + cache, + query.tags.map(tag => `${keyPrefix}:${tagQueryKey(tag)}`), + missing => source.getPrivateLogsByTags({ ...query, tags: missing.map(i => query.tags[i]) }), + { method: 'getPrivateLogsByTags', requested: 'tags' }, + ); + }, + + getPublicLogsByTags: (query: PublicLogsQuery) => { + if (!hasImmutableAnswer(query)) { + return source.getPublicLogsByTags(query); + } + const keyPrefix = `public-logs:${publicLogsQueryKey(query)}`; + return readBatchedPerKey( + cache, + query.tags.map(tag => `${keyPrefix}:${tagQueryKey(tag)}`), + missing => source.getPublicLogsByTags({ ...query, tags: missing.map(i => query.tags[i]) }), + { method: 'getPublicLogsByTags', requested: 'tags' }, + ); + }, + }; + + return new Proxy(source, { + get(target, prop) { + if (prop === 'wipeCache') { + return () => cache.wipe(); + } + if (Object.hasOwn(cachedReads, prop)) { + return cachedReads[prop as keyof AztecNode]; + } + const value = Reflect.get(target, prop); + return typeof value === 'function' ? value.bind(target) : value; + }, + }) as CachingAztecNode; +} + +/** + * Store of node read promises keyed by method and arguments. + * + * Holds no node reference of its own: it only memoizes promises handed to it by the {@link withCache} wrapper that + * owns it. Rejected promises are evicted so callers can retry. A settled value is kept unless the caller's + * `shouldCache` turns it away. + */ +class AztecNodeCache { + private readonly cache = new Map>(); + + /** + * Returns the cached promise for `key`, or runs `read` and caches the in-flight promise. + * + * `shouldCache` runs once the promise settles and answers whether its value is kept. Every settled value is kept by + * default. Callers waiting on a value that is then turned away still share the one in-flight read, so declining to + * keep a value costs nothing on concurrent reads. + */ + public fetch( + key: string, + read: () => Promise, + { shouldCache }: { shouldCache?: (value: T) => boolean } = {}, + ): Promise { + return this.#get(key) ?? this.#set(key, read(), shouldCache); + } + + /** + * The cached promise for each of `keys`, `undefined` where there is none. For callers that batch their own misses + * rather than reading one key. + */ + public peekAll(keys: string[]): (Promise | undefined)[] { + return keys.map(key => this.#get(key)); + } + + /** + * Clears the store. + */ + public wipe(): void { + this.cache.clear(); + } + + #get(key: string): Promise | undefined { + return this.cache.get(key) as Promise | undefined; + } + + #set(key: string, promise: Promise, shouldCache?: (value: T) => boolean): Promise { + const evict = () => { + if (this.cache.get(key) === promise) { + this.cache.delete(key); + } + }; + // A rejected read is evicted so callers can retry; a `shouldCache` that throws is treated the same way. The + // trailing catch keeps a throwing predicate from surfacing as an unhandled rejection on this detached promise. + promise + .then(value => { + if (shouldCache && !shouldCache(value)) { + evict(); + } + }, evict) + .catch(evict); + this.cache.set(key, promise); + return promise; + } +} + +/** + * The block hash `block` pins to (a `BlockHash` or `{ hash }` reference), or `undefined` for every other way of naming + * a block. + * + * A number or a tag names a moving chain position, as does naming no block at all: a reorg can put a different block + * at the same number, and tags follow the growing chain. An `{ archive }` root is different, since it commits to every + * block hash up to its own block and so pins content as tightly as a hash. It stays uncached because no PXE read uses + * one: keeping an `undefined` answer as a non-membership fact is only sound because PXE pins blocks it has already + * seen, and that holds for the references PXE actually builds. + */ +function hashReferenceOf(block: BlockParameter | undefined): BlockHash | undefined { + if (block instanceof BlockHash) { + return block; + } + if (typeof block === 'object' && block !== null && 'hash' in block) { + return block.hash; + } + return undefined; +} + +/** + * Answers one result per entry of `keys`, taking whatever the cache already holds and fetching the rest in a single + * batched call. Each fetched result is cached under its own key, so a later call that overlaps this one fetches only + * the entries it has not seen, and a key repeated within `keys` is asked of the node once. + * + * `fetchMissing` receives the indexes still missing, in order, and must answer one result per requested index. + * `shortResponse` names the method and what it requests, for the error raised when it does not. + */ +function readBatchedPerKey( + cache: AztecNodeCache, + keys: string[], + fetchMissing: (missingIndexes: number[]) => Promise, + shortResponse: { method: string; requested: string }, +): Promise { + const cached = cache.peekAll(keys); + + // One request per missing key rather than per missing position, so a key repeated in `keys` seeds a single entry + // that every position carrying it then reads. + const requestIndexByKey = new Map(); + cached.forEach((result, index) => { + if (result === undefined && !requestIndexByKey.has(keys[index])) { + requestIndexByKey.set(keys[index], index); + } + }); + + const fetchedByKey = new Map>(); + if (requestIndexByKey.size > 0) { + const requestedIndexes = [...requestIndexByKey.values()]; + const batch = fetchMissing(requestedIndexes).then(fetched => { + if (fetched.length !== requestedIndexes.length) { + // Each entry is filled from the batch response by position. If the node returned fewer results than we asked + // for, the positions past the end would read undefined, which the cache keeps as a genuine answer, so a + // truncated response would be cached as fact. Rejecting the whole batch evicts those entries instead, so a + // retry re-fetches every one of them. + throw new Error( + `${shortResponse.method} returned ${fetched.length} results for ${requestedIndexes.length} requested ` + + `${shortResponse.requested}`, + ); + } + return fetched; + }); + [...requestIndexByKey.keys()].forEach((key, batchIndex) => { + fetchedByKey.set( + key, + cache.fetch(key, () => batch.then(fetched => fetched[batchIndex])), + ); + }); + } + + // Every position is answered: a cache hit, or the entry its key's request seeded just above. + return Promise.all(keys.map((key, index) => cached[index] ?? fetchedByKey.get(key)!)); +} + +/** + * Whether a tag query's answer can no longer change, and so can be cached. + * + * Two things would let it change, and the query has to rule out both. `referenceBlock` names the chain the answer + * belongs to, so the call fails once that block is gone rather than answering from a chain that reorged. `toBlock` + * stops the query below the tip, so blocks yet to be produced cannot add logs to the answer. Where the query starts + * does not matter: an open lower end reaches only blocks that are already behind it, which no longer move. PXE's tag + * queries get both from the `getAll*LogsByTags` helpers, which take them from the anchor block the query is pinned to. + */ +function hasImmutableAnswer(query: LogsQueryBase): boolean { + return query.referenceBlock !== undefined && query.toBlock !== undefined; +} + +/** Cache-key segment for the parts of a private tag query that every tag in it shares. */ +function privateLogsQueryKey(query: PrivateLogsQuery): string { + const parts: QueryKeyParts = { ...sharedQueryKeyParts(query) }; + return Object.values(parts).map(keyPart).join(':'); +} + +/** Cache-key segment for the parts of a public tag query that every tag in it shares. */ +function publicLogsQueryKey(query: PublicLogsQuery): string { + const parts: QueryKeyParts = { + ...sharedQueryKeyParts(query), + contractAddress: query.contractAddress, + }; + return Object.values(parts).map(keyPart).join(':'); +} + +/** + * The fields of a query that a key has to cover, which is all of them but `tags`: each tag is keyed on its own by + * {@link tagQueryKey}. + * + * The key builders are total over this type, so a field added to a query fails to compile there rather than silently + * being left out of the key and colliding with a query that differs only in it. + */ +type QueryKeyParts = { [K in keyof Required>]: unknown }; + +/** The key parts {@link PrivateLogsQuery} and {@link PublicLogsQuery} have in common. */ +function sharedQueryKeyParts(query: LogsQueryBase): QueryKeyParts { + return { + referenceBlock: query.referenceBlock, + fromBlock: query.fromBlock, + toBlock: query.toBlock, + txHash: query.txHash, + includeEffects: query.includeEffects, + limitPerTag: query.limitPerTag, + }; +} + +/** + * Cache-key segment for one tag entry, covering the pagination cursor as well as the tag: each page of a tag's stream + * is a read of its own, and a repeated query asks for the same pages in the same order. + */ +function tagQueryKey(entry: TagQuery): string { + return 'tag' in entry ? `${keyPart(entry.tag)}@${keyPart(entry.afterLog)}` : keyPart(entry); +} + +/** + * Renders a call argument as a stable cache-key segment, via its own `toString` when it has one, else as JSON. + * + * Example: the options object `{ includeTransactions: true }` renders as `'{"includeTransactions":true}'`, and an + * absent optional argument as `'undefined'`. + */ +function 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/notes/note_service.test.ts b/yarn-project/pxe/src/notes/note_service.test.ts index 193c7ddb3b7f..6273ed3bb728 100644 --- a/yarn-project/pxe/src/notes/note_service.test.ts +++ b/yarn-project/pxe/src/notes/note_service.test.ts @@ -1,4 +1,4 @@ -import { BlockNumber, SlotNumber } from '@aztec/foundation/branded-types'; +import { BlockNumber } from '@aztec/foundation/branded-types'; import { Fr } from '@aztec/foundation/curves/bn254'; import { KeyStore } from '@aztec/key-store'; import { openTmpStore } from '@aztec/kv-store/lmdb-v2'; @@ -11,14 +11,14 @@ import { deriveKeys } from '@aztec/stdlib/keys'; import { NoteDao, NoteStatus } from '@aztec/stdlib/note'; import { makeBlockHeader } from '@aztec/stdlib/testing'; import { MerkleTreeId } from '@aztec/stdlib/trees'; -import { type IndexedTxEffect, TxEffect, TxHash } from '@aztec/stdlib/tx'; +import { TxEffect, TxHash } from '@aztec/stdlib/tx'; import { jest } from '@jest/globals'; import { mock } from 'jest-mock-extended'; import type { NoteValidationRequest } from '../contract_function_simulator/noir-structs/note_validation_request.js'; import { NoteStore } from '../storage/note_store/note_store.js'; -import { NoteService } from './note_service.js'; +import { NoteService, type NoteValidationTxData } from './note_service.js'; describe('NoteService', () => { let noteStore: NoteStore; @@ -213,7 +213,7 @@ describe('NoteService', () => { let txHash: TxHash; let txEffect: TxEffect; - let indexedTxEffect: IndexedTxEffect; + let validationTxData: NoteValidationTxData; let blockNumber: BlockNumber; let buildRequest: (overrides?: Partial) => NoteValidationRequest; @@ -239,12 +239,11 @@ describe('NoteService', () => { noteHashes: [uniqueNoteHash], }); - indexedTxEffect = { + validationTxData = { l2BlockNumber: blockNumber, l2BlockHash: BlockHash.random(), - data: txEffect, + noteHashes: txEffect.noteHashes, txIndexInBlock: 0, - slotNumber: SlotNumber(Number(blockNumber)), }; /* Happy path context conditions: @@ -273,7 +272,7 @@ describe('NoteService', () => { }); it('should store note if it exists in a tx effect', async () => { - await noteService.validateAndStoreNotes([buildRequest()], recipient.address, defaultTxEffectsMap()); + await noteService.validateAndStoreNotes([buildRequest()], recipient.address, defaultValidationTxDataMap()); // Verify note was stored const notes = await noteStore.getNotes({ contractAddress, scopes: [recipient.address] }, 'test'); @@ -297,7 +296,7 @@ describe('NoteService', () => { noteService.validateAndStoreNotes( [buildRequest({ txHash: TxHash.random() })], recipient.address, - defaultTxEffectsMap(), + defaultValidationTxDataMap(), ), ).rejects.toThrow(/Could not find tx effect/); }); @@ -307,7 +306,7 @@ describe('NoteService', () => { noteService.validateAndStoreNotes( [buildRequest({ noteHash: Fr.random() })], recipient.address, - defaultTxEffectsMap(), + defaultValidationTxDataMap(), ), ).rejects.toThrow(/is not present in tx/); }); @@ -316,12 +315,12 @@ describe('NoteService', () => { setSyncedBlockNumber(BlockNumber(blockNumber - 1)); await expect( - noteService.validateAndStoreNotes([buildRequest()], recipient.address, defaultTxEffectsMap()), + noteService.validateAndStoreNotes([buildRequest()], recipient.address, defaultValidationTxDataMap()), ).rejects.toThrow(/Obtained a newer tx effect for .* for a note validation request than the anchor block/); }); it('should batch findLeavesIndexes across notes', async () => { - // Two notes from the same tx so we can reuse the indexedTxEffect; each carries its own unique note hash. + // Two notes from the same tx so we can reuse the validationTxData; each carries its own unique note hash. const otherNoteHash = Fr.random(); const otherNullifier = Fr.random(); const otherNoteNonce = Fr.random(); @@ -330,14 +329,11 @@ describe('NoteService', () => { await siloNoteHash(contractAddress, otherNoteHash), ); - const sharedTxEffect: IndexedTxEffect = { - ...indexedTxEffect, - data: TxEffect.from({ - ...indexedTxEffect.data, - noteHashes: [uniqueNoteHash, otherUniqueNoteHash], - }), + const sharedTxData: NoteValidationTxData = { + ...validationTxData, + noteHashes: [uniqueNoteHash, otherUniqueNoteHash], }; - const map = new Map([[txHash.toString(), sharedTxEffect]]); + const map = new Map([[txHash.toString(), sharedTxData]]); await noteService.validateAndStoreNotes( [ @@ -367,7 +363,7 @@ describe('NoteService', () => { ); }); - await noteService.validateAndStoreNotes([buildRequest()], recipient.address, defaultTxEffectsMap()); + await noteService.validateAndStoreNotes([buildRequest()], recipient.address, defaultValidationTxDataMap()); const verifyNoteNullifiedInJobContext = async (jobId: string) => { // Now we verify that the note is stored as nullified by checking it can be retrieved only with @@ -400,8 +396,8 @@ describe('NoteService', () => { await verifyNoteNullifiedInJobContext('fresh-job'); }); - function defaultTxEffectsMap() { - return new Map([[txHash.toString(), indexedTxEffect]]); + function defaultValidationTxDataMap() { + return new Map([[txHash.toString(), validationTxData]]); } }); }); diff --git a/yarn-project/pxe/src/notes/note_service.ts b/yarn-project/pxe/src/notes/note_service.ts index b84ef27739f2..c366add9aedd 100644 --- a/yarn-project/pxe/src/notes/note_service.ts +++ b/yarn-project/pxe/src/notes/note_service.ts @@ -1,12 +1,12 @@ import { chunk } from '@aztec/foundation/collection'; import { Fr } from '@aztec/foundation/curves/bn254'; import type { AztecAddress } from '@aztec/stdlib/aztec-address'; -import { BlockHash, type DataInBlock } from '@aztec/stdlib/block'; +import { BlockHash, type DataInBlock, type InBlock } from '@aztec/stdlib/block'; import { computeUniqueNoteHash, siloNoteHash, siloNullifier } from '@aztec/stdlib/hash'; import { type AztecNode, MAX_RPC_LEN } from '@aztec/stdlib/interfaces/client'; import { Note, NoteDao, NoteStatus } from '@aztec/stdlib/note'; import { MerkleTreeId } from '@aztec/stdlib/trees'; -import type { BlockHeader, IndexedTxEffect } from '@aztec/stdlib/tx'; +import type { BlockHeader } from '@aztec/stdlib/tx'; import type { NoteValidationRequest } from '../contract_function_simulator/noir-structs/note_validation_request.js'; import type { NoteStore } from '../storage/note_store/note_store.js'; @@ -96,7 +96,7 @@ export class NoteService { } /** - * Validates and stores a batch of notes against pre-fetched tx effects. + * Validates and stores a batch of notes against the pre-fetched onchain context of their txs. * * For each request we must verify that: * - the note actually exists in the corresponding tx effect (and thus in the note hash tree), and @@ -112,13 +112,13 @@ export class NoteService { * * @param requests - The notes to validate and store. * @param scope - The scope under which the notes are being stored. - * @param txEffects - Pre-fetched tx effects keyed by `TxHash.toString()`. Must contain entries for every request's - * txHash; missing entries are treated as a node bug and cause an error. + * @param validationTxData - The onchain context of each request's tx, keyed by `TxHash.toString()`. Must contain + * entries for every request's txHash; missing entries are treated as a node bug and cause an error. */ public async validateAndStoreNotes( requests: NoteValidationRequest[], scope: AztecAddress, - txEffects: Map, + validationTxData: ReadonlyMap, ): Promise { if (requests.length === 0) { return; @@ -159,8 +159,8 @@ export class NoteService { const { uniqueNoteHash, siloedNullifier } = computed[i]; const nullifierIndex = nullifierIndexes[i]; - const txEffect = txEffects.get(txHash.toString()); - if (!txEffect) { + const txData = validationTxData.get(txHash.toString()); + if (!txData) { // We error out instead of just logging a warning and skipping the note because this would indicate a bug. This // is because the node has already served info about this tx either when obtaining the log (LogResult carries // the tx info) or when getting metadata for the offchain message (before the message got passed to @@ -168,7 +168,7 @@ export class NoteService { throw new Error(`Could not find tx effect for tx hash ${txHash} when processing a note.`); } - if (txEffect.l2BlockNumber > anchorBlockNumber) { + if (txData.l2BlockNumber > anchorBlockNumber) { // If the message was delivered onchain, this would indicate a bug: log sync should never load logs from blocks // newer than the anchor block. If the note came via an offchain message, it would likely also be a bug, since // we sync a new anchor block before calling `process_message`. For this not to be a bug, the message would @@ -181,7 +181,7 @@ export class NoteService { } // Find the index of the note hash in the noteHashes array to determine note ordering within the tx - const noteIndexInTx = txEffect.data.noteHashes.findIndex(nh => nh.equals(uniqueNoteHash)); + const noteIndexInTx = txData.noteHashes.findIndex(nh => nh.equals(uniqueNoteHash)); if (noteIndexInTx === -1) { // Similar to the comment above - we error out as this would indicate a bug in nonce discovery. throw new Error(`Note hash ${noteHash} (uniqued as ${uniqueNoteHash}) is not present in tx ${txHash}`); @@ -198,9 +198,9 @@ export class NoteService { noteHash, siloedNullifier, txHash, - txEffect.l2BlockNumber, - txEffect.l2BlockHash.toString(), - txEffect.txIndexInBlock, + txData.l2BlockNumber, + txData.l2BlockHash.toString(), + txData.txIndexInBlock, noteIndexInTx, ), ); @@ -228,3 +228,9 @@ export class NoteService { ).flat(); } } + +/** The onchain context of the tx a note validation request points at: where it was mined and its note hashes. */ +export type NoteValidationTxData = InBlock & { + noteHashes: Fr[]; + txIndexInBlock: number; +}; diff --git a/yarn-project/pxe/src/pxe.ts b/yarn-project/pxe/src/pxe.ts index 571aacffdba2..5501835e3851 100644 --- a/yarn-project/pxe/src/pxe.ts +++ b/yarn-project/pxe/src/pxe.ts @@ -62,7 +62,6 @@ import { BlockSynchronizer } from './block_synchronizer/index.js'; import type { PXEConfig } from './config/index.js'; import { ContractClassService } from './contract/contract_class_service.js'; import { ContractSyncService } from './contract/contract_sync_service.js'; -import { BenchmarkedNodeFactory } from './contract_function_simulator/benchmarked_node.js'; import { ContractFunctionSimulator, generateSimulatedProvingResult, @@ -74,6 +73,7 @@ import { PrivateEventFilterValidator } from './events/private_event_filter_valid import type { ExecutionHooks } from './hooks/index.js'; import { JobCoordinator } from './job_coordinator/job_coordinator.js'; import { TxResolverService } from './messages/tx_resolver_service.js'; +import { type CachingAztecNode, withCache } from './node/caching_aztec_node.js'; import { PrivateKernelExecutionProver, type PrivateKernelExecutionProverConfig, @@ -230,7 +230,7 @@ export type RegisteredTaggingSecretSource = */ export class PXE { private constructor( - private node: AztecNode, + private node: CachingAztecNode, private nodeDebug: AztecNodeDebug | undefined, private db: AztecAsyncKVStore, private blockStateSynchronizer: BlockSynchronizer, @@ -315,18 +315,22 @@ export class PXE { l2TipsStore, factStore, } = openPxeStores(store, initialBlockHash); - const contractClassService = new ContractClassService(node, contractStore); + // Every PXE consumer reads through this one wrapper, so a read cached by one is served to the rest. Only + // immutable, hash-pinned reads are cached (the rule lives on `withCache`), which makes it safe regardless of + // the consumer's anchor block; the block synchronizer wipes it on anchor updates to bound memory. + const readCachedNode = withCache(node); + const contractClassService = new ContractClassService(readCachedNode, contractStore); const contractSyncService = new ContractSyncService( - node, + readCachedNode, contractStore, contractClassService, noteStore, createLogger('pxe:contract_sync', bindings), ); - const txResolver = new TxResolverService(node); + const txResolver = new TxResolverService(readCachedNode); const synchronizer = new BlockSynchronizer( - node, + readCachedNode, store, anchorBlockStore, noteStore, @@ -334,7 +338,6 @@ export class PXE { factStore, l2TipsStore, contractSyncService, - contractClassService, config, bindings, ); @@ -355,7 +358,7 @@ export class PXE { const jobQueue = new SerialQueue(); const pxe = new PXE( - node, + readCachedNode, nodeDebug, store, synchronizer, @@ -410,7 +413,7 @@ export class PXE { noteStore: this.noteStore, keyStore: this.keyStore, addressStore: this.addressStore, - aztecNode: BenchmarkedNodeFactory.create(this.node), + aztecNode: this.node, l2TipsStore: this.l2TipsStore, senderTaggingStore: this.senderTaggingStore, recipientTaggingStore: this.recipientTaggingStore, @@ -1016,6 +1019,7 @@ export class PXE { // computationally demanding that it'd be rare for someone to try to do it concurrently regardless. return this.#putInJobQueue(async jobId => { const totalTimer = new Timer(); + const recording = this.node.startRecording(); try { const syncTimer = new Timer(); await this.#maybeSync(); @@ -1063,7 +1067,7 @@ export class PXE { const txProvingResult = new TxProvingResult(privateExecutionResult, publicInputs, chonkProof!, { timings, - nodeRPCCalls: contractFunctionSimulator?.getStats().nodeRPCCalls, + nodeRPCCalls: recording.stop(), }); // We keep track of which tagging indices we've used in this tx so that we don't repeat them in future txs @@ -1084,6 +1088,9 @@ export class PXE { return txProvingResult; } catch (err: any) { throw this.#contextualizeError(err, inspect(txRequest), inspect(privateExecutionResult)); + } finally { + // Idempotent cleanup for the error and early-exit paths. The success path already stopped the recording. + recording.stop(); } }); } @@ -1101,6 +1108,7 @@ export class PXE { // We disable concurrent profiles for consistency with simulateTx. return this.#putInJobQueue(async jobId => { const totalTimer = new Timer(); + const recording = this.node.startRecording(); try { const txInfo = { origin: txRequest.origin, @@ -1166,10 +1174,12 @@ export class PXE { total - ((syncTime ?? 0) + (proving ?? 0) + perFunction.reduce((acc, { time }) => acc + time, 0)), }; - const simulatorStats = contractFunctionSimulator.getStats(); - return new TxProfileResult(executionSteps, { timings, nodeRPCCalls: simulatorStats.nodeRPCCalls }); + return new TxProfileResult(executionSteps, { timings, nodeRPCCalls: recording.stop() }); } catch (err: any) { throw this.#contextualizeError(err, inspect(txRequest), `profileMode=${profileMode}`); + } finally { + // Idempotent cleanup for the error and early-exit paths. The success path already stopped the recording. + recording.stop(); } }); } @@ -1208,6 +1218,7 @@ export class PXE { // to the capsules), and we need to prevent concurrent runs from interfering with one another (e.g. attempting to // delete the same read value, or reading values that another simulation is currently modifying). return this.#putInJobQueue(async jobId => { + const recording = this.node.startRecording(); try { const totalTimer = new Timer(); const txInfo = { @@ -1328,10 +1339,9 @@ export class PXE { : {}), }); - const simulatorStats = contractFunctionSimulator.getStats(); return TxSimulationResult.fromPrivateSimulationResultAndPublicOutput(privateSimulationResult, publicOutput, { timings, - nodeRPCCalls: simulatorStats.nodeRPCCalls, + nodeRPCCalls: recording.stop(), }); } catch (err: any) { throw this.#contextualizeError( @@ -1341,6 +1351,9 @@ export class PXE { `skipTxValidation=${skipTxValidation}`, `scopes=${scopes.map(s => s.toString()).join(', ')}`, ); + } finally { + // Idempotent cleanup for the error and early-exit paths. The success path already stopped the recording. + recording.stop(); } }); } @@ -1357,6 +1370,7 @@ export class PXE { // to the capsules), and we need to prevent concurrent runs from interfering with one another (e.g. attempting to // delete the same read value, or reading values that another execution is currently modifying). return this.#putInJobQueue(async jobId => { + const recording = this.node.startRecording(); try { const totalTimer = new Timer(); const syncTimer = new Timer(); @@ -1396,12 +1410,11 @@ export class PXE { unaccounted: totalTime - (syncTime + perFunction.reduce((acc, { time }) => acc + time, 0)), }; - const simulationStats = contractFunctionSimulator.getStats(); return { result: executionResult, offchainEffects, anchorBlockTimestamp: anchorBlockHeader.globalVariables.timestamp, - stats: { timings, nodeRPCCalls: simulationStats.nodeRPCCalls }, + stats: { timings, nodeRPCCalls: recording.stop() }, }; } catch (err: any) { const { to, name, args } = call; @@ -1411,6 +1424,9 @@ export class PXE { `executeUtility ${to}:${name}(${stringifiedArgs})`, `scopes=${scopes.map(s => s.toString()).join(', ')}`, ); + } finally { + // Idempotent cleanup for the error and early-exit paths. The success path already stopped the recording. + recording.stop(); } }); } diff --git a/yarn-project/pxe/src/tagging/get_all_logs_by_tags.test.ts b/yarn-project/pxe/src/tagging/get_all_logs_by_tags.test.ts index c55f0e1abe81..21a45de966a6 100644 --- a/yarn-project/pxe/src/tagging/get_all_logs_by_tags.test.ts +++ b/yarn-project/pxe/src/tagging/get_all_logs_by_tags.test.ts @@ -12,7 +12,10 @@ import { getAllPrivateLogsByTags } from './get_all_logs_by_tags.js'; // We don't bother testing getAllPublicLogsByTagsFromContract because both of the functions are a simple wrapper around // the same per-tag pagination loop, so testing the private logs function is enough. -const MOCK_ANCHOR_BLOCK_HASH = BlockHash.random(); +const MOCK_ANCHOR = { hash: BlockHash.random(), number: BlockNumber(100) }; + +/** The exclusive upper bound queries anchored at `MOCK_ANCHOR` are expected to carry: one block past its number. */ +const MOCK_ANCHOR_TO_BLOCK = BlockNumber(MOCK_ANCHOR.number + 1); /** Builds a log with a stable blockNumber/logIndexWithinTx so we can assert cursor wiring. */ function makeLog({ blockNumber = 1, logIndexWithinTx = 0 }: { blockNumber?: number; logIndexWithinTx?: number } = {}) { @@ -42,14 +45,14 @@ describe('getAllPrivateLogsByTags', () => { it('returns empty arrays when no logs found', async () => { aztecNode.getPrivateLogsByTags.mockResolvedValue(tags.map(() => [])); - const result = await getAllPrivateLogsByTags(aztecNode, tags, MOCK_ANCHOR_BLOCK_HASH); + const result = await getAllPrivateLogsByTags(aztecNode, tags, MOCK_ANCHOR); expect(result).toEqual([[], [], []]); expect(aztecNode.getPrivateLogsByTags).toHaveBeenCalledWith({ tags, - referenceBlock: MOCK_ANCHOR_BLOCK_HASH, + referenceBlock: MOCK_ANCHOR.hash, fromBlock: undefined, - toBlock: undefined, + toBlock: MOCK_ANCHOR_TO_BLOCK, includeEffects: false, } satisfies PrivateLogsQuery); }); @@ -58,7 +61,7 @@ describe('getAllPrivateLogsByTags', () => { const logsPerTag = tags.map((_tag, i) => Array.from({ length: i + 1 }, () => makeLog())); aztecNode.getPrivateLogsByTags.mockResolvedValue(logsPerTag); - const result = await getAllPrivateLogsByTags(aztecNode, tags, MOCK_ANCHOR_BLOCK_HASH); + const result = await getAllPrivateLogsByTags(aztecNode, tags, MOCK_ANCHOR); expect(result.map(logs => logs.length)).toEqual([1, 2, 3]); expect(aztecNode.getPrivateLogsByTags).toHaveBeenCalledTimes(1); @@ -71,7 +74,7 @@ describe('getAllPrivateLogsByTags', () => { aztecNode.getPrivateLogsByTags.mockResolvedValueOnce(firstPage).mockResolvedValueOnce(secondPage); - const result = await getAllPrivateLogsByTags(aztecNode, tags, MOCK_ANCHOR_BLOCK_HASH); + const result = await getAllPrivateLogsByTags(aztecNode, tags, MOCK_ANCHOR); expect(result.map(logs => logs.length)).toEqual([MAX_LOGS_PER_TAG + 5, 1, 0]); expect(aztecNode.getPrivateLogsByTags).toHaveBeenCalledTimes(2); @@ -79,18 +82,18 @@ describe('getAllPrivateLogsByTags', () => { // Round 1: all tags queried with bare tags expect(aztecNode.getPrivateLogsByTags).toHaveBeenNthCalledWith(1, { tags, - referenceBlock: MOCK_ANCHOR_BLOCK_HASH, + referenceBlock: MOCK_ANCHOR.hash, fromBlock: undefined, - toBlock: undefined, + toBlock: MOCK_ANCHOR_TO_BLOCK, includeEffects: false, }); // Round 2: only tag[0] re-queried, with an afterLog cursor pointing at the last log of round 1 expect(aztecNode.getPrivateLogsByTags).toHaveBeenNthCalledWith(2, { tags: [{ tag: tags[0], afterLog: LogCursor.fromLog(lastLogOfFirstPage) }], - referenceBlock: MOCK_ANCHOR_BLOCK_HASH, + referenceBlock: MOCK_ANCHOR.hash, fromBlock: undefined, - toBlock: undefined, + toBlock: MOCK_ANCHOR_TO_BLOCK, includeEffects: false, }); }); @@ -98,7 +101,7 @@ describe('getAllPrivateLogsByTags', () => { it('handles empty tags array', async () => { aztecNode.getPrivateLogsByTags.mockResolvedValue([]); - const result = await getAllPrivateLogsByTags(aztecNode, [], MOCK_ANCHOR_BLOCK_HASH); + const result = await getAllPrivateLogsByTags(aztecNode, [], MOCK_ANCHOR); expect(result).toEqual([]); expect(aztecNode.getPrivateLogsByTags).not.toHaveBeenCalled(); @@ -107,7 +110,7 @@ describe('getAllPrivateLogsByTags', () => { it('forwards options (fromBlock/toBlock/includeEffects/limitPerTag) to the node', async () => { aztecNode.getPrivateLogsByTags.mockResolvedValue(tags.map(() => [])); - await getAllPrivateLogsByTags(aztecNode, tags, MOCK_ANCHOR_BLOCK_HASH, { + await getAllPrivateLogsByTags(aztecNode, tags, MOCK_ANCHOR, { fromBlock: BlockNumber(5), toBlock: BlockNumber(10), includeEffects: true, @@ -116,7 +119,7 @@ describe('getAllPrivateLogsByTags', () => { expect(aztecNode.getPrivateLogsByTags).toHaveBeenCalledWith({ tags, - referenceBlock: MOCK_ANCHOR_BLOCK_HASH, + referenceBlock: MOCK_ANCHOR.hash, fromBlock: BlockNumber(5), toBlock: BlockNumber(10), includeEffects: true, @@ -124,6 +127,16 @@ describe('getAllPrivateLogsByTags', () => { }); }); + it('narrows a toBlock that reaches past the anchor block', async () => { + aztecNode.getPrivateLogsByTags.mockResolvedValue(tags.map(() => [])); + + await getAllPrivateLogsByTags(aztecNode, tags, MOCK_ANCHOR, { toBlock: BlockNumber(500) }); + + expect(aztecNode.getPrivateLogsByTags).toHaveBeenCalledWith( + expect.objectContaining({ toBlock: MOCK_ANCHOR_TO_BLOCK }), + ); + }); + describe('batching when tags exceed MAX_RPC_LEN', () => { let manyTags: SiloedTag[]; @@ -139,7 +152,7 @@ describe('getAllPrivateLogsByTags', () => { return Promise.resolve(query.tags.map(() => [makeLog()])); }); - const result = await getAllPrivateLogsByTags(aztecNode, manyTags, MOCK_ANCHOR_BLOCK_HASH); + const result = await getAllPrivateLogsByTags(aztecNode, manyTags, MOCK_ANCHOR); expect(result).toHaveLength(MAX_RPC_LEN + 50); expect(result.every(logs => logs.length === 1)).toBe(true); @@ -148,16 +161,16 @@ describe('getAllPrivateLogsByTags', () => { expect(aztecNode.getPrivateLogsByTags).toHaveBeenCalledTimes(2); expect(aztecNode.getPrivateLogsByTags).toHaveBeenNthCalledWith(1, { tags: batch1Tags, - referenceBlock: MOCK_ANCHOR_BLOCK_HASH, + referenceBlock: MOCK_ANCHOR.hash, fromBlock: undefined, - toBlock: undefined, + toBlock: MOCK_ANCHOR_TO_BLOCK, includeEffects: false, }); expect(aztecNode.getPrivateLogsByTags).toHaveBeenNthCalledWith(2, { tags: batch2Tags, - referenceBlock: MOCK_ANCHOR_BLOCK_HASH, + referenceBlock: MOCK_ANCHOR.hash, fromBlock: undefined, - toBlock: undefined, + toBlock: MOCK_ANCHOR_TO_BLOCK, includeEffects: false, }); }); @@ -184,7 +197,7 @@ describe('getAllPrivateLogsByTags', () => { return Promise.resolve(query.tags.map(() => [makeLog()])); }); - const result = await getAllPrivateLogsByTags(aztecNode, manyTags, MOCK_ANCHOR_BLOCK_HASH); + const result = await getAllPrivateLogsByTags(aztecNode, manyTags, MOCK_ANCHOR); expect(result).toHaveLength(MAX_RPC_LEN + 50); // First tag in batch 1 got paginated: MAX_LOGS_PER_TAG + 3 diff --git a/yarn-project/pxe/src/tagging/get_all_logs_by_tags.ts b/yarn-project/pxe/src/tagging/get_all_logs_by_tags.ts index c18663a24fa5..22b297a5dcad 100644 --- a/yarn-project/pxe/src/tagging/get_all_logs_by_tags.ts +++ b/yarn-project/pxe/src/tagging/get_all_logs_by_tags.ts @@ -1,4 +1,4 @@ -import type { BlockNumber } from '@aztec/foundation/branded-types'; +import { BlockNumber } from '@aztec/foundation/branded-types'; import type { AztecAddress } from '@aztec/stdlib/aztec-address'; import type { BlockHash } from '@aztec/stdlib/block'; import { MAX_RPC_LEN } from '@aztec/stdlib/interfaces/api-limit'; @@ -11,6 +11,31 @@ import { queryAllPrivateLogsByTags, queryAllPublicLogsByTags, } from '@aztec/stdlib/logs'; +import type { BlockHeader } from '@aztec/stdlib/tx'; + +/** + * The block a tag query is anchored to. + * + * Both fields come from one header (see {@link logQueryAnchorOf}), which is what keeps the bound and the reorg check + * talking about the same block. + */ +export type LogQueryAnchor = { + /** + * Hash of the anchor block, naming the chain the query must be answered on: the node throws if that block is gone, + * which is how a reorg surfaces. + */ + hash: BlockHash; + /** Height of the anchor block, bounding the query to blocks at or below it. */ + number: BlockNumber; +}; + +/** + * The {@link LogQueryAnchor} naming `anchorBlockHeader`: its hash and its height. Callers build it once and reuse + * it across every query anchored to that block, so the header is hashed only once. + */ +export async function logQueryAnchorOf(anchorBlockHeader: BlockHeader): Promise { + return { hash: await anchorBlockHeader.hash(), number: anchorBlockHeader.getBlockNumber() }; +} /** Optional block-range, effects opt-in, and pagination cap shared by both wrappers. */ export type GetAllLogsByTagsOptions = { @@ -61,15 +86,15 @@ async function getAllPagesInBatches( aztecNode: AztecNode, tags: SiloedTag[], - anchorBlockHash: BlockHash, + anchor: LogQueryAnchor, options: Opts = {} as Opts, ): Promise[][]> { return getAllPagesInBatches( @@ -77,9 +102,9 @@ export function getAllPrivateLogsByTags queryAllPrivateLogsByTags(aztecNode, { tags: batch, - referenceBlock: anchorBlockHash, + referenceBlock: anchor.hash, fromBlock: options.fromBlock, - toBlock: options.toBlock, + toBlock: exclusiveUpperBound(anchor, options.toBlock), includeEffects: options.includeEffects ?? false, limitPerTag: options.limitPerTag, }) as Promise[][]>, @@ -92,8 +117,8 @@ export function getAllPrivateLogsByTags[][]> { return getAllPagesInBatches( @@ -110,11 +135,22 @@ export function getAllPublicLogsByTagsFromContract[][]>, ); } + +/** + * Exclusive upper block bound for a query anchored at `anchor`: one past the anchor block, or the caller's own + * `toBlock` when that is tighter. + * + * The bound is spelled out in the request rather than left implicit in `referenceBlock`, so the request itself + * names a fixed block range that no later block can widen. + */ +function exclusiveUpperBound(anchor: LogQueryAnchor, toBlock: BlockNumber | undefined): BlockNumber { + return BlockNumber(Math.min(toBlock ?? Infinity, anchor.number + 1)); +} diff --git a/yarn-project/pxe/src/tagging/index.ts b/yarn-project/pxe/src/tagging/index.ts index 462d0bdaec4b..b6b3996d3431 100644 --- a/yarn-project/pxe/src/tagging/index.ts +++ b/yarn-project/pxe/src/tagging/index.ts @@ -13,7 +13,12 @@ export { syncTaggedPrivateLogs } from './recipient_sync/sync_tagged_private_logs export { syncSenderTaggingIndexes } from './sender_sync/sync_sender_tagging_indexes.js'; export { persistSenderTaggingIndexRangesForTx } from './persist_sender_tagging_index_ranges.js'; export { UNFINALIZED_TAGGING_INDEXES_WINDOW_LEN, INITIAL_CONSTRAINED_PROBE_LEN } from './constants.js'; -export { getAllPrivateLogsByTags, getAllPublicLogsByTagsFromContract } from './get_all_logs_by_tags.js'; +export { + type LogQueryAnchor, + getAllPrivateLogsByTags, + getAllPublicLogsByTagsFromContract, + logQueryAnchorOf, +} from './get_all_logs_by_tags.js'; // Re-export tagging-related types from stdlib export { AppTaggingSecret, Tag, SiloedTag } from '@aztec/stdlib/logs'; diff --git a/yarn-project/pxe/src/tagging/recipient_sync/sync_tagged_private_logs.bench.test.ts b/yarn-project/pxe/src/tagging/recipient_sync/sync_tagged_private_logs.bench.test.ts index 94a74a5b1ca1..b613ce802469 100644 --- a/yarn-project/pxe/src/tagging/recipient_sync/sync_tagged_private_logs.bench.test.ts +++ b/yarn-project/pxe/src/tagging/recipient_sync/sync_tagged_private_logs.bench.test.ts @@ -13,7 +13,7 @@ import { mkdir, writeFile } from 'fs/promises'; import { type MockProxy, mock } from 'jest-mock-extended'; import path from 'path'; -import { BenchmarkedNodeFactory } from '../../contract_function_simulator/benchmarked_node.js'; +import { withRecording } from '../../node/benchmarked_node.js'; import { RecipientTaggingStore } from '../../storage/tagging_store/recipient_tagging_store.js'; import { UNFINALIZED_TAGGING_INDEXES_WINDOW_LEN, syncTaggedPrivateLogs } from '../index.js'; import { computeSiloedTagForIndex, extractTags } from '../testing/tag_query_test_utils.js'; @@ -35,7 +35,7 @@ import { computeSiloedTagForIndex, extractTags } from '../testing/tag_query_test * * Metrics, per scenario: * - `tag-queries`: total tags queried, the throughput win. - * - `rpc-round-trips`: sequential blocking waits on the node, via `BenchmarkedNodeFactory`. The latency axis: this + * - `rpc-round-trips`: sequential blocking waits on the node, via `withRecording`. The latency axis: this * grows with K per the complexity analysis on `INITIAL_CONSTRAINED_PROBE_LEN`, and depends only on K, not on secret * count. A round's tags are chunked at MAX_RPC_LEN (=100) into parallel calls internally, but those overlap, so a * wide round is still one round-trip; that is why round-trips, not raw call count, is the latency axis. @@ -193,7 +193,8 @@ describeBench('syncTaggedPrivateLogs constrained-sync bench', () => { // Wrap the node so we capture round-trips and blocking time the same way the client_flows app benches do. The // Proxy delegates to the underlying mock, so `mock.calls` still records every query for tag counting. - const benchmarkedNode = BenchmarkedNodeFactory.create(aztecNode); + const benchmarkedNode = withRecording(aztecNode); + const recording = benchmarkedNode.startRecording(); const logs = await syncTaggedPrivateLogs( secrets, @@ -209,7 +210,7 @@ describeBench('syncTaggedPrivateLogs constrained-sync bench', () => { // Round-trips and blocking time from the same instrumentation the app benches use. `syncTaggedPrivateLogs` only // ever calls `getPrivateLogsByTags`, so every round-trip is that method. - const { roundTrips } = benchmarkedNode.getStats(); + const { roundTrips } = recording.stop(); const rpcRoundTrips = roundTrips.roundTrips; const rpcBlockingTimeMs = roundTrips.totalBlockingTime; diff --git a/yarn-project/pxe/src/tagging/recipient_sync/sync_tagged_private_logs.ts b/yarn-project/pxe/src/tagging/recipient_sync/sync_tagged_private_logs.ts index e892b1b58e54..56a61407db0e 100644 --- a/yarn-project/pxe/src/tagging/recipient_sync/sync_tagged_private_logs.ts +++ b/yarn-project/pxe/src/tagging/recipient_sync/sync_tagged_private_logs.ts @@ -1,6 +1,5 @@ -import { BlockNumber } from '@aztec/foundation/branded-types'; +import type { BlockNumber } from '@aztec/foundation/branded-types'; import { isDefined } from '@aztec/foundation/types'; -import type { BlockHash } from '@aztec/stdlib/block'; import type { AztecNode } from '@aztec/stdlib/interfaces/client'; import type { AppTaggingSecret, LogResult } from '@aztec/stdlib/logs'; import { AppTaggingSecretKind, SiloedTag } from '@aztec/stdlib/logs'; @@ -12,7 +11,7 @@ import { UNFINALIZED_TAGGING_INDEXES_WINDOW_LEN, unfinalizedTaggingIndexesWindowEnd, } from '../constants.js'; -import { getAllPrivateLogsByTags } from '../get_all_logs_by_tags.js'; +import { type LogQueryAnchor, getAllPrivateLogsByTags, logQueryAnchorOf } from '../get_all_logs_by_tags.js'; import { findHighestIndexes } from './utils/find_highest_indexes.js'; /** @@ -94,8 +93,7 @@ export async function syncTaggedPrivateLogs( return []; } - const anchorBlockNumber = anchorBlockHeader.getBlockNumber(); - const anchorBlockHash = await anchorBlockHeader.hash(); + const anchor = await logQueryAnchorOf(anchorBlockHeader); const currentTimestamp = anchorBlockHeader.globalVariables.timestamp; // Read stored indexes from the db and compute the initial [start, end) range for each secret @@ -104,7 +102,7 @@ export async function syncTaggedPrivateLogs( while (pending.length > 0) { // Compute tags for all pending secrets and fetch logs in batched RPC calls - const logsPerSecret = await fetchLogsForSecrets(pending, aztecNode, anchorBlockNumber, anchorBlockHash); + const logsPerSecret = await fetchLogsForSecrets(pending, aztecNode, anchor); const nextRound = await Promise.all( pending.map(async (pendingSecret, i) => { @@ -185,8 +183,7 @@ function getIndexRangesForSecrets( async function fetchLogsForSecrets( pending: PendingSecret[], aztecNode: AztecNode, - anchorBlockNumber: BlockNumber, - anchorBlockHash: BlockHash, + anchor: LogQueryAnchor, ): Promise { // Determine the index range for each secret const indexesPerSecret = pending.map(({ start, end }) => Array.from({ length: end - start }, (_, i) => start + i)); @@ -200,14 +197,10 @@ async function fetchLogsForSecrets( const allTags = tagsPerSecret.flat(); - // getAllPrivateLogsByTags handles MAX_RPC_LEN chunking internally. Recipient sync builds `PendingTaggedLog` from - // each log's note hashes and first nullifier, so we opt into effects. The `toBlock` cap (anchor block + 1, - // exclusive) tells the node to skip any logs in blocks past the anchor — the same guard previously enforced - // by an in-memory filter on the response. - const allResults = await getAllPrivateLogsByTags(aztecNode, allTags, anchorBlockHash, { - includeEffects: true, - toBlock: BlockNumber(anchorBlockNumber + 1), - }); + // getAllPrivateLogsByTags handles MAX_RPC_LEN chunking internally, and bounds the query at the anchor block so + // logs from later blocks are never returned. Recipient sync builds `PendingTaggedLog` from each log's note hashes + // and first nullifier, so we opt into effects. + const allResults = await getAllPrivateLogsByTags(aztecNode, allTags, anchor, { includeEffects: true }); // Split flat results back per secret using the known lengths const logsPerSecret: LogWithIndex[][] = []; 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 23954713893e..f34a06734cf8 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 @@ -16,7 +16,7 @@ import { computeSiloedTagForIndex, extractTags } from '../testing/tag_query_test import { syncSenderTaggingIndexes } from './sync_sender_tagging_indexes.js'; import { minedReceipt } from './utils/test_utils.js'; -const MOCK_ANCHOR_BLOCK_HASH = BlockHash.random(); +const MOCK_ANCHOR = { hash: BlockHash.random(), number: BlockNumber(100) }; // The finalized tip the tests sync against, and log block numbers on either side of it. const MOCK_FINALIZED_BLOCK_NUMBER = BlockNumber(15); const FINALIZED_LOG_BLOCK = MOCK_FINALIZED_BLOCK_NUMBER - 1; @@ -510,6 +510,6 @@ describe('syncSenderTaggingIndexes', () => { } function sync({ finalizedAt = MOCK_FINALIZED_BLOCK_NUMBER }: { finalizedAt?: BlockNumber } = {}) { - return syncSenderTaggingIndexes(secret, aztecNode, taggingStore, finalizedAt, MOCK_ANCHOR_BLOCK_HASH, 'test'); + return syncSenderTaggingIndexes(secret, aztecNode, taggingStore, finalizedAt, MOCK_ANCHOR, 'test'); } }); 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 5d004c252444..3e4536ac7495 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 @@ -1,10 +1,10 @@ import type { BlockNumber } from '@aztec/foundation/branded-types'; -import type { BlockHash } from '@aztec/stdlib/block'; import type { AztecNode } from '@aztec/stdlib/interfaces/server'; import type { AppTaggingSecret } from '@aztec/stdlib/logs'; import type { SenderTaggingStore } from '../../storage/tagging_store/sender_tagging_store.js'; import { unfinalizedTaggingIndexesWindowEnd } from '../constants.js'; +import type { LogQueryAnchor } from '../get_all_logs_by_tags.js'; import { loadAndStoreNewTaggingIndexes } from './utils/load_and_store_new_tagging_indexes.js'; import { resolvePendingTxs } from './utils/resolve_pending_txs.js'; @@ -25,7 +25,7 @@ export async function syncSenderTaggingIndexes( aztecNode: AztecNode, taggingStore: SenderTaggingStore, finalizedBlockNumber: BlockNumber, - anchorBlockHash: BlockHash, + anchor: LogQueryAnchor, jobId: string, ): Promise { // # Explanation of how syncing works @@ -58,15 +58,7 @@ export async function syncSenderTaggingIndexes( let newFinalizedIndex = undefined; while (true) { - const txsInLogs = await loadAndStoreNewTaggingIndexes( - secret, - start, - end, - aztecNode, - taggingStore, - anchorBlockHash, - jobId, - ); + const txsInLogs = await loadAndStoreNewTaggingIndexes(secret, start, end, aztecNode, taggingStore, anchor, jobId); // Pending txs for this window: prior syncs, txs this PXE itself sent, and what the logs just stored. const pendingTxs = await taggingStore.getPendingTxs(secret, start, end, jobId); 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 b029e10d76b0..ed44b34dd505 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 @@ -1,3 +1,4 @@ +import { BlockNumber } from '@aztec/foundation/branded-types'; import type { Fr } from '@aztec/foundation/curves/bn254'; import { BlockHash } from '@aztec/stdlib/block'; import type { AztecNode } from '@aztec/stdlib/interfaces/server'; @@ -17,7 +18,7 @@ import type { SenderTaggingStore } from '../../../storage/tagging_store/sender_t import { computeSiloedTagForIndex, extractTags } from '../../testing/tag_query_test_utils.js'; import { loadAndStoreNewTaggingIndexes } from './load_and_store_new_tagging_indexes.js'; -const MOCK_ANCHOR_BLOCK_HASH = BlockHash.random(); +const MOCK_ANCHOR = { hash: BlockHash.random(), number: BlockNumber(100) }; describe('loadAndStoreNewTaggingIndexes', () => { let secret: AppTaggingSecret; @@ -43,7 +44,7 @@ describe('loadAndStoreNewTaggingIndexes', () => { return Promise.resolve(tags.map(() => [])); }); - await loadAndStoreNewTaggingIndexes(secret, 0, 10, aztecNode, taggingStore, MOCK_ANCHOR_BLOCK_HASH, 'test'); + await loadAndStoreNewTaggingIndexes(secret, 0, 10, aztecNode, taggingStore, MOCK_ANCHOR, 'test'); expect(taggingStore.mergePendingIndexes).not.toHaveBeenCalled(); }); @@ -58,7 +59,7 @@ describe('loadAndStoreNewTaggingIndexes', () => { return Promise.resolve(tags.map((t: SiloedTag) => (t.equals(tag) ? [makeLog(txHash, tag.value)] : []))); }); - await loadAndStoreNewTaggingIndexes(secret, 0, 10, aztecNode, taggingStore, MOCK_ANCHOR_BLOCK_HASH, 'test'); + await loadAndStoreNewTaggingIndexes(secret, 0, 10, aztecNode, taggingStore, MOCK_ANCHOR, 'test'); expect(taggingStore.mergePendingIndexes).toHaveBeenCalledTimes(1); expect(taggingStore.mergePendingIndexes).toHaveBeenCalledWith( @@ -89,7 +90,7 @@ describe('loadAndStoreNewTaggingIndexes', () => { ); }); - await loadAndStoreNewTaggingIndexes(secret, 0, 10, aztecNode, taggingStore, MOCK_ANCHOR_BLOCK_HASH, 'test'); + await loadAndStoreNewTaggingIndexes(secret, 0, 10, aztecNode, taggingStore, MOCK_ANCHOR, 'test'); expect(taggingStore.mergePendingIndexes).toHaveBeenCalledTimes(1); expect(taggingStore.mergePendingIndexes).toHaveBeenCalledWith( @@ -121,7 +122,7 @@ describe('loadAndStoreNewTaggingIndexes', () => { ); }); - await loadAndStoreNewTaggingIndexes(secret, 0, 10, aztecNode, taggingStore, MOCK_ANCHOR_BLOCK_HASH, 'test'); + await loadAndStoreNewTaggingIndexes(secret, 0, 10, aztecNode, taggingStore, MOCK_ANCHOR, 'test'); expect(taggingStore.mergePendingIndexes).toHaveBeenCalledTimes(2); expect(taggingStore.mergePendingIndexes).toHaveBeenCalledWith( @@ -150,7 +151,7 @@ describe('loadAndStoreNewTaggingIndexes', () => { ); }); - await loadAndStoreNewTaggingIndexes(secret, 0, 10, aztecNode, taggingStore, MOCK_ANCHOR_BLOCK_HASH, 'test'); + await loadAndStoreNewTaggingIndexes(secret, 0, 10, aztecNode, taggingStore, MOCK_ANCHOR, 'test'); expect(taggingStore.mergePendingIndexes).toHaveBeenCalledTimes(2); expect(taggingStore.mergePendingIndexes).toHaveBeenCalledWith( @@ -202,7 +203,7 @@ describe('loadAndStoreNewTaggingIndexes', () => { ); }); - await loadAndStoreNewTaggingIndexes(secret, 0, 10, aztecNode, taggingStore, MOCK_ANCHOR_BLOCK_HASH, 'test'); + await loadAndStoreNewTaggingIndexes(secret, 0, 10, aztecNode, taggingStore, MOCK_ANCHOR, 'test'); expect(taggingStore.mergePendingIndexes).toHaveBeenCalledTimes(3); expect(taggingStore.mergePendingIndexes).toHaveBeenCalledWith( @@ -246,7 +247,7 @@ describe('loadAndStoreNewTaggingIndexes', () => { ); }); - await loadAndStoreNewTaggingIndexes(secret, start, end, aztecNode, taggingStore, MOCK_ANCHOR_BLOCK_HASH, 'test'); + await loadAndStoreNewTaggingIndexes(secret, start, end, aztecNode, taggingStore, MOCK_ANCHOR, 'test'); // Only the log at start should be stored; end is exclusive expect(taggingStore.mergePendingIndexes).toHaveBeenCalledTimes(1); 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 980299b29bec..eb7dcced8f44 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 @@ -1,11 +1,10 @@ import type { BlockNumber } from '@aztec/foundation/branded-types'; -import type { BlockHash } from '@aztec/stdlib/block'; import type { AztecNode } from '@aztec/stdlib/interfaces/server'; import { type AppTaggingSecret, type LogResult, SiloedTag } from '@aztec/stdlib/logs'; import { TxHash } from '@aztec/stdlib/tx'; import type { SenderTaggingStore } from '../../../storage/tagging_store/sender_tagging_store.js'; -import { getAllPrivateLogsByTags } from '../../get_all_logs_by_tags.js'; +import { type LogQueryAnchor, getAllPrivateLogsByTags } from '../../get_all_logs_by_tags.js'; /** * Loads tagging indexes from the Aztec node and stores them in the tagging data provider. Returns the txs the @@ -18,7 +17,7 @@ import { getAllPrivateLogsByTags } from '../../get_all_logs_by_tags.js'; * @param end - The ending index (exclusive) of the window to process. * @param aztecNode - The Aztec node instance to query for logs. * @param taggingStore - The data provider to store pending indexes. - * @param anchorBlockHash - Hash of a block to use as reference block when querying node. + * @param anchor - Block the log query is anchored to. * @param jobId - Job identifier, used to keep writes in-memory until they can be persisted in a data integrity * preserving way. */ @@ -28,7 +27,7 @@ export async function loadAndStoreNewTaggingIndexes( end: number, aztecNode: AztecNode, taggingStore: SenderTaggingStore, - anchorBlockHash: BlockHash, + anchor: LogQueryAnchor, jobId: string, ): Promise> { // We compute the tags for the current window of indexes @@ -36,7 +35,7 @@ export async function loadAndStoreNewTaggingIndexes( Array.from({ length: end - start }, (_, i) => SiloedTag.compute({ extendedSecret, index: start + i })), ); - const allLogs = await getAllPrivateLogsByTags(aztecNode, siloedTagsForWindow, anchorBlockHash); + const allLogs = await getAllPrivateLogsByTags(aztecNode, siloedTagsForWindow, anchor); if (allLogs.length !== siloedTagsForWindow.length) { throw new Error( `Number of log arrays does not match number of tags. ${allLogs.length} !== ${siloedTagsForWindow.length}`, diff --git a/yarn-project/stdlib/src/logs/logs_query.ts b/yarn-project/stdlib/src/logs/logs_query.ts index 2ab4fdd94ac8..d78c698a58e2 100644 --- a/yarn-project/stdlib/src/logs/logs_query.ts +++ b/yarn-project/stdlib/src/logs/logs_query.ts @@ -33,8 +33,8 @@ export type LogsQueryBase = { */ txHash?: TxHash; /** - * Reorg-safety anchor: the latest block hash the caller has synced to. If set and the block is no - * longer present, the call throws. Distinct from `toBlock`, which is a filter, not a safety check. + * Reorg-safety anchor: the latest block hash the caller has synced to. The call throws if that block is no longer + * present. Results are capped at that block, and `toBlock` can only narrow the range further, never past it. */ referenceBlock?: BlockHash; /** When set, each log also carries `noteHashes` and all `nullifiers` for note-nonce discovery. */ diff --git a/yarn-project/wallet-sdk/src/base-wallet/base_wallet.test.ts b/yarn-project/wallet-sdk/src/base-wallet/base_wallet.test.ts index 9451aa85b738..4635184736ed 100644 --- a/yarn-project/wallet-sdk/src/base-wallet/base_wallet.test.ts +++ b/yarn-project/wallet-sdk/src/base-wallet/base_wallet.test.ts @@ -18,7 +18,6 @@ import { NestedProcessReturnValues, OFFCHAIN_MESSAGE_IDENTIFIER, type OffchainEffect, - PendingTxReceipt, PrivateExecutionResult, Tx, TxEffect, @@ -399,7 +398,6 @@ describe('BaseWallet', () => { pxe.getSyncedBlockHeader.mockResolvedValue(BlockHeader.empty()); wallet.mockAccount.createTxExecutionRequest.mockResolvedValue(mock()); pxe.proveTx.mockResolvedValue(provenTx); - node.getTxReceipt.mockResolvedValue(new PendingTxReceipt(TxHash.random(), undefined)); node.sendTx.mockResolvedValue(); const payload = new ExecutionPayload([await makeFunctionCall(FunctionType.PRIVATE, false, 'transfer')], [], []); 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 7d1e942b2c41..ccde76c5e0f0 100644 --- a/yarn-project/wallet-sdk/src/base-wallet/base_wallet.ts +++ b/yarn-project/wallet-sdk/src/base-wallet/base_wallet.ts @@ -2,9 +2,11 @@ import type { Account, NoFrom } from '@aztec/aztec.js/account'; import { NO_FROM } from '@aztec/aztec.js/account'; import type { CallIntent, IntentInnerHash } from '@aztec/aztec.js/authorization'; import { + DefaultWaitOpts, type InteractionWaitOptions, NO_WAIT, type SendReturn, + type WaitOpts, extractOffchainOutput, } from '@aztec/aztec.js/contracts'; import type { FeePaymentMethod } from '@aztec/aztec.js/fee'; @@ -531,9 +533,6 @@ export abstract class BaseWallet implements Wallet { ); const tx = await provenTx.toTx(); const txHash = tx.getTxHash(); - if ((await this.aztecNode.getTxReceipt(txHash)).isMined()) { - throw new Error(`A settled tx with equal hash ${txHash.toString()} exists.`); - } this.log.debug(`Sending transaction ${txHash}`); await this.aztecNode.sendTx(tx).catch(err => { throw this.contextualizeError(err, inspect(tx)); @@ -547,11 +546,13 @@ export abstract class BaseWallet implements Wallet { // Otherwise, wait for the full receipt (default behavior on wait: undefined) const callerWaitOpts = typeof opts.wait === 'object' ? opts.wait : undefined; - const waitOpts = + const waitOpts: WaitOpts | undefined = this.defaultWaitInterval !== undefined && callerWaitOpts?.interval === undefined ? { ...callerWaitOpts, interval: this.defaultWaitInterval } : callerWaitOpts; - const receipt = await waitForTx(this.aztecNode, txHash, waitOpts); + // The tx was just sent, so an immediate first poll cannot find it mined; skip one poll interval up front. + const initialDelay = waitOpts?.initialDelay ?? waitOpts?.interval ?? DefaultWaitOpts.interval; + const receipt = await waitForTx(this.aztecNode, txHash, { ...waitOpts, initialDelay }); // Display debug logs from public execution if present (served in test mode only) if (receipt.isMined() && receipt.debugLogs?.length) {