From bfa901a4fd455acab6dbad242f2b0b6999d973ed Mon Sep 17 00:00:00 2001 From: Nicolas Chamo Date: Thu, 12 Mar 2026 19:22:59 -0300 Subject: [PATCH 1/8] feat: per-contract sync cache invalidation oracle --- .../aztec/src/messages/processing/offchain.nr | 3 +- .../aztec/src/oracle/contract_sync.nr | 12 +++ .../aztec-nr/aztec/src/oracle/mod.nr | 1 + .../aztec-nr/aztec/src/oracle/version.nr | 2 +- .../end-to-end/src/e2e_2_pxes.test.ts | 47 ++++++-- .../src/e2e_offchain_payment.test.ts | 11 -- .../contract_function_simulator.ts | 1 + .../oracle/interfaces.ts | 1 + .../oracle/oracle.ts | 6 ++ .../oracle/private_execution_oracle.ts | 4 - .../oracle/utility_execution.test.ts | 16 +++ .../oracle/utility_execution_oracle.ts | 16 +++ .../contract_sync_service.test.ts | 90 +++++++++++----- .../contract_sync/contract_sync_service.ts | 102 +++++++++++------- yarn-project/pxe/src/oracle_version.ts | 4 +- yarn-project/pxe/src/pxe.ts | 7 +- .../oracle/txe_oracle_top_level_context.ts | 3 + yarn-project/txe/src/txe_session.test.ts | 1 + yarn-project/txe/src/txe_session.ts | 12 ++- 19 files changed, 243 insertions(+), 96 deletions(-) create mode 100644 noir-projects/aztec-nr/aztec/src/oracle/contract_sync.nr diff --git a/noir-projects/aztec-nr/aztec/src/messages/processing/offchain.nr b/noir-projects/aztec-nr/aztec/src/messages/processing/offchain.nr index 7e93d7d896ba..f2baf5274392 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/processing/offchain.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/processing/offchain.nr @@ -5,6 +5,7 @@ use crate::{ encoding::MESSAGE_CIPHERTEXT_LEN, processing::{MessageContext, MessageTxContext, OffchainMessageWithContext, resolve_message_contexts}, }, + oracle::contract_sync::invalidate_contract_sync_cache, protocol::{ address::AztecAddress, constants::MAX_TX_LIFETIME, @@ -132,7 +133,7 @@ pub unconstrained fn receive( i += 1; } - // TODO: Invoke an oracle to invalidate contract sync state cache + invalidate_contract_sync_cache(contract_address); } /// Returns offchain-delivered messages to process during sync. diff --git a/noir-projects/aztec-nr/aztec/src/oracle/contract_sync.nr b/noir-projects/aztec-nr/aztec/src/oracle/contract_sync.nr new file mode 100644 index 000000000000..3d83af959272 --- /dev/null +++ b/noir-projects/aztec-nr/aztec/src/oracle/contract_sync.nr @@ -0,0 +1,12 @@ +use crate::protocol::address::AztecAddress; + +#[oracle(aztec_utl_invalidateContractSyncCache)] +unconstrained fn invalidate_contract_sync_cache_oracle(contract_address: AztecAddress) {} + +/// Forces the PXE to re-sync the given contract on the next query. +/// +/// Call this after writing data (e.g. offchain messages) that the contract's `sync_state` function needs to discover. +/// Without invalidation, the sync cache would skip re-running `sync_state` until the next block. +pub unconstrained fn invalidate_contract_sync_cache(contract_address: AztecAddress) { + invalidate_contract_sync_cache_oracle(contract_address); +} diff --git a/noir-projects/aztec-nr/aztec/src/oracle/mod.nr b/noir-projects/aztec-nr/aztec/src/oracle/mod.nr index d167b1967109..772dcf882ea8 100644 --- a/noir-projects/aztec-nr/aztec/src/oracle/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/oracle/mod.nr @@ -10,6 +10,7 @@ pub mod auth_witness; pub mod block_header; pub mod call_private_function; pub mod capsules; +pub mod contract_sync; pub mod public_call; pub mod tx_phase; pub mod execution; diff --git a/noir-projects/aztec-nr/aztec/src/oracle/version.nr b/noir-projects/aztec-nr/aztec/src/oracle/version.nr index f16367db9d0a..db1f5d94ef02 100644 --- a/noir-projects/aztec-nr/aztec/src/oracle/version.nr +++ b/noir-projects/aztec-nr/aztec/src/oracle/version.nr @@ -4,7 +4,7 @@ /// /// @dev Whenever a contract function or Noir test is run, the `aztec_utl_assertCompatibleOracleVersion` oracle is /// called and if the oracle version is incompatible an error is thrown. -pub global ORACLE_VERSION: Field = 16; +pub global ORACLE_VERSION: Field = 17; /// Asserts that the version of the oracle is compatible with the version expected by the contract. pub fn assert_compatible_oracle_version() { diff --git a/yarn-project/end-to-end/src/e2e_2_pxes.test.ts b/yarn-project/end-to-end/src/e2e_2_pxes.test.ts index fb18c816774d..2d6943e2b197 100644 --- a/yarn-project/end-to-end/src/e2e_2_pxes.test.ts +++ b/yarn-project/end-to-end/src/e2e_2_pxes.test.ts @@ -27,6 +27,17 @@ describe('e2e_2_pxes', () => { let teardownA: () => Promise; let teardownB: () => Promise; + async function setupSecondaryPXE(accountIndex: number, pxeName: string) { + const { wallet, teardown } = await setupPXEAndGetWallet(aztecNode, {}, undefined, pxeName); + const accountManager = await wallet.createSchnorrAccount( + initialFundedAccounts[accountIndex].secret, + initialFundedAccounts[accountIndex].salt, + ); + const deployMethod = await accountManager.getDeployMethod(); + await deployMethod.send({ from: AztecAddress.ZERO }); + return { wallet, address: accountManager.address, teardown }; + } + beforeEach(async () => { ({ aztecNode, @@ -37,17 +48,7 @@ describe('e2e_2_pxes', () => { teardown: teardownA, } = await setup(1, { numberOfInitialFundedAccounts: 3 })); - // Account A is already deployed in setup - - // Deploy accountB via walletB. - ({ wallet: walletB, teardown: teardownB } = await setupPXEAndGetWallet(aztecNode, {}, undefined, 'pxe-1')); - const accountBManager = await walletB.createSchnorrAccount( - initialFundedAccounts[1].secret, - initialFundedAccounts[1].salt, - ); - accountBAddress = accountBManager.address; - const accountBDeployMethod = await accountBManager.getDeployMethod(); - await accountBDeployMethod.send({ from: AztecAddress.ZERO }); + ({ wallet: walletB, address: accountBAddress, teardown: teardownB } = await setupSecondaryPXE(1, 'pxe-b')); await walletA.registerSender(accountBAddress, 'accountB'); await walletB.registerSender(accountAAddress, 'accountA'); @@ -211,4 +212,28 @@ describe('e2e_2_pxes', () => { await expectTokenBalance(walletB, token, accountBAddress, transferAmount2, logger); await expectTokenBalance(walletB, token, sharedAccountAddress, transferAmount1 - transferAmount2, logger); }); + + it('balance updates automatically after sender is registered', async () => { + const initialBalance = 500n; + const transferAmount = 200n; + + const { contract: token, instance } = await deployToken(walletA, accountAAddress, initialBalance, logger); + + // Set up a third PXE (C) that does NOT have sender A registered + const { wallet: walletC, address: accountCAddress, teardown: teardownC } = await setupSecondaryPXE(2, 'pxe-c'); + await walletC.registerContract(instance, TokenContract.artifact); + + // Transfer from A to C + const contractWithWalletA = TokenContract.at(token.address, walletA); + await contractWithWalletA.methods.transfer(accountCAddress, transferAmount).send({ from: accountAAddress }); + + // Balance is 0 because PXE C doesn't know about sender A yet + await expectTokenBalance(walletC, token, accountCAddress, 0n, logger); + + // Register sender A on PXE C -- cache invalidation makes balance visible immediately + await walletC.registerSender(accountAAddress, 'accountA'); + await expectTokenBalance(walletC, token, accountCAddress, transferAmount, logger); + + await teardownC(); + }); }); diff --git a/yarn-project/end-to-end/src/e2e_offchain_payment.test.ts b/yarn-project/end-to-end/src/e2e_offchain_payment.test.ts index ffe2fd7ce271..87114fe8a080 100644 --- a/yarn-project/end-to-end/src/e2e_offchain_payment.test.ts +++ b/yarn-project/end-to-end/src/e2e_offchain_payment.test.ts @@ -104,9 +104,6 @@ describe('e2e_offchain_payment', () => { ]) .simulate({ from: bob }); - // Force an empty block so the PXE re-syncs and discovers the offchain-delivered notes. - await forceEmptyBlock(); - // TODO(F-413): we need to implement scopes on capsules so we can check Alice's balance too here. This is not // possible right now because the offchain inbox is shared for all accounts using this contract in the same PXE, // which is bad. @@ -147,14 +144,6 @@ describe('e2e_offchain_payment', () => { ]) .simulate({ from: bob }); - // TODO: revisit this. The call to offchain_receive is a utility and as such it causes the contract to sync, which, - // in combination with our caching policies, means subsequent utility calls won't trigger a re-sync. - // Given we're hooking the offchain sync process to the general sync process, this means we won't process any new - // offchain messages until at least one block passes. - // A potential escape hatch for this is to remove the check that forbids external invocation of `sync_state`. - // That would let users trigger syncs manually to circumvent caching issues like this. - await forceEmptyBlock(); - // Check that Bob got the payment before a re-org const { result: bobBalance } = await contract.methods.get_balance(bob).simulate({ from: bob }); expect(bobBalance).toBe(paymentAmount); 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 ec64c38eb8f6..cad48e4461fd 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 @@ -341,6 +341,7 @@ export class ContractFunctionSimulator { capsuleStore: this.capsuleStore, privateEventStore: this.privateEventStore, messageContextService: this.messageContextService, + contractSyncService: this.contractSyncService, jobId, scopes, }); diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/interfaces.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/interfaces.ts index 9f1e6c311a90..56f7a9c8e13d 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/interfaces.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/interfaces.ts @@ -143,6 +143,7 @@ export interface IUtilityExecutionOracle { copyCapsule(contractAddress: AztecAddress, srcKey: Fr, dstKey: Fr, numEntries: number): Promise; aes128Decrypt(ciphertext: Buffer, iv: Buffer, symKey: Buffer): Promise; getSharedSecret(address: AztecAddress, ephPk: Point): Promise; + invalidateContractSyncCache(contractAddress: AztecAddress): Promise; } /** diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle.ts index 76b81342ef5a..1cb1b18eb264 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle.ts @@ -653,6 +653,12 @@ export class Oracle { return secret.toFields().map(toACVMField); } + // eslint-disable-next-line camelcase + async aztec_utl_invalidateContractSyncCache([contractAddress]: ACVMField[]): Promise { + await this.handlerAsUtility().invalidateContractSyncCache(AztecAddress.fromField(Fr.fromString(contractAddress))); + return []; + } + // eslint-disable-next-line camelcase async aztec_utl_emitOffchainEffect(data: ACVMField[]) { await this.handlerAsPrivate().emitOffchainEffect(data.map(Fr.fromString)); 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 89ff505566e5..4d3a5b13f4a8 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 @@ -26,7 +26,6 @@ import { } from '@aztec/stdlib/tx'; import type { AccessScopes } from '../../access_scopes.js'; -import type { ContractSyncService } from '../../contract_sync/contract_sync_service.js'; import { NoteService } from '../../notes/note_service.js'; import type { SenderTaggingStore } from '../../storage/tagging_store/sender_tagging_store.js'; import { syncSenderTaggingIndexes } from '../../tagging/index.js'; @@ -49,7 +48,6 @@ export type PrivateExecutionOracleArgs = Omit { capsuleStore, privateEventStore, messageContextService, + contractSyncService, jobId: 'test-job-id', scopes: 'ALL_SCOPES', }); @@ -238,6 +239,21 @@ describe('Utility Execution test suite', () => { }); }); + describe('invalidateContractSyncCache', () => { + it('throws when contract address does not match', async () => { + const otherAddress = await AztecAddress.random(); + expect(() => utilityExecutionOracle.invalidateContractSyncCache(otherAddress)).toThrow( + `Contract ${contractAddress} cannot invalidate sync cache of ${otherAddress}`, + ); + expect(contractSyncService.invalidateContract).not.toHaveBeenCalled(); + }); + + it('invalidates cache for own contract address', async () => { + await utilityExecutionOracle.invalidateContractSyncCache(contractAddress); + expect(contractSyncService.invalidateContract).toHaveBeenCalledWith(contractAddress); + }); + }); + describe('utilityResolveMessageContexts', () => { const requestSlot = Fr.random(); const responseSlot = Fr.random(); 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 e0d9fa7e0952..8d1ddd788b48 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 @@ -23,6 +23,7 @@ import type { BlockHeader, Capsule } from '@aztec/stdlib/tx'; import type { AccessScopes } from '../../access_scopes.js'; import { createContractLogger, logContractMessage } from '../../contract_logging.js'; +import type { ContractSyncService } from '../../contract_sync/contract_sync_service.js'; import { EventService } from '../../events/event_service.js'; import { LogService } from '../../logs/log_service.js'; import { MessageContextService } from '../../messages/message_context_service.js'; @@ -62,6 +63,7 @@ export type UtilityExecutionOracleArgs = { capsuleStore: CapsuleStore; privateEventStore: PrivateEventStore; messageContextService: MessageContextService; + contractSyncService: ContractSyncService; jobId: string; log?: ReturnType; scopes: AccessScopes; @@ -90,6 +92,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra protected readonly capsuleStore: CapsuleStore; protected readonly privateEventStore: PrivateEventStore; protected readonly messageContextService: MessageContextService; + protected readonly contractSyncService: ContractSyncService; protected readonly jobId: string; protected logger: ReturnType; protected readonly scopes: AccessScopes; @@ -109,6 +112,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra this.capsuleStore = args.capsuleStore; this.privateEventStore = args.privateEventStore; this.messageContextService = args.messageContextService; + this.contractSyncService = args.contractSyncService; this.jobId = args.jobId; this.logger = args.log ?? createLogger('simulator:client_view_context'); this.scopes = args.scopes; @@ -641,6 +645,18 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra return this.capsuleStore.copyCapsule(this.contractAddress, srcSlot, dstSlot, numEntries, this.jobId); } + /** + * Clears cached sync state for a contract, forcing re-sync on the next query so that newly stored notes or events + * are discovered. + */ + public invalidateContractSyncCache(contractAddress: AztecAddress): Promise { + if (!contractAddress.equals(this.contractAddress)) { + throw new Error(`Contract ${this.contractAddress} cannot invalidate sync cache of ${contractAddress}`); + } + this.contractSyncService.invalidateContract(contractAddress); + return Promise.resolve(); + } + // TODO(#11849): consider replacing this oracle with a pure Noir implementation of aes decryption. public aes128Decrypt(ciphertext: Buffer, iv: Buffer, symKey: Buffer): Promise { const aes128 = new Aes128(); diff --git a/yarn-project/pxe/src/contract_sync/contract_sync_service.test.ts b/yarn-project/pxe/src/contract_sync/contract_sync_service.test.ts index 2839374f6a01..fe9c6fd5084f 100644 --- a/yarn-project/pxe/src/contract_sync/contract_sync_service.test.ts +++ b/yarn-project/pxe/src/contract_sync/contract_sync_service.test.ts @@ -37,17 +37,19 @@ describe('ContractSyncService', () => { .mockResolvedValue(undefined); contractStore = mock(); - contractStore.getFunctionCall.mockResolvedValue( - FunctionCall.from({ - name: 'sync_state', - to: contractAddress, - selector: FunctionSelector.empty(), - type: FunctionType.UTILITY, - hideMsgSender: false, - isStatic: false, - args: [], - returnTypes: [], - }), + contractStore.getFunctionCall.mockImplementation((_name, _args, address) => + Promise.resolve( + FunctionCall.from({ + name: 'sync_state', + to: address, + selector: FunctionSelector.empty(), + type: FunctionType.UTILITY, + hideMsgSender: false, + isStatic: false, + args: [], + returnTypes: [], + }), + ), ); contractStore.getContractInstance.mockResolvedValue({ currentContractClassId: classId, @@ -123,14 +125,14 @@ describe('ContractSyncService', () => { expectSyncedScopes([scopeA], [scopeB]); }); - it('skips sync for overridden contract in the same job', async () => { - service.setOverriddenContracts(jobId, new Set([contractAddress.toString()])); + it('skips sync for excluded contract in the same job', async () => { + service.setExcludedFromSync(jobId, new Set([contractAddress.toString()])); await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [scopeA]); expectNoSync(); }); - it('does not skip sync for overridden contract in a different job', async () => { - service.setOverriddenContracts('other-job', new Set([contractAddress.toString()])); + it('does not skip sync for excluded contract in a different job', async () => { + service.setExcludedFromSync('other-job', new Set([contractAddress.toString()])); await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [scopeA]); expectSyncedScopes([scopeA]); }); @@ -178,12 +180,12 @@ describe('ContractSyncService', () => { }); describe('commit', () => { - it('clears overrides for the given job', async () => { - service.setOverriddenContracts(jobId, new Set([contractAddress.toString()])); + it('clears exclusions for the given job', async () => { + service.setExcludedFromSync(jobId, new Set([contractAddress.toString()])); await service.commit(jobId); await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [scopeA]); - // When overrides are set, contract sync is skipped. We verify the overrides were cleared by confirming that sync + // When exclusions are set, contract sync is skipped. We verify the exclusions were cleared by confirming that sync // was actually triggered. expectSyncedScopes([scopeA]); }); @@ -206,26 +208,26 @@ describe('ContractSyncService', () => { expectSyncedScopes([scopeA], [scopeA]); }); - it('clears overrides for the given job', async () => { - service.setOverriddenContracts(jobId, new Set([contractAddress.toString()])); + it('clears exclusions for the given job', async () => { + service.setExcludedFromSync(jobId, new Set([contractAddress.toString()])); await service.discardStaged(jobId); await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [scopeA]); - // When overrides are set, contract sync is skipped. We verify the overrides were cleared by confirming that sync + // When exclusions are set, contract sync is skipped. We verify the exclusions were cleared by confirming that sync // was actually triggered. expectSyncedScopes([scopeA]); }); - it('preserves overrides for other jobs', async () => { - service.setOverriddenContracts(jobId, new Set([contractAddress.toString()])); - service.setOverriddenContracts('other-job', new Set([contractAddress.toString()])); + it('preserves exclusions for other jobs', async () => { + service.setExcludedFromSync(jobId, new Set([contractAddress.toString()])); + service.setExcludedFromSync('other-job', new Set([contractAddress.toString()])); await service.discardStaged(jobId); - // jobId override cleared, sync proceeds + // jobId exclusion cleared, sync proceeds await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [scopeA]); expectSyncedScopes([scopeA]); - // other-job override still active, sync skipped + // other-job exclusion still active, sync skipped await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, 'other-job', [ scopeA, ]); @@ -233,6 +235,32 @@ describe('ContractSyncService', () => { }); }); + describe('invalidateContract', () => { + const contract2 = AztecAddress.fromBigInt(300n); + + it('re-syncs a contract after its cache is invalidated', async () => { + await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [scopeA]); + expectSyncedContracts([contractAddress, [scopeA]]); + + service.invalidateContract(contractAddress); + + await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [scopeA]); + expectSyncedContracts([contractAddress, [scopeA]], [contractAddress, [scopeA]]); + }); + + it('invalidating one contract does not re-sync other contracts', async () => { + await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [scopeA]); + await service.ensureContractSynced(contract2, null, utilityExecutor, anchorBlockHeader, jobId, [scopeA]); + expectSyncedContracts([contractAddress, [scopeA]], [contract2, [scopeA]]); + + service.invalidateContract(contractAddress); + + await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [scopeA]); + await service.ensureContractSynced(contract2, null, utilityExecutor, anchorBlockHeader, jobId, [scopeA]); + expectSyncedContracts([contractAddress, [scopeA]], [contract2, [scopeA]], [contractAddress, [scopeA]]); + }); + }); + /** Asserts the utility executor was called exactly with the given sequence of scope arrays. */ const expectSyncedScopes = (...expectedScopes: AccessScopes[]) => { expect(utilityExecutor).toHaveBeenCalledTimes(expectedScopes.length); @@ -242,5 +270,15 @@ describe('ContractSyncService', () => { } }; + /** Asserts the utility executor was called exactly with the given sequence of [contractAddress, scopes] pairs. */ + const expectSyncedContracts = (...expected: [AztecAddress, AccessScopes][]) => { + expect(utilityExecutor).toHaveBeenCalledTimes(expected.length); + for (let i = 0; i < expected.length; i++) { + const [call, actualScopes] = utilityExecutor.mock.calls[i]; + expect(call.to).toEqual(expected[i][0]); + expect(actualScopes).toEqual(expected[i][1]); + } + }; + const expectNoSync = () => expect(utilityExecutor).not.toHaveBeenCalled(); }); diff --git a/yarn-project/pxe/src/contract_sync/contract_sync_service.ts b/yarn-project/pxe/src/contract_sync/contract_sync_service.ts index 1ae202911f6a..3b5f64446a99 100644 --- a/yarn-project/pxe/src/contract_sync/contract_sync_service.ts +++ b/yarn-project/pxe/src/contract_sync/contract_sync_service.ts @@ -20,12 +20,12 @@ export class ContractSyncService implements StagedStore { readonly storeName = 'contract_sync'; // Tracks contracts synced since last wipe. The cache is keyed per individual scope address - // (`contractAddress:scopeAddress`), or `contractAddress:*` for undefined scopes (all accounts). + // (`contractAddress:scopeAddress`), or `contractAddress:*` for all scopes (all accounts). // The value is a promise that resolves when the contract is synced. private syncedContracts: Map> = new Map(); - // Per-job overridden contract addresses - these contracts should not be synced. - private overriddenContracts: Map> = new Map(); + // Per-job excluded contract addresses - these contracts should not be synced. + private excludedFromSync: Map> = new Map(); constructor( private aztecNode: AztecNode, @@ -35,8 +35,8 @@ export class ContractSyncService implements StagedStore { ) {} /** Sets contracts that should be skipped during sync for a specific job. */ - setOverriddenContracts(jobId: string, addresses: Set): void { - this.overriddenContracts.set(jobId, addresses); + setExcludedFromSync(jobId: string, addresses: Set): void { + this.excludedFromSync.set(jobId, addresses); } /** @@ -56,47 +56,35 @@ export class ContractSyncService implements StagedStore { jobId: string, scopes: AccessScopes, ): Promise { - // Skip sync if this contract has an override for this job (overrides are keyed by contract address only) - const overrides = this.overriddenContracts.get(jobId); - if (overrides?.has(contractAddress.toString())) { + if (this.#shouldSkipSync(contractAddress, jobId, scopes)) { return; } - // Skip sync if we already synced for "all scopes", or if we have an empty list of scopes - const allScopesKey = toKey(contractAddress, 'ALL_SCOPES'); - const allScopesExisting = this.syncedContracts.get(allScopesKey); - if (allScopesExisting || (scopes !== 'ALL_SCOPES' && scopes.length == 0)) { - return; - } - - const unsyncedScopes = - scopes === 'ALL_SCOPES' - ? scopes - : scopes.filter(scope => !this.syncedContracts.has(toKey(contractAddress, scope))); - const unsyncedScopesKeys = toKeys(contractAddress, unsyncedScopes); - - if (unsyncedScopesKeys.length > 0) { - // Start sync and store the promise for all unsynced scopes - const promise = this.#doSync( + const unsyncedScopes = this.#getUnsyncedScopes(contractAddress, scopes); + this.#startSyncIfNeeded(contractAddress, unsyncedScopes, () => + this.#syncContract( contractAddress, functionToInvokeAfterSync, utilityExecutor, anchorBlockHeader, jobId, unsyncedScopes, - ).catch(err => { - // There was an error syncing the contract, so we remove it from the cache so that it can be retried. - unsyncedScopesKeys.forEach(key => this.syncedContracts.delete(key)); - throw err; - }); - unsyncedScopesKeys.forEach(key => this.syncedContracts.set(key, promise)); - } + ), + ); - const promises = toKeys(contractAddress, scopes).map(key => this.syncedContracts.get(key)!); - await Promise.all(promises); + await this.#awaitSync(contractAddress, scopes); + } + + /** Clears sync cache entries for a specific contract across all scopes. */ + invalidateContract(contractAddress: AztecAddress): void { + const prefix = `${contractAddress.toString()}:`; + // This scan over all keys should be fine given the cache is bounded by `num_contracts * num_accounts` + [...this.syncedContracts.keys()] + .filter(key => key.startsWith(prefix)) + .forEach(key => this.syncedContracts.delete(key)); } - async #doSync( + async #syncContract( contractAddress: AztecAddress, functionToInvokeAfterSync: FunctionSelector | null, utilityExecutor: (call: FunctionCall, scopes: AccessScopes) => Promise, @@ -129,8 +117,8 @@ export class ContractSyncService implements StagedStore { } commit(jobId: string): Promise { - // Clear overridden contracts for this job - this.overriddenContracts.delete(jobId); + // Clear excluded contracts for this job + this.excludedFromSync.delete(jobId); return Promise.resolve(); } @@ -138,9 +126,49 @@ export class ContractSyncService implements StagedStore { // We clear the synced contracts cache here because, when the job is discarded, any associated database writes from // the sync are also undone. this.syncedContracts.clear(); - this.overriddenContracts.delete(jobId); + this.excludedFromSync.delete(jobId); return Promise.resolve(); } + + /** Filters out scopes that are already cached, returning only those that still need syncing. */ + #getUnsyncedScopes(contractAddress: AztecAddress, scopes: AccessScopes): AccessScopes { + if (scopes === 'ALL_SCOPES') { + return scopes; + } + return scopes.filter(scope => !this.syncedContracts.has(toKey(contractAddress, scope))); + } + + /** Returns true if sync should be skipped for this contract */ + #shouldSkipSync(contractAddress: AztecAddress, jobId: string, scopes: AccessScopes): boolean { + const excluded = this.excludedFromSync.get(jobId); + if (excluded?.has(contractAddress.toString())) { + return true; + } + const hasContractBeenSyncedForAllScopes = this.syncedContracts.has(toKey(contractAddress, 'ALL_SCOPES')); + const isScopeListEmpty = scopes !== 'ALL_SCOPES' && scopes.length === 0; + return hasContractBeenSyncedForAllScopes || isScopeListEmpty; + } + + /** If there are unsynced scopes, starts sync and stores the promise in cache with error cleanup. */ + #startSyncIfNeeded(contractAddress: AztecAddress, unsyncedScopes: AccessScopes, syncFn: () => Promise): void { + const keys = toKeys(contractAddress, unsyncedScopes); + if (keys.length === 0) { + return; + } + const promise = syncFn().catch(err => { + keys.forEach(key => this.syncedContracts.delete(key)); + throw err; + }); + keys.forEach(key => this.syncedContracts.set(key, promise)); + } + + /** Collects all relevant scope promises (including in-flight ones from concurrent calls) and awaits them. */ + async #awaitSync(contractAddress: AztecAddress, scopes: AccessScopes): Promise { + const promises = toKeys(contractAddress, scopes) + .map(key => this.syncedContracts.get(key)) + .filter(p => p !== undefined); + await Promise.all(promises); + } } function toKeys(contract: AztecAddress, scopes: AccessScopes) { diff --git a/yarn-project/pxe/src/oracle_version.ts b/yarn-project/pxe/src/oracle_version.ts index f3069e1bf869..3f98ff0a503c 100644 --- a/yarn-project/pxe/src/oracle_version.ts +++ b/yarn-project/pxe/src/oracle_version.ts @@ -4,9 +4,9 @@ /// /// @dev Whenever a contract function or Noir test is run, the `aztec_utl_assertCompatibleOracleVersion` oracle is called /// and if the oracle version is incompatible an error is thrown. -export const ORACLE_VERSION = 16; +export const ORACLE_VERSION = 17; /// This hash is computed as by hashing the Oracle interface and it is used to detect when the Oracle interface changes, /// which in turn implies that you need to update the ORACLE_VERSION constant in this file and in /// `noir-projects/aztec-nr/aztec/src/oracle/version.nr`. -export const ORACLE_INTERFACE_HASH = '73ccb2a24bc9fe7514108be9ff98d7ca8734bc316fb7c1ec4329d1d32f412a55'; +export const ORACLE_INTERFACE_HASH = '20adf8dc31492bf53df9a5b1bc75cf1f6d2b46e4e90b26aad0bf656a579bd9a7'; diff --git a/yarn-project/pxe/src/pxe.ts b/yarn-project/pxe/src/pxe.ts index 8596f42015d0..37a223af1d1d 100644 --- a/yarn-project/pxe/src/pxe.ts +++ b/yarn-project/pxe/src/pxe.ts @@ -560,6 +560,9 @@ export class PXE { if (wasAdded) { this.log.info(`Added sender:\n ${sender.toString()}`); + // Wipe the entire sync cache: the new sender's tagged logs could contain notes/events for any contract, so + // all contracts must re-sync to discover them. + this.contractSyncService.wipe(); } else { this.log.info(`Sender:\n "${sender.toString()}"\n already registered.`); } @@ -921,9 +924,9 @@ export class PXE { const hasOverriddenContracts = overriddenContracts !== undefined && overriddenContracts.size > 0; const skipKernels = hasOverriddenContracts; - // Set overridden contracts on the sync service so it knows to skip syncing them + // Exclude overridden contracts from sync so the sync service skips them if (hasOverriddenContracts) { - this.contractSyncService.setOverriddenContracts(jobId, overriddenContracts); + this.contractSyncService.setExcludedFromSync(jobId, overriddenContracts); } // Execution of private functions only; no proving, and no kernel logic. diff --git a/yarn-project/txe/src/oracle/txe_oracle_top_level_context.ts b/yarn-project/txe/src/oracle/txe_oracle_top_level_context.ts index 6e5cee8e615f..9b062a46aafd 100644 --- a/yarn-project/txe/src/oracle/txe_oracle_top_level_context.ts +++ b/yarn-project/txe/src/oracle/txe_oracle_top_level_context.ts @@ -17,6 +17,7 @@ import { AddressStore, CapsuleStore, type ContractStore, + type ContractSyncService, NoteStore, ORACLE_VERSION, PrivateEventStore, @@ -111,6 +112,7 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl private version: Fr, private chainId: Fr, private authwits: Map, + private readonly contractSyncService: ContractSyncService, ) { this.logger = createLogger('txe:top_level_context'); this.logger.debug('Entering Top Level Context'); @@ -745,6 +747,7 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl capsuleStore: this.capsuleStore, privateEventStore: this.privateEventStore, messageContextService: this.stateMachine.messageContextService, + contractSyncService: this.contractSyncService, jobId, scopes, }); diff --git a/yarn-project/txe/src/txe_session.test.ts b/yarn-project/txe/src/txe_session.test.ts index 6422a2cd8355..b6506eb20cd3 100644 --- a/yarn-project/txe/src/txe_session.test.ts +++ b/yarn-project/txe/src/txe_session.test.ts @@ -26,6 +26,7 @@ describe('TXESession.processFunction', () => { new Fr(1), // chainId new Fr(1), // version 0n, // nextBlockTimestamp + {} as any, // contractSyncService ); }); diff --git a/yarn-project/txe/src/txe_session.ts b/yarn-project/txe/src/txe_session.ts index 98136e07f342..38e447b73153 100644 --- a/yarn-project/txe/src/txe_session.ts +++ b/yarn-project/txe/src/txe_session.ts @@ -9,6 +9,7 @@ import { AnchorBlockStore, CapsuleStore, ContractStore, + ContractSyncService, JobCoordinator, NoteService, NoteStore, @@ -150,6 +151,7 @@ export class TXESession implements TXESessionStateHandler { private chainId: Fr, private version: Fr, private nextBlockTimestamp: bigint, + private contractSyncService: ContractSyncService, ) {} static async init(contractStore: ContractStore) { @@ -185,6 +187,9 @@ export class TXESession implements TXESessionStateHandler { const initialJobId = jobCoordinator.beginJob(); + const logger = createLogger('txe:session'); + const contractSyncService = new ContractSyncService(stateMachine.node, contractStore, noteStore, logger); + const topLevelOracleHandler = new TXEOracleTopLevelContext( stateMachine, contractStore, @@ -201,11 +206,12 @@ export class TXESession implements TXESessionStateHandler { version, chainId, new Map(), + contractSyncService, ); await topLevelOracleHandler.advanceBlocksBy(1); return new TXESession( - createLogger('txe:session'), + logger, stateMachine, topLevelOracleHandler, contractStore, @@ -223,6 +229,7 @@ export class TXESession implements TXESessionStateHandler { version, chainId, nextBlockTimestamp, + contractSyncService, ); } @@ -309,6 +316,7 @@ export class TXESession implements TXESessionStateHandler { this.version, this.chainId, this.authwits, + this.contractSyncService, ); this.state = { name: 'TOP_LEVEL' }; @@ -439,6 +447,7 @@ export class TXESession implements TXESessionStateHandler { capsuleStore: this.capsuleStore, privateEventStore: this.privateEventStore, messageContextService: this.stateMachine.messageContextService, + contractSyncService: this.contractSyncService, jobId: this.currentJobId, scopes: 'ALL_SCOPES', }); @@ -531,6 +540,7 @@ export class TXESession implements TXESessionStateHandler { capsuleStore: this.capsuleStore, privateEventStore: this.privateEventStore, messageContextService: this.stateMachine.messageContextService, + contractSyncService: this.contractSyncService, jobId: this.currentJobId, scopes, }); From 63f1e95453ea6940b4ced508de325e5263e1a059 Mon Sep 17 00:00:00 2001 From: Nicolas Chamo Date: Thu, 12 Mar 2026 19:44:19 -0300 Subject: [PATCH 2/8] fix(txe): add invalidateContractSyncCache handler to rpc_translator --- yarn-project/txe/src/rpc_translator.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/yarn-project/txe/src/rpc_translator.ts b/yarn-project/txe/src/rpc_translator.ts index bd38bf295e42..1e47edbb87c9 100644 --- a/yarn-project/txe/src/rpc_translator.ts +++ b/yarn-project/txe/src/rpc_translator.ts @@ -915,6 +915,13 @@ export class RPCTranslator { return toForeignCallResult(secret.toFields().map(toSingle)); } + // eslint-disable-next-line camelcase + async aztec_utl_invalidateContractSyncCache(foreignContractAddress: ForeignCallSingle) { + const contractAddress = addressFromSingle(foreignContractAddress); + await this.handlerAsUtility().invalidateContractSyncCache(contractAddress); + return toForeignCallResult([]); + } + // eslint-disable-next-line camelcase aztec_utl_emitOffchainEffect(_foreignData: ForeignCallArray) { throw new Error('Offchain effects are not yet supported in the TestEnvironment'); From 9434ecbb83fe9477af3aa8f039743091d56b31cd Mon Sep 17 00:00:00 2001 From: Nicolas Chamo Date: Sat, 14 Mar 2026 15:28:35 +0000 Subject: [PATCH 3/8] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Nicolás Venturo --- yarn-project/pxe/src/contract_sync/contract_sync_service.ts | 3 ++- yarn-project/txe/src/rpc_translator.ts | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/yarn-project/pxe/src/contract_sync/contract_sync_service.ts b/yarn-project/pxe/src/contract_sync/contract_sync_service.ts index 3b5f64446a99..387f79fd630a 100644 --- a/yarn-project/pxe/src/contract_sync/contract_sync_service.ts +++ b/yarn-project/pxe/src/contract_sync/contract_sync_service.ts @@ -78,7 +78,8 @@ export class ContractSyncService implements StagedStore { /** Clears sync cache entries for a specific contract across all scopes. */ invalidateContract(contractAddress: AztecAddress): void { const prefix = `${contractAddress.toString()}:`; - // This scan over all keys should be fine given the cache is bounded by `num_contracts * num_accounts` + // This scan over all keys should be fine given the cache is bounded by `num_syncedcontracts * + // num_accounts` [...this.syncedContracts.keys()] .filter(key => key.startsWith(prefix)) .forEach(key => this.syncedContracts.delete(key)); diff --git a/yarn-project/txe/src/rpc_translator.ts b/yarn-project/txe/src/rpc_translator.ts index 1e47edbb87c9..40c226a9526d 100644 --- a/yarn-project/txe/src/rpc_translator.ts +++ b/yarn-project/txe/src/rpc_translator.ts @@ -918,7 +918,9 @@ export class RPCTranslator { // eslint-disable-next-line camelcase async aztec_utl_invalidateContractSyncCache(foreignContractAddress: ForeignCallSingle) { const contractAddress = addressFromSingle(foreignContractAddress); + await this.handlerAsUtility().invalidateContractSyncCache(contractAddress); + return toForeignCallResult([]); } From f8245878b27fe627bd5ed79942dba76d7ca46c18 Mon Sep 17 00:00:00 2001 From: Nicolas Chamo Date: Sat, 14 Mar 2026 13:14:15 -0300 Subject: [PATCH 4/8] fix(pxe): address pr feedback --- .../contract_sync/contract_sync_service.ts | 51 ++++++++++--------- yarn-project/pxe/src/pxe.ts | 3 +- 2 files changed, 28 insertions(+), 26 deletions(-) diff --git a/yarn-project/pxe/src/contract_sync/contract_sync_service.ts b/yarn-project/pxe/src/contract_sync/contract_sync_service.ts index 387f79fd630a..1edebcd4f5e8 100644 --- a/yarn-project/pxe/src/contract_sync/contract_sync_service.ts +++ b/yarn-project/pxe/src/contract_sync/contract_sync_service.ts @@ -56,19 +56,18 @@ export class ContractSyncService implements StagedStore { jobId: string, scopes: AccessScopes, ): Promise { - if (this.#shouldSkipSync(contractAddress, jobId, scopes)) { + if (this.#shouldSkipSync(jobId, contractAddress)) { return; } - const unsyncedScopes = this.#getUnsyncedScopes(contractAddress, scopes); - this.#startSyncIfNeeded(contractAddress, unsyncedScopes, () => + this.#startSyncIfNeeded(contractAddress, scopes, scopesToSync => this.#syncContract( contractAddress, functionToInvokeAfterSync, utilityExecutor, anchorBlockHeader, jobId, - unsyncedScopes, + scopesToSync, ), ); @@ -78,7 +77,7 @@ export class ContractSyncService implements StagedStore { /** Clears sync cache entries for a specific contract across all scopes. */ invalidateContract(contractAddress: AztecAddress): void { const prefix = `${contractAddress.toString()}:`; - // This scan over all keys should be fine given the cache is bounded by `num_syncedcontracts * + // This scan over all keys should be fine given the cache is bounded by `num_syncedcontracts * // num_accounts` [...this.syncedContracts.keys()] .filter(key => key.startsWith(prefix)) @@ -130,39 +129,41 @@ export class ContractSyncService implements StagedStore { this.excludedFromSync.delete(jobId); return Promise.resolve(); } - - /** Filters out scopes that are already cached, returning only those that still need syncing. */ - #getUnsyncedScopes(contractAddress: AztecAddress, scopes: AccessScopes): AccessScopes { - if (scopes === 'ALL_SCOPES') { - return scopes; - } - return scopes.filter(scope => !this.syncedContracts.has(toKey(contractAddress, scope))); - } - /** Returns true if sync should be skipped for this contract */ - #shouldSkipSync(contractAddress: AztecAddress, jobId: string, scopes: AccessScopes): boolean { - const excluded = this.excludedFromSync.get(jobId); - if (excluded?.has(contractAddress.toString())) { - return true; - } - const hasContractBeenSyncedForAllScopes = this.syncedContracts.has(toKey(contractAddress, 'ALL_SCOPES')); - const isScopeListEmpty = scopes !== 'ALL_SCOPES' && scopes.length === 0; - return hasContractBeenSyncedForAllScopes || isScopeListEmpty; + #shouldSkipSync(jobId: string, contractAddress: AztecAddress): boolean { + return !!this.excludedFromSync.get(jobId)?.has(contractAddress.toString()); } /** If there are unsynced scopes, starts sync and stores the promise in cache with error cleanup. */ - #startSyncIfNeeded(contractAddress: AztecAddress, unsyncedScopes: AccessScopes, syncFn: () => Promise): void { - const keys = toKeys(contractAddress, unsyncedScopes); + #startSyncIfNeeded( + contractAddress: AztecAddress, + scopes: AccessScopes, + syncFn: (scopesToSync: AccessScopes) => Promise, + ): void { + const scopesToSync = this.#getScopesToSync(contractAddress, scopes); + const keys = toKeys(contractAddress, scopesToSync); if (keys.length === 0) { return; } - const promise = syncFn().catch(err => { + const promise = syncFn(scopesToSync).catch(err => { keys.forEach(key => this.syncedContracts.delete(key)); throw err; }); keys.forEach(key => this.syncedContracts.set(key, promise)); } + /** Filters out scopes that are already cached, returning only those that still need syncing. */ + #getScopesToSync(contractAddress: AztecAddress, scopes: AccessScopes): AccessScopes { + if (this.syncedContracts.has(toKey(contractAddress, 'ALL_SCOPES'))) { + // If we are already syncing all scopes, then return an empty list + return []; + } + if (scopes === 'ALL_SCOPES') { + return 'ALL_SCOPES'; + } + return scopes.filter(scope => !this.syncedContracts.has(toKey(contractAddress, scope))); + } + /** Collects all relevant scope promises (including in-flight ones from concurrent calls) and awaits them. */ async #awaitSync(contractAddress: AztecAddress, scopes: AccessScopes): Promise { const promises = toKeys(contractAddress, scopes) diff --git a/yarn-project/pxe/src/pxe.ts b/yarn-project/pxe/src/pxe.ts index 37a223af1d1d..226ef49b287b 100644 --- a/yarn-project/pxe/src/pxe.ts +++ b/yarn-project/pxe/src/pxe.ts @@ -924,8 +924,9 @@ export class PXE { const hasOverriddenContracts = overriddenContracts !== undefined && overriddenContracts.size > 0; const skipKernels = hasOverriddenContracts; - // Exclude overridden contracts from sync so the sync service skips them if (hasOverriddenContracts) { + // Overridden contracts don't have a sync function, so calling sync on them would fail. + // We exclude them so the sync service skips them entirely. this.contractSyncService.setExcludedFromSync(jobId, overriddenContracts); } From 5eb3471e1612932222bc1a4c59de09d412a911ff Mon Sep 17 00:00:00 2001 From: Nicolas Chamo Date: Tue, 17 Mar 2026 13:34:16 -0300 Subject: [PATCH 5/8] feat(pxe): scope contract sync cache invalidation by recipient --- .../aztec/src/messages/processing/offchain.nr | 6 +- .../aztec/src/oracle/contract_sync.nr | 14 ++- .../end-to-end/src/e2e_2_pxes.test.ts | 25 +++-- .../oracle/interfaces.ts | 2 +- .../oracle/oracle.ts | 12 ++- .../oracle/utility_execution.test.ts | 13 ++- .../oracle/utility_execution_oracle.ts | 8 +- .../contract_sync_service.test.ts | 98 +++++++++++++++++-- .../contract_sync/contract_sync_service.ts | 15 ++- yarn-project/txe/src/rpc_translator.ts | 12 ++- 10 files changed, 165 insertions(+), 40 deletions(-) diff --git a/noir-projects/aztec-nr/aztec/src/messages/processing/offchain.nr b/noir-projects/aztec-nr/aztec/src/messages/processing/offchain.nr index 5345fe04587d..d33e2a8af2fc 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/processing/offchain.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/processing/offchain.nr @@ -105,6 +105,9 @@ pub unconstrained fn receive( messages: BoundedVec, ) { let inbox: CapsuleArray = CapsuleArray::at(contract_address, OFFCHAIN_INBOX_SLOT); + // May contain duplicates if multiple messages target the same recipient. This is harmless since + // cache invalidation on the TS side is idempotent (deleting an already-deleted key is a no-op). + let mut scopes: BoundedVec = BoundedVec::new(); let mut i = 0; let messages_len = messages.len(); while i < messages_len { @@ -122,10 +125,11 @@ pub unconstrained fn receive( anchor_block_timestamp: msg.anchor_block_timestamp, }, ); + scopes.push(msg.recipient); i += 1; } - invalidate_contract_sync_cache(contract_address); + invalidate_contract_sync_cache(contract_address, scopes); } /// Returns offchain-delivered messages to process during sync. diff --git a/noir-projects/aztec-nr/aztec/src/oracle/contract_sync.nr b/noir-projects/aztec-nr/aztec/src/oracle/contract_sync.nr index 3d83af959272..98017107ab0e 100644 --- a/noir-projects/aztec-nr/aztec/src/oracle/contract_sync.nr +++ b/noir-projects/aztec-nr/aztec/src/oracle/contract_sync.nr @@ -1,12 +1,18 @@ use crate::protocol::address::AztecAddress; #[oracle(aztec_utl_invalidateContractSyncCache)] -unconstrained fn invalidate_contract_sync_cache_oracle(contract_address: AztecAddress) {} +unconstrained fn invalidate_contract_sync_cache_oracle( + contract_address: AztecAddress, + scopes: BoundedVec, +) {} -/// Forces the PXE to re-sync the given contract on the next query. +/// Forces the PXE to re-sync the given contract for a set of scopes on the next query. /// /// Call this after writing data (e.g. offchain messages) that the contract's `sync_state` function needs to discover. /// Without invalidation, the sync cache would skip re-running `sync_state` until the next block. -pub unconstrained fn invalidate_contract_sync_cache(contract_address: AztecAddress) { - invalidate_contract_sync_cache_oracle(contract_address); +pub unconstrained fn invalidate_contract_sync_cache( + contract_address: AztecAddress, + scopes: BoundedVec, +) { + invalidate_contract_sync_cache_oracle(contract_address, scopes); } diff --git a/yarn-project/end-to-end/src/e2e_2_pxes.test.ts b/yarn-project/end-to-end/src/e2e_2_pxes.test.ts index 2d6943e2b197..41e9a7e20d11 100644 --- a/yarn-project/end-to-end/src/e2e_2_pxes.test.ts +++ b/yarn-project/end-to-end/src/e2e_2_pxes.test.ts @@ -27,11 +27,16 @@ describe('e2e_2_pxes', () => { let teardownA: () => Promise; let teardownB: () => Promise; - async function setupSecondaryPXE(accountIndex: number, pxeName: string) { - const { wallet, teardown } = await setupPXEAndGetWallet(aztecNode, {}, undefined, pxeName); + async function setupSecondaryPXE( + node: AztecNode, + fundedAccounts: InitialAccountData[], + accountIndex: number, + pxeName: string, + ) { + const { wallet, teardown } = await setupPXEAndGetWallet(node, {}, undefined, pxeName); const accountManager = await wallet.createSchnorrAccount( - initialFundedAccounts[accountIndex].secret, - initialFundedAccounts[accountIndex].salt, + fundedAccounts[accountIndex].secret, + fundedAccounts[accountIndex].salt, ); const deployMethod = await accountManager.getDeployMethod(); await deployMethod.send({ from: AztecAddress.ZERO }); @@ -48,7 +53,11 @@ describe('e2e_2_pxes', () => { teardown: teardownA, } = await setup(1, { numberOfInitialFundedAccounts: 3 })); - ({ wallet: walletB, address: accountBAddress, teardown: teardownB } = await setupSecondaryPXE(1, 'pxe-b')); + ({ + wallet: walletB, + address: accountBAddress, + teardown: teardownB, + } = await setupSecondaryPXE(aztecNode, initialFundedAccounts, 1, 'pxe-b')); await walletA.registerSender(accountBAddress, 'accountB'); await walletB.registerSender(accountAAddress, 'accountA'); @@ -220,7 +229,11 @@ describe('e2e_2_pxes', () => { const { contract: token, instance } = await deployToken(walletA, accountAAddress, initialBalance, logger); // Set up a third PXE (C) that does NOT have sender A registered - const { wallet: walletC, address: accountCAddress, teardown: teardownC } = await setupSecondaryPXE(2, 'pxe-c'); + const { + wallet: walletC, + address: accountCAddress, + teardown: teardownC, + } = await setupSecondaryPXE(aztecNode, initialFundedAccounts, 2, 'pxe-c'); await walletC.registerContract(instance, TokenContract.artifact); // Transfer from A to C diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/interfaces.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/interfaces.ts index 4cfe39887cf4..c9b33c9fc2f1 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/interfaces.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/interfaces.ts @@ -143,7 +143,7 @@ export interface IUtilityExecutionOracle { copyCapsule(contractAddress: AztecAddress, srcKey: Fr, dstKey: Fr, numEntries: number): Promise; aes128Decrypt(ciphertext: Buffer, iv: Buffer, symKey: Buffer): Promise; getSharedSecret(address: AztecAddress, ephPk: Point): Promise; - invalidateContractSyncCache(contractAddress: AztecAddress): Promise; + invalidateContractSyncCache(contractAddress: AztecAddress, scopes: AztecAddress[]): Promise; emitOffchainEffect(data: Fr[]): Promise; } diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle.ts index 8c8f47650149..16becc0d4272 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle.ts @@ -635,8 +635,16 @@ export class Oracle { } // eslint-disable-next-line camelcase - async aztec_utl_invalidateContractSyncCache([contractAddress]: ACVMField[]): Promise { - await this.handlerAsUtility().invalidateContractSyncCache(AztecAddress.fromField(Fr.fromString(contractAddress))); + async aztec_utl_invalidateContractSyncCache( + [contractAddress]: ACVMField[], + scopes: ACVMField[], + [scopeCount]: ACVMField[], + ): Promise { + const scopeAddresses = scopes.slice(0, +scopeCount).map(s => AztecAddress.fromField(Fr.fromString(s))); + await this.handlerAsUtility().invalidateContractSyncCache( + AztecAddress.fromField(Fr.fromString(contractAddress)), + scopeAddresses, + ); return []; } 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 fa7ad7d4df7a..6c0fbad50cdf 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 @@ -249,15 +249,18 @@ describe('Utility Execution test suite', () => { describe('invalidateContractSyncCache', () => { it('throws when contract address does not match', async () => { const otherAddress = await AztecAddress.random(); - expect(() => utilityExecutionOracle.invalidateContractSyncCache(otherAddress)).toThrow( + const scope = await AztecAddress.random(); + expect(() => utilityExecutionOracle.invalidateContractSyncCache(otherAddress, [scope])).toThrow( `Contract ${contractAddress} cannot invalidate sync cache of ${otherAddress}`, ); - expect(contractSyncService.invalidateContract).not.toHaveBeenCalled(); + expect(contractSyncService.invalidateContractForScopes).not.toHaveBeenCalled(); }); - it('invalidates cache for own contract address', async () => { - await utilityExecutionOracle.invalidateContractSyncCache(contractAddress); - expect(contractSyncService.invalidateContract).toHaveBeenCalledWith(contractAddress); + it('invalidates cache for the given scopes', async () => { + const scopeA = await AztecAddress.random(); + const scopeB = await AztecAddress.random(); + await utilityExecutionOracle.invalidateContractSyncCache(contractAddress, [scopeA, scopeB]); + expect(contractSyncService.invalidateContractForScopes).toHaveBeenCalledWith(contractAddress, [scopeA, scopeB]); }); }); 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 a7e6bd3b41f3..f0c3e78ee7bc 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 @@ -656,14 +656,14 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra } /** - * Clears cached sync state for a contract, forcing re-sync on the next query so that newly stored notes or events - * are discovered. + * Clears cached sync state for a contract for a set of scopes, forcing re-sync on the next query so that newly + * stored notes or events are discovered. */ - public invalidateContractSyncCache(contractAddress: AztecAddress): Promise { + public invalidateContractSyncCache(contractAddress: AztecAddress, scopes: AztecAddress[]): Promise { if (!contractAddress.equals(this.contractAddress)) { throw new Error(`Contract ${this.contractAddress} cannot invalidate sync cache of ${contractAddress}`); } - this.contractSyncService.invalidateContract(contractAddress); + this.contractSyncService.invalidateContractForScopes(contractAddress, scopes); return Promise.resolve(); } diff --git a/yarn-project/pxe/src/contract_sync/contract_sync_service.test.ts b/yarn-project/pxe/src/contract_sync/contract_sync_service.test.ts index fe9c6fd5084f..9e2d6b5f0eba 100644 --- a/yarn-project/pxe/src/contract_sync/contract_sync_service.test.ts +++ b/yarn-project/pxe/src/contract_sync/contract_sync_service.test.ts @@ -235,25 +235,109 @@ describe('ContractSyncService', () => { }); }); - describe('invalidateContract', () => { + describe('invalidateContractForScopes', () => { const contract2 = AztecAddress.fromBigInt(300n); - it('re-syncs a contract after its cache is invalidated', async () => { + it('only invalidates the targeted scope', async () => { + await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [ + scopeA, + scopeB, + ]); + expectSyncedScopes([scopeA, scopeB]); + + service.invalidateContractForScopes(contractAddress, [scopeA]); + + await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [ + scopeA, + scopeB, + ]); + // Only scopeA should be re-synced, scopeB is still cached. + expectSyncedScopes([scopeA, scopeB], [scopeA]); + }); + + it('invalidates multiple scopes at once', async () => { + await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [ + scopeA, + scopeB, + ]); + expectSyncedScopes([scopeA, scopeB]); + + service.invalidateContractForScopes(contractAddress, [scopeA, scopeB]); + + await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [ + scopeA, + scopeB, + ]); + // Both scopes should be re-synced. + expectSyncedScopes([scopeA, scopeB], [scopeA, scopeB]); + }); + + it('also invalidates the ALL_SCOPES entry', async () => { + // Sync ALL_SCOPES -- covers every account. + await service.ensureContractSynced( + contractAddress, + null, + utilityExecutor, + anchorBlockHeader, + jobId, + 'ALL_SCOPES', + ); + expectSyncedScopes('ALL_SCOPES'); + + // Syncing scopeA is a no-op because ALL_SCOPES already covers it. await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [scopeA]); - expectSyncedContracts([contractAddress, [scopeA]]); + expectSyncedScopes('ALL_SCOPES'); - service.invalidateContract(contractAddress); + // Invalidate scopeA -- this should also clear the ALL_SCOPES entry. + service.invalidateContractForScopes(contractAddress, [scopeA]); + // Now syncing scopeA triggers a re-sync. await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [scopeA]); - expectSyncedContracts([contractAddress, [scopeA]], [contractAddress, [scopeA]]); + expectSyncedScopes('ALL_SCOPES', [scopeA]); + + // And syncing ALL_SCOPES also triggers a re-sync since it was invalidated too. + await service.ensureContractSynced( + contractAddress, + null, + utilityExecutor, + anchorBlockHeader, + jobId, + 'ALL_SCOPES', + ); + expectSyncedScopes('ALL_SCOPES', [scopeA], 'ALL_SCOPES'); + }); + + it('empty scopes is a no-op', async () => { + await service.ensureContractSynced( + contractAddress, + null, + utilityExecutor, + anchorBlockHeader, + jobId, + 'ALL_SCOPES', + ); + expectSyncedScopes('ALL_SCOPES'); + + service.invalidateContractForScopes(contractAddress, []); + + // ALL_SCOPES should still be cached since no scopes were invalidated. + await service.ensureContractSynced( + contractAddress, + null, + utilityExecutor, + anchorBlockHeader, + jobId, + 'ALL_SCOPES', + ); + expectSyncedScopes('ALL_SCOPES'); }); - it('invalidating one contract does not re-sync other contracts', async () => { + it('does not affect other contracts', async () => { await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [scopeA]); await service.ensureContractSynced(contract2, null, utilityExecutor, anchorBlockHeader, jobId, [scopeA]); expectSyncedContracts([contractAddress, [scopeA]], [contract2, [scopeA]]); - service.invalidateContract(contractAddress); + service.invalidateContractForScopes(contractAddress, [scopeA]); await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [scopeA]); await service.ensureContractSynced(contract2, null, utilityExecutor, anchorBlockHeader, jobId, [scopeA]); diff --git a/yarn-project/pxe/src/contract_sync/contract_sync_service.ts b/yarn-project/pxe/src/contract_sync/contract_sync_service.ts index 1edebcd4f5e8..8726efada832 100644 --- a/yarn-project/pxe/src/contract_sync/contract_sync_service.ts +++ b/yarn-project/pxe/src/contract_sync/contract_sync_service.ts @@ -74,14 +74,13 @@ export class ContractSyncService implements StagedStore { await this.#awaitSync(contractAddress, scopes); } - /** Clears sync cache entries for a specific contract across all scopes. */ - invalidateContract(contractAddress: AztecAddress): void { - const prefix = `${contractAddress.toString()}:`; - // This scan over all keys should be fine given the cache is bounded by `num_syncedcontracts * - // num_accounts` - [...this.syncedContracts.keys()] - .filter(key => key.startsWith(prefix)) - .forEach(key => this.syncedContracts.delete(key)); + /** Clears sync cache entries for the given scopes of a contract. Also clears the ALL_SCOPES entry. */ + invalidateContractForScopes(contractAddress: AztecAddress, scopes: AztecAddress[]): void { + if (scopes.length === 0) { + return; + } + scopes.forEach(scope => this.syncedContracts.delete(toKey(contractAddress, scope))); + this.syncedContracts.delete(toKey(contractAddress, 'ALL_SCOPES')); } async #syncContract( diff --git a/yarn-project/txe/src/rpc_translator.ts b/yarn-project/txe/src/rpc_translator.ts index 4cbf79382fa2..0550d75f6390 100644 --- a/yarn-project/txe/src/rpc_translator.ts +++ b/yarn-project/txe/src/rpc_translator.ts @@ -917,10 +917,18 @@ export class RPCTranslator { } // eslint-disable-next-line camelcase - async aztec_utl_invalidateContractSyncCache(foreignContractAddress: ForeignCallSingle) { + async aztec_utl_invalidateContractSyncCache( + foreignContractAddress: ForeignCallSingle, + foreignScopes: ForeignCallArray, + foreignScopeCount: ForeignCallSingle, + ) { const contractAddress = addressFromSingle(foreignContractAddress); + const count = fromSingle(foreignScopeCount).toNumber(); + const scopes = fromArray(foreignScopes) + .slice(0, count) + .map(f => new AztecAddress(f)); - await this.handlerAsUtility().invalidateContractSyncCache(contractAddress); + await this.handlerAsUtility().invalidateContractSyncCache(contractAddress, scopes); return toForeignCallResult([]); } From 8ee9ac570509aa63bb5337fa44c349eb58f4cba0 Mon Sep 17 00:00:00 2001 From: Nicolas Chamo Date: Tue, 17 Mar 2026 13:47:44 -0300 Subject: [PATCH 6/8] chore(pxe): update oracle interface hash --- yarn-project/pxe/src/oracle_version.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/yarn-project/pxe/src/oracle_version.ts b/yarn-project/pxe/src/oracle_version.ts index 3f98ff0a503c..9f8e560b0b97 100644 --- a/yarn-project/pxe/src/oracle_version.ts +++ b/yarn-project/pxe/src/oracle_version.ts @@ -9,4 +9,4 @@ export const ORACLE_VERSION = 17; /// This hash is computed as by hashing the Oracle interface and it is used to detect when the Oracle interface changes, /// which in turn implies that you need to update the ORACLE_VERSION constant in this file and in /// `noir-projects/aztec-nr/aztec/src/oracle/version.nr`. -export const ORACLE_INTERFACE_HASH = '20adf8dc31492bf53df9a5b1bc75cf1f6d2b46e4e90b26aad0bf656a579bd9a7'; +export const ORACLE_INTERFACE_HASH = 'da41b71824ad5017c26293450190de183ff404f6ef385e0218b3a942dec9f6b8'; From 13c5ecb42083bc3080c97730578c019b324271ca Mon Sep 17 00:00:00 2001 From: Nicolas Chamo Date: Tue, 17 Mar 2026 15:41:22 -0300 Subject: [PATCH 7/8] fix(pxe): make invalidateContractSyncCache synchronous --- .../src/contract_function_simulator/oracle/interfaces.ts | 2 +- .../pxe/src/contract_function_simulator/oracle/oracle.ts | 6 +++--- .../oracle/utility_execution_oracle.ts | 3 +-- yarn-project/txe/src/rpc_translator.ts | 6 +++--- 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/interfaces.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/interfaces.ts index c9b33c9fc2f1..0af35e567caa 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/interfaces.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/interfaces.ts @@ -143,7 +143,7 @@ export interface IUtilityExecutionOracle { copyCapsule(contractAddress: AztecAddress, srcKey: Fr, dstKey: Fr, numEntries: number): Promise; aes128Decrypt(ciphertext: Buffer, iv: Buffer, symKey: Buffer): Promise; getSharedSecret(address: AztecAddress, ephPk: Point): Promise; - invalidateContractSyncCache(contractAddress: AztecAddress, scopes: AztecAddress[]): Promise; + invalidateContractSyncCache(contractAddress: AztecAddress, scopes: AztecAddress[]): void; emitOffchainEffect(data: Fr[]): Promise; } diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle.ts index 16becc0d4272..ca07ab43ba30 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle.ts @@ -635,17 +635,17 @@ export class Oracle { } // eslint-disable-next-line camelcase - async aztec_utl_invalidateContractSyncCache( + aztec_utl_invalidateContractSyncCache( [contractAddress]: ACVMField[], scopes: ACVMField[], [scopeCount]: ACVMField[], ): Promise { const scopeAddresses = scopes.slice(0, +scopeCount).map(s => AztecAddress.fromField(Fr.fromString(s))); - await this.handlerAsUtility().invalidateContractSyncCache( + this.handlerAsUtility().invalidateContractSyncCache( AztecAddress.fromField(Fr.fromString(contractAddress)), scopeAddresses, ); - return []; + return Promise.resolve([]); } // eslint-disable-next-line camelcase 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 f0c3e78ee7bc..12422b44540e 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 @@ -659,12 +659,11 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra * Clears cached sync state for a contract for a set of scopes, forcing re-sync on the next query so that newly * stored notes or events are discovered. */ - public invalidateContractSyncCache(contractAddress: AztecAddress, scopes: AztecAddress[]): Promise { + public invalidateContractSyncCache(contractAddress: AztecAddress, scopes: AztecAddress[]): void { if (!contractAddress.equals(this.contractAddress)) { throw new Error(`Contract ${this.contractAddress} cannot invalidate sync cache of ${contractAddress}`); } this.contractSyncService.invalidateContractForScopes(contractAddress, scopes); - return Promise.resolve(); } // TODO(#11849): consider replacing this oracle with a pure Noir implementation of aes decryption. diff --git a/yarn-project/txe/src/rpc_translator.ts b/yarn-project/txe/src/rpc_translator.ts index 0550d75f6390..b50a88d7cd01 100644 --- a/yarn-project/txe/src/rpc_translator.ts +++ b/yarn-project/txe/src/rpc_translator.ts @@ -917,7 +917,7 @@ export class RPCTranslator { } // eslint-disable-next-line camelcase - async aztec_utl_invalidateContractSyncCache( + aztec_utl_invalidateContractSyncCache( foreignContractAddress: ForeignCallSingle, foreignScopes: ForeignCallArray, foreignScopeCount: ForeignCallSingle, @@ -928,9 +928,9 @@ export class RPCTranslator { .slice(0, count) .map(f => new AztecAddress(f)); - await this.handlerAsUtility().invalidateContractSyncCache(contractAddress, scopes); + this.handlerAsUtility().invalidateContractSyncCache(contractAddress, scopes); - return toForeignCallResult([]); + return Promise.resolve(toForeignCallResult([])); } // eslint-disable-next-line camelcase From f878c1aacf181308a54b433c59c24a6b7b270cda Mon Sep 17 00:00:00 2001 From: Nicolas Chamo Date: Tue, 17 Mar 2026 16:13:25 -0300 Subject: [PATCH 8/8] fix(pxe): remove await on synchronous invalidateContractSyncCache in test --- .../oracle/utility_execution.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 6c0fbad50cdf..bbfee4e337d1 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 @@ -259,7 +259,7 @@ describe('Utility Execution test suite', () => { it('invalidates cache for the given scopes', async () => { const scopeA = await AztecAddress.random(); const scopeB = await AztecAddress.random(); - await utilityExecutionOracle.invalidateContractSyncCache(contractAddress, [scopeA, scopeB]); + utilityExecutionOracle.invalidateContractSyncCache(contractAddress, [scopeA, scopeB]); expect(contractSyncService.invalidateContractForScopes).toHaveBeenCalledWith(contractAddress, [scopeA, scopeB]); }); });