From 7a77232ed7e3dd0783482e7f9230d3a0f866775c Mon Sep 17 00:00:00 2001 From: Martin Verzilli Date: Fri, 10 Jul 2026 09:33:02 +0200 Subject: [PATCH 1/4] feat: getTxEffects oracle (#24636) Adds a new `getTxEffects` oracle to fetch tx effects in batch. --- .../aztec/src/oracle/message_processing.nr | 8 +++ .../aztec-nr/aztec/src/oracle/version.nr | 2 +- .../oracle/oracle_registry.ts | 5 ++ .../oracle/utility_execution.test.ts | 53 ++++++++++++++++++- .../oracle/utility_execution_oracle.ts | 51 +++++++++++++----- yarn-project/pxe/src/oracle_version.ts | 4 +- yarn-project/txe/src/rpc_translator.ts | 9 ++++ 7 files changed, 114 insertions(+), 18 deletions(-) diff --git a/noir-projects/aztec-nr/aztec/src/oracle/message_processing.nr b/noir-projects/aztec-nr/aztec/src/oracle/message_processing.nr index f6182d59f83d..fc7722521040 100644 --- a/noir-projects/aztec-nr/aztec/src/oracle/message_processing.nr +++ b/noir-projects/aztec-nr/aztec/src/oracle/message_processing.nr @@ -66,3 +66,11 @@ pub unconstrained fn get_tx_effect(tx_hash: Field) -> Option { #[oracle(aztec_utl_getTxEffect)] unconstrained fn get_tx_effect_oracle(tx_hash: Field) -> Option {} + +/// Fetches all effects of settled transactions by hash, preserving request order. +pub unconstrained fn get_tx_effects(tx_hashes: EphemeralArray) -> EphemeralArray> { + get_tx_effects_oracle(tx_hashes) +} + +#[oracle(aztec_utl_getTxEffects)] +unconstrained fn get_tx_effects_oracle(tx_hashes: EphemeralArray) -> EphemeralArray> {} diff --git a/noir-projects/aztec-nr/aztec/src/oracle/version.nr b/noir-projects/aztec-nr/aztec/src/oracle/version.nr index cb77267bd8c5..6137d3d641d7 100644 --- a/noir-projects/aztec-nr/aztec/src/oracle/version.nr +++ b/noir-projects/aztec-nr/aztec/src/oracle/version.nr @@ -11,7 +11,7 @@ /// immediately if AZTEC_NR_MINOR > PXE_MINOR because if a contract is updated to use a newer Aztec.nr dependency /// without actually using any of the new oracles then there is no reason to throw. pub global ORACLE_VERSION_MAJOR: Field = 30; -pub global ORACLE_VERSION_MINOR: Field = 7; +pub global ORACLE_VERSION_MINOR: Field = 8; /// Asserts that the version of the oracle is compatible with the version expected by the contract. pub fn assert_compatible_oracle_version() { diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_registry.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_registry.ts index 18bf3305e570..09c53de0730b 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_registry.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_registry.ts @@ -273,6 +273,11 @@ export const ORACLE_REGISTRY = { returnType: OPTION(TX_EFFECT), }), + aztec_utl_getTxEffects: makeEntry({ + params: [{ name: 'txHashes', type: EPHEMERAL_ARRAY(TX_HASH) }], + returnType: EPHEMERAL_ARRAY(OPTION(TX_EFFECT)), + }), + aztec_utl_setCapsule: makeEntry({ params: [ { name: 'contractAddress', type: AZTEC_ADDRESS }, diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution.test.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution.test.ts index 3f7e6838ef02..995de85f15f1 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution.test.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution.test.ts @@ -34,6 +34,7 @@ import { BlockHeader, CallContext, Capsule, + DroppedTxReceipt, GlobalVariables, MinedTxReceipt, TxEffect, @@ -670,14 +671,19 @@ describe('Utility Execution test suite', () => { }); describe('node read cache', () => { - const makeMinedReceipt = (txHash: TxHash, txEffect = TxEffect.empty()) => + const makeTxEffect = (txHash: TxHash) => TxEffect.from({ ...TxEffect.empty(), txHash }); + const makeMinedReceipt = ( + txHash: TxHash, + txEffect = makeTxEffect(txHash), + blockNumber = BlockNumber(syncedBlockNumber), + ) => new MinedTxReceipt( txHash, TxStatus.FINALIZED, TxExecutionResult.SUCCESS, 0n, BlockHash.random(), - BlockNumber(syncedBlockNumber), + blockNumber, SlotNumber(1), 0, EpochNumber(1), @@ -727,6 +733,49 @@ describe('Utility Execution test suite', () => { expect(aztecNode.getTxReceipt).toHaveBeenCalledTimes(2); }); + it('returns aligned tx-effect options for tx hash batches', async () => { + const service = new EphemeralArrayService(); + const oracle = makeOracle({ scopes: [scope] }); + const presentTxHash = TxHash.random(); + const pendingTxHash = TxHash.random(); + const futureTxHash = TxHash.random(); + + aztecNode.getTxReceipt.mockImplementation(txHash => { + if (txHash.equals(presentTxHash)) { + return Promise.resolve(makeMinedReceipt(txHash)); + } + if (txHash.equals(futureTxHash)) { + return Promise.resolve(makeMinedReceipt(txHash, makeTxEffect(txHash), BlockNumber(syncedBlockNumber + 1))); + } + return Promise.resolve(new DroppedTxReceipt(txHash)); + }); + + const result = await oracle.getTxEffects( + EphemeralArray.fromValues(service, [presentTxHash, pendingTxHash, futureTxHash, presentTxHash]), + ); + const options = result.readAll(service); + + expect(options.map(option => option.isSome())).toEqual([true, false, false, true]); + expect(options[0].value?.txHash).toEqual(presentTxHash); + expect(options[3].value?.txHash).toEqual(presentTxHash); + expect(aztecNode.getTxReceipt).toHaveBeenCalledTimes(3); + }); + + it('does not share batched tx-effect reads across utility executions', async () => { + const service = new EphemeralArrayService(); + const txHash = TxHash.random(); + aztecNode.getTxReceipt.mockResolvedValue(makeMinedReceipt(txHash)); + + const firstOracle = makeOracle({ scopes: [scope] }); + const secondOracle = makeOracle({ scopes: [scope] }); + + await firstOracle.getTxEffects(EphemeralArray.fromValues(service, [txHash, txHash])); + expect(aztecNode.getTxReceipt).toHaveBeenCalledTimes(1); + + await secondOracle.getTxEffects(EphemeralArray.fromValues(service, [txHash])); + expect(aztecNode.getTxReceipt).toHaveBeenCalledTimes(2); + }); + it('reuses archive witness reads within a utility execution', async () => { const oracle = makeOracle({ scopes: [scope] }); const referenceBlockHash = await anchorBlockHeader.hash(); diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution_oracle.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution_oracle.ts index 983df2003f32..125650e48871 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution_oracle.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution_oracle.ts @@ -35,6 +35,7 @@ import { type Capsule, type IndexedTxEffect, type OffchainEffect, + type TxEffect, type TxHash, } from '@aztec/stdlib/tx'; @@ -683,21 +684,25 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra throw new Error('Invalid tx hash passed into aztec_utl_getTxEffect oracle handler'); } - const receipt = await this.aztecNodeReadCache.getTxReceiptWithEffect(txHash); - if (!receipt.isMined() || !receipt.txEffect || receipt.blockNumber > this.anchorBlockHeader.getBlockNumber()) { - return Option.none(); + return await this.#getTxEffectOption(txHash); + } + + /** Fetches transaction effects for all hashes, preserving request order. */ + public async getTxEffects(txHashes: EphemeralArray): Promise>> { + const hashes = txHashes.readAll(this.ephemeralArrayService); + const invalidHash = hashes.find(txHash => txHash.hash.isZero()); + if (invalidHash) { + throw new Error('Invalid tx hash passed into aztec_utl_getTxEffects oracle handler'); } - const txEffect = receipt.txEffect; - return Option.some({ - ...txEffect, - publicLogs: FlatPublicLogs.fromLogs(txEffect.publicLogs), - contractClassLogs: txEffect.contractClassLogs.map(log => ({ - contractAddress: log.contractAddress, - fields: log.fields.toFields(), - emittedLength: log.emittedLength, - })), - }); + const uniqueTxHashes = uniqueBy(hashes, h => h.toString()); + const options = await Promise.all(uniqueTxHashes.map(txHash => this.#getTxEffectOption(txHash))); + const optionsByHash = new Map(uniqueTxHashes.map((txHash, i) => [txHash.toString(), options[i]])); + + return EphemeralArray.fromValues( + this.ephemeralArrayService, + hashes.map(txHash => optionsByHash.get(txHash.toString())!), + ); } public setCapsule(contractAddress: AztecAddress, slot: Fr, capsule: Fr[], scope: AztecAddress): void { @@ -1119,6 +1124,26 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra ); } + async #getTxEffectOption(txHash: TxHash): Promise> { + const receipt = await this.aztecNodeReadCache.getTxReceiptWithEffect(txHash); + if (!receipt.isMined() || !receipt.txEffect || receipt.blockNumber > this.anchorBlockHeader.getBlockNumber()) { + return Option.none(); + } + return Option.some(this.#toTxEffectData(receipt.txEffect)); + } + + #toTxEffectData(txEffect: TxEffect): TxEffectData { + return { + ...txEffect, + publicLogs: FlatPublicLogs.fromLogs(txEffect.publicLogs), + contractClassLogs: txEffect.contractClassLogs.map(log => ({ + contractAddress: log.contractAddress, + fields: log.fields.toFields(), + emittedLength: log.emittedLength, + })), + }; + } + /** Runs a query concurrently with a validation that the block hash is not ahead of the anchor block. */ async #queryWithBlockHashNotAfterAnchor(blockHash: BlockHash, query: () => Promise): Promise { // Most contracts query state at the "current" block, which is the anchor. Skip the validation when we can. diff --git a/yarn-project/pxe/src/oracle_version.ts b/yarn-project/pxe/src/oracle_version.ts index 688b384f4d14..92cf5a9d3aa1 100644 --- a/yarn-project/pxe/src/oracle_version.ts +++ b/yarn-project/pxe/src/oracle_version.ts @@ -11,7 +11,7 @@ /// if AZTEC_NR_MINOR > PXE_MINOR because if a contract is updated to use a newer Aztec.nr dependency without actually /// using any of the new oracles then there is no reason to throw. export const ORACLE_VERSION_MAJOR = 30; -export const ORACLE_VERSION_MINOR = 7; +export const ORACLE_VERSION_MINOR = 8; /// This hash is computed from the `ORACLE_REGISTRY` declaration (each oracle's name, ordered parameter names and /// types, and return type) and is used to detect when the oracle interface changes. When it does, you need to either: @@ -19,4 +19,4 @@ export const ORACLE_VERSION_MINOR = 7; /// - increment only `ORACLE_VERSION_MINOR` if the change is additive (a new oracle was added). /// /// These constants must be kept in sync between this file and `noir-projects/aztec-nr/aztec/src/oracle/version.nr`. -export const ORACLE_INTERFACE_HASH = '416b0e7d3e6dca5803ebe2a65ea93f1e79759dc39ef8ec4c52eeba5e2b0f3fca'; +export const ORACLE_INTERFACE_HASH = 'b302a8e65d37043c0a0dc08a39895603122dce334843f0442462edb3f56e32e2'; diff --git a/yarn-project/txe/src/rpc_translator.ts b/yarn-project/txe/src/rpc_translator.ts index 4190dc9c65d4..33c70f4c09f7 100644 --- a/yarn-project/txe/src/rpc_translator.ts +++ b/yarn-project/txe/src/rpc_translator.ts @@ -633,6 +633,15 @@ export class RPCTranslator { }); } + // eslint-disable-next-line camelcase + aztec_utl_getTxEffects(...inputs: ForeignCallArgs) { + return callTxeHandler({ + oracle: 'aztec_utl_getTxEffects', + inputs, + handler: ([txHashes]) => this.handlerAsUtility().getTxEffects(txHashes), + }); + } + // eslint-disable-next-line camelcase aztec_utl_setCapsule(...inputs: ForeignCallArgs) { return callTxeHandler({ From 0bb062854965b50db9e0075d09ddb1dd807a40d4 Mon Sep 17 00:00:00 2001 From: Martin Verzilli Date: Fri, 10 Jul 2026 18:43:59 +0200 Subject: [PATCH 2/4] feat: preserve stores on schema version or rollup address change (#24631) Changes store management so that changes in rollup addresses and schema versions cause different stores to be used. Previously we only supported one store to be present at a time, which meant any version or rollup change wiped pre-existing stores (note this includes network changes). An underlying design decision in this PR is to strip the kv-store package from responsibility over location on disk, knowledge about rollup addresses, etc, at least as as regards PXE and wallet storage. Other users of LMDB-v2 should not be impacted by this change. In consonance, IndexedDB and SQLite backends drop their `createStore` functions, which shoehorned wallet and PXE store creation to an homogeneous interface that made it hard to let them independently evolve. Since we're changing this, I decided to also include the chain id as a component of the store id, in addition to the already present schema version and rollup address. It's not clear that we'll ever work on a testnet or a different L1, but doing so is trivial and removes the need to deal with this in the future. Closes F-809 --- .../docs/resources/migration_notes.md | 15 +++- .../navbar/components/NetworkSelector.tsx | 12 ++-- .../src/deprecated/indexeddb/index.ts | 27 +------ .../kv-store/src/sqlite-opfs/index.ts | 21 +----- .../kv-store/src/sqlite-opfs/manage.ts | 36 ++++++++++ .../src/sqlite-opfs/store_management.test.ts | 56 +++++++++++++++ yarn-project/kv-store/tsconfig.json | 3 +- .../src/entrypoints/client/bundle/index.ts | 1 + .../src/entrypoints/client/bundle/utils.ts | 18 ++++- .../pxe/src/entrypoints/client/lazy/index.ts | 1 + .../pxe/src/entrypoints/client/lazy/utils.ts | 18 ++++- .../pxe/src/entrypoints/client/store.ts | 38 ++++++++++ .../pxe/src/entrypoints/server/index.ts | 1 + .../pxe/src/entrypoints/server/store.test.ts | 67 +++++++++++++++++ .../pxe/src/entrypoints/server/store.ts | 47 ++++++++++++ .../pxe/src/entrypoints/server/utils.ts | 28 ++++++-- .../pxe_db_compatibility.test.ts | 69 ++++++++++-------- .../pxe/src/storage/store_identity.test.ts | 68 ++++++++++++++++++ .../pxe/src/storage/store_identity.ts | 72 +++++++++++++++++++ .../src/embedded/entrypoints/browser.ts | 38 +++++----- .../wallets/src/embedded/entrypoints/node.ts | 51 ++++++------- .../wallets/src/embedded/wallet_db.ts | 3 + 22 files changed, 551 insertions(+), 139 deletions(-) create mode 100644 yarn-project/kv-store/src/sqlite-opfs/manage.ts create mode 100644 yarn-project/kv-store/src/sqlite-opfs/store_management.test.ts create mode 100644 yarn-project/pxe/src/entrypoints/client/store.ts create mode 100644 yarn-project/pxe/src/entrypoints/server/store.test.ts create mode 100644 yarn-project/pxe/src/entrypoints/server/store.ts create mode 100644 yarn-project/pxe/src/storage/store_identity.test.ts create mode 100644 yarn-project/pxe/src/storage/store_identity.ts diff --git a/docs/docs-developers/docs/resources/migration_notes.md b/docs/docs-developers/docs/resources/migration_notes.md index 0ff37ad3c221..81090922254b 100644 --- a/docs/docs-developers/docs/resources/migration_notes.md +++ b/docs/docs-developers/docs/resources/migration_notes.md @@ -9,11 +9,20 @@ Aztec is in active development. Each version may introduce breaking changes that ## TBD -### [PXE] Local PXE database is reset on upgrade +### [PXE] Stores are now selected by `(l1ChainId, rollupAddress, schemaVersion)` instead of being wiped on mismatch -The persisted tagging stores now key every entry by the self-describing `::` form of `AppTaggingSecret`; unconstrained secrets previously used a two-part `:` key. This bumps the PXE data schema version, and there is no forward migration for the old keys: on first open the PXE clears any database whose stored schema version differs from the current one. The wipe resets the entire PXE store, not just the tagging data, because all of it shares one backing database. +Previously, connecting a PXE or embedded wallet to a different or redeployed rollup, or bumping the store schema version, wiped the existing on-disk store in place. That meant master account keys could be destroyed simply by pointing a wallet at a different network. PXE data stores now exist per `(l1ChainId, rollupAddress, schemaVersion)` triple, and switching networks (or upgrading) selects or creates the matching store instead of overwriting previous ones. The embedded wallet's `wallet_data` store is partitioned the same way, so accounts and aliases are per network: switching networks starts with an empty account list until accounts are re-imported, and switching back finds the originals intact. -**Impact**: On upgrade your local PXE state is reset. You must re-register accounts and re-sync from genesis. Wallets should surface a "your local state was reset, please re-register accounts and re-sync" path. +**Impact**: The first start after upgrading to this version begins with a fresh, empty store; the pre-upgrade data is not deleted. On Node.js environments (lmdb-v2) pre-upgrade data stays at `/` while new per-identity `pxe_data` stores live under `/-stores/`. The embedded Node.js wallet previously stored data in cwd-relative, rollup-address-suffixed directories instead (`pxe_data_/pxe_data` for the PXE store, `wallet_data_/wallet_data` for the wallet store): if you used it before this release, that is where the old data lives. The embedded wallet now defaults its data root to `aztec-wallet-data/`, with per-identity wallet stores under `/wallet_data-stores/` on Node.js and OPFS store names prefixed `wallet_data_` in the browser. Browser apps can enumerate and clean up `pxe_data` and `wallet_data` stores for networks no longer in use with the new `listStores()` / `deleteStore()` utilities: + +```ts +import { deleteStore, listStores } from '@aztec/kv-store/sqlite-opfs'; + +const names = await listStores(); +await deleteStore(names[0]); +``` + +This change also removes `createStore` from `@aztec/kv-store/sqlite-opfs` and `@aztec/kv-store/deprecated/indexeddb`: stores are now opened by name with `AztecSQLiteOPFSStore.open` / `AztecIndexedDBStore.open`. ### [Aztec.nr] `TestEnvironmentOptions::with_tagging_secret_strategy` replaced diff --git a/playground/src/components/navbar/components/NetworkSelector.tsx b/playground/src/components/navbar/components/NetworkSelector.tsx index 05854ca06611..0d707ef3d4ba 100644 --- a/playground/src/components/navbar/components/NetworkSelector.tsx +++ b/playground/src/components/navbar/components/NetworkSelector.tsx @@ -7,7 +7,7 @@ import AddIcon from '@mui/icons-material/Add'; import { AddNetworksDialog } from './AddNetworkDialog'; import CircularProgress from '@mui/material/CircularProgress'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; -import { createStore } from '@aztec/kv-store/sqlite-opfs'; +import { AztecSQLiteOPFSStore, storePoolDirectory } from '@aztec/kv-store/sqlite-opfs'; import { AztecContext } from '../../../aztecContext'; import { navbarButtonStyle, navbarSelect } from '../../../styles/common'; import { NETWORKS } from '../../../utils/networks'; @@ -50,10 +50,12 @@ export function NetworkSelector() { } setIsContextInitialized(true); WebLogger.create(setLogs, setTotalLogCount); - const store = await createStore('playground_data', { - dataDirectory: 'playground', - dataStoreMapSizeKb: 1e6, - }); + const store = await AztecSQLiteOPFSStore.open( + WebLogger.getInstance().createLogger('playground_data'), + 'playground_data', + false, + storePoolDirectory('playground_data'), + ); const playgroundDB = PlaygroundDB.getInstance(); playgroundDB.init(store, WebLogger.getInstance().createLogger('playground_db').info); setPlaygroundDB(PlaygroundDB.getInstance()); diff --git a/yarn-project/kv-store/src/deprecated/indexeddb/index.ts b/yarn-project/kv-store/src/deprecated/indexeddb/index.ts index 7ce637b8ab0c..e2ab6b6d5ad3 100644 --- a/yarn-project/kv-store/src/deprecated/indexeddb/index.ts +++ b/yarn-project/kv-store/src/deprecated/indexeddb/index.ts @@ -1,34 +1,9 @@ -import { type Logger, createLogger } from '@aztec/foundation/log'; -import type { DataStoreConfig } from '@aztec/stdlib/kv-store'; +import { createLogger } from '@aztec/foundation/log'; -import { initStoreForRollupAndSchemaVersion } from '../../utils.js'; import { AztecIndexedDBStore } from './store.js'; export { AztecIndexedDBStore } from './store.js'; -/** - * @deprecated The IndexedDB backend is being retired. Use `@aztec/kv-store/sqlite-opfs` instead. - */ -export async function createStore( - name: string, - config: DataStoreConfig, - schemaVersion: number | undefined = undefined, - log: Logger = createLogger('kv-store'), -) { - let { dataDirectory } = config; - if (typeof dataDirectory !== 'undefined') { - dataDirectory = `${dataDirectory}/${name}`; - } - - log.info( - dataDirectory - ? `Creating ${name} data store at directory ${dataDirectory} with map size ${config.dataStoreMapSizeKb} KB` - : `Creating ${name} ephemeral data store with map size ${config.dataStoreMapSizeKb} KB`, - ); - const store = await AztecIndexedDBStore.open(createLogger('kv-store:indexeddb'), dataDirectory ?? '', false); - return initStoreForRollupAndSchemaVersion(store, schemaVersion, config.rollupAddress, log); -} - /** * @deprecated The IndexedDB backend is being retired. Use `@aztec/kv-store/sqlite-opfs` instead. */ diff --git a/yarn-project/kv-store/src/sqlite-opfs/index.ts b/yarn-project/kv-store/src/sqlite-opfs/index.ts index 9599a1532144..65074aa2f865 100644 --- a/yarn-project/kv-store/src/sqlite-opfs/index.ts +++ b/yarn-project/kv-store/src/sqlite-opfs/index.ts @@ -1,28 +1,11 @@ -import { type Logger, createLogger } from '@aztec/foundation/log'; -import type { DataStoreConfig } from '@aztec/stdlib/kv-store'; +import { createLogger } from '@aztec/foundation/log'; -import { initStoreForRollupAndSchemaVersion } from '../utils.js'; import { AztecSQLiteOPFSStore } from './store.js'; export { AztecSQLiteOPFSStore } from './store.js'; export { SqliteEncryptionError } from './errors.js'; export type { SqliteEncryptionErrorCode } from './errors.js'; - -export async function createStore( - name: string, - config: DataStoreConfig, - schemaVersion: number | undefined = undefined, - log: Logger = createLogger('kv-store'), -) { - const { dataDirectory } = config; - log.info( - dataDirectory - ? `Creating ${name} SQLite-OPFS data store with map size ${config.dataStoreMapSizeKb} KB` - : `Creating ${name} ephemeral SQLite-OPFS data store with map size ${config.dataStoreMapSizeKb} KB`, - ); - const store = await AztecSQLiteOPFSStore.open(createLogger('kv-store:sqlite-opfs'), name, false); - return initStoreForRollupAndSchemaVersion(store, schemaVersion, config.rollupAddress, log); -} +export { OPFS_POOL_DIR_PREFIX, deleteStore, listStores, storePoolDirectory } from './manage.js'; export function openTmpStore(ephemeral: boolean = false): Promise { return AztecSQLiteOPFSStore.open(createLogger('kv-store:sqlite-opfs'), undefined, ephemeral); diff --git a/yarn-project/kv-store/src/sqlite-opfs/manage.ts b/yarn-project/kv-store/src/sqlite-opfs/manage.ts new file mode 100644 index 000000000000..2f2f24fe02e3 --- /dev/null +++ b/yarn-project/kv-store/src/sqlite-opfs/manage.ts @@ -0,0 +1,36 @@ +/** Prefix for the per-store OPFS SAH pool directories owned by this package. */ +export const OPFS_POOL_DIR_PREFIX = '.aztec-kv-'; + +/** + * OPFS directory holding a store's SAH pool. One directory per store: the SAH-pool VFS allows only one + * concurrent instance per directory and its capacity does not grow automatically, so sharing a pool across + * stores would make concurrently opened stores contend for locks and orphaned stores exhaust pool slots. + */ +export function storePoolDirectory(effectiveName: string): string { + return `${OPFS_POOL_DIR_PREFIX}${effectiveName}`; +} + +/** + * Lists the names of every persistent sqlite-opfs store in this origin, by enumerating the per-store pool + * directories. Includes stores other than the current one, so wallets can surface and clean up data for + * networks/versions no longer in use. + */ +export async function listStores(): Promise { + const root = await navigator.storage.getDirectory(); + const names: string[] = []; + for await (const [entryName, handle] of root.entries()) { + if (handle.kind === 'directory' && entryName.startsWith(OPFS_POOL_DIR_PREFIX)) { + names.push(entryName.slice(OPFS_POOL_DIR_PREFIX.length)); + } + } + return names; +} + +/** + * Permanently deletes a store by effective name (as returned by {@link listStores}). The store must be closed: + * an open store's SAH pool holds locks on the directory and the removal will reject. + */ +export async function deleteStore(effectiveName: string): Promise { + const root = await navigator.storage.getDirectory(); + await root.removeEntry(storePoolDirectory(effectiveName), { recursive: true }); +} diff --git a/yarn-project/kv-store/src/sqlite-opfs/store_management.test.ts b/yarn-project/kv-store/src/sqlite-opfs/store_management.test.ts new file mode 100644 index 000000000000..4a99674e9b38 --- /dev/null +++ b/yarn-project/kv-store/src/sqlite-opfs/store_management.test.ts @@ -0,0 +1,56 @@ +import { mockLogger } from '../interfaces/utils.js'; +import { AztecSQLiteOPFSStore } from './index.js'; +import { deleteStore, listStores, storePoolDirectory } from './manage.js'; + +const openByName = (name: string) => AztecSQLiteOPFSStore.open(mockLogger, name, false, storePoolDirectory(name)); + +describe('sqlite-opfs store management', () => { + it('round-trips data for a store reopened by name', async () => { + const store = await openByName('mech_roundtrip'); + await store.openSingleton('payload').set('data'); + await store.close(); + + const reopened = await openByName('mech_roundtrip'); + expect(await reopened.openSingleton('payload').getAsync()).toEqual('data'); + await reopened.close(); + await deleteStore('mech_roundtrip'); + }); + + it('opens two different stores concurrently in the same tab', async () => { + const a = await openByName('mech_concurrent_a'); + const b = await openByName('mech_concurrent_b'); + + await a.openSingleton('k').set('a'); + await b.openSingleton('k').set('b'); + expect(await a.openSingleton('k').getAsync()).toEqual('a'); + expect(await b.openSingleton('k').getAsync()).toEqual('b'); + + await a.close(); + await b.close(); + await deleteStore('mech_concurrent_a'); + await deleteStore('mech_concurrent_b'); + }); + + it('lists created stores and deletes them', async () => { + const store = await openByName('mech_managed'); + await store.openSingleton('k').set('v'); + await store.close(); + + expect(await listStores()).toContain('mech_managed'); + await deleteStore('mech_managed'); + expect(await listStores()).not.toContain('mech_managed'); + + // Recreating after deletion starts empty. + const fresh = await openByName('mech_managed'); + expect(await fresh.openSingleton('k').getAsync()).toBeUndefined(); + await fresh.close(); + await deleteStore('mech_managed'); + }); + + it('refuses to delete a store that is currently open', async () => { + const store = await openByName('mech_locked'); + await expect(deleteStore('mech_locked')).rejects.toThrow(); + await store.close(); + await deleteStore('mech_locked'); + }); +}); diff --git a/yarn-project/kv-store/tsconfig.json b/yarn-project/kv-store/tsconfig.json index b9f4050a2816..673519ee091f 100644 --- a/yarn-project/kv-store/tsconfig.json +++ b/yarn-project/kv-store/tsconfig.json @@ -5,7 +5,8 @@ "rootDir": "src", "tsBuildInfoFile": ".tsbuildinfo", "allowJs": true, - "checkJs": false + "checkJs": false, + "lib": ["dom", "dom.asynciterable", "esnext", "es2017.object"] }, "references": [ { diff --git a/yarn-project/pxe/src/entrypoints/client/bundle/index.ts b/yarn-project/pxe/src/entrypoints/client/bundle/index.ts index 437bf2a74da3..12f498eabc41 100644 --- a/yarn-project/pxe/src/entrypoints/client/bundle/index.ts +++ b/yarn-project/pxe/src/entrypoints/client/bundle/index.ts @@ -6,3 +6,4 @@ export * from '../../../contract_logging.js'; export * from '../../../storage/index.js'; export * from './utils.js'; export type { PXECreationOptions } from '../../pxe_creation_options.js'; +export { openBrowserStore } from '../store.js'; diff --git a/yarn-project/pxe/src/entrypoints/client/bundle/utils.ts b/yarn-project/pxe/src/entrypoints/client/bundle/utils.ts index 3c37cd2d3490..7961cc7155ec 100644 --- a/yarn-project/pxe/src/entrypoints/client/bundle/utils.ts +++ b/yarn-project/pxe/src/entrypoints/client/bundle/utils.ts @@ -1,6 +1,5 @@ import { BBBundlePrivateKernelProver } from '@aztec/bb-prover/client/bundle'; import { createLogger } from '@aztec/foundation/log'; -import { createStore } from '@aztec/kv-store/sqlite-opfs'; import { BundledProtocolContractsProvider } from '@aztec/protocol-contracts/providers/bundle'; import { WASMSimulator } from '@aztec/simulator/client'; import { getStandardAuthRegistry } from '@aztec/standard-contracts/auth-registry'; @@ -12,6 +11,7 @@ import type { PXEConfig } from '../../../config/index.js'; import { PXE } from '../../../pxe.js'; import { PXE_DATA_SCHEMA_VERSION } from '../../../storage/metadata.js'; import { type PXECreationOptions, isPrivateKernelProver } from '../../pxe_creation_options.js'; +import { openBrowserStore } from '../store.js'; /** * Create and start an PXE instance with the given AztecNode. @@ -31,16 +31,28 @@ export async function createPXE( const actor = options.loggerActorLabel; const loggers = options.loggers ?? {}; - const l1ContractAddresses = await aztecNode.getL1ContractAddresses(); + const { l1ChainId, l1ContractAddresses, rollupVersion } = await aztecNode.getNodeInfo(); const configWithContracts = { ...config, ...l1ContractAddresses, + l1ChainId, + rollupVersion, } as PXEConfig; const storeLogger = loggers.store ?? createLogger('pxe:data', { actor }); const store = - options.store ?? (await createStore('pxe_data', configWithContracts, PXE_DATA_SCHEMA_VERSION, storeLogger)); + options.store ?? + (await openBrowserStore( + 'pxe_data', + PXE_DATA_SCHEMA_VERSION, + { + l1ChainId, + rollupAddress: l1ContractAddresses.rollupAddress, + dataStoreMapSizeKb: configWithContracts.dataStoreMapSizeKb, + }, + storeLogger, + )); const simulator = options.simulator ?? new WASMSimulator(); const proverLogger = loggers.prover ?? createLogger('pxe:bb:wasm:bundle', { actor }); diff --git a/yarn-project/pxe/src/entrypoints/client/lazy/index.ts b/yarn-project/pxe/src/entrypoints/client/lazy/index.ts index 3f417a8b5209..d9a339a33458 100644 --- a/yarn-project/pxe/src/entrypoints/client/lazy/index.ts +++ b/yarn-project/pxe/src/entrypoints/client/lazy/index.ts @@ -6,3 +6,4 @@ export * from '../../../error_enriching.js'; export * from '../../../contract_logging.js'; export * from './utils.js'; export { type PXECreationOptions } from '../../pxe_creation_options.js'; +export { openBrowserStore } from '../store.js'; diff --git a/yarn-project/pxe/src/entrypoints/client/lazy/utils.ts b/yarn-project/pxe/src/entrypoints/client/lazy/utils.ts index a036c9b7bc7a..f9f228db638a 100644 --- a/yarn-project/pxe/src/entrypoints/client/lazy/utils.ts +++ b/yarn-project/pxe/src/entrypoints/client/lazy/utils.ts @@ -1,6 +1,5 @@ import { BBLazyPrivateKernelProver } from '@aztec/bb-prover/client/lazy'; import { createLogger } from '@aztec/foundation/log'; -import { createStore } from '@aztec/kv-store/sqlite-opfs'; import { LazyProtocolContractsProvider } from '@aztec/protocol-contracts/providers/lazy'; import { WASMSimulator } from '@aztec/simulator/client'; import { getStandardAuthRegistry } from '@aztec/standard-contracts/auth-registry/lazy'; @@ -12,6 +11,7 @@ import type { PXEConfig } from '../../../config/index.js'; import { PXE } from '../../../pxe.js'; import { PXE_DATA_SCHEMA_VERSION } from '../../../storage/metadata.js'; import { type PXECreationOptions, isPrivateKernelProver } from '../../pxe_creation_options.js'; +import { openBrowserStore } from '../store.js'; /** * Create and start an PXE instance with the given AztecNode. @@ -29,10 +29,12 @@ export async function createPXE( ) { const actor = options.loggerActorLabel; - const l1ContractAddresses = await aztecNode.getL1ContractAddresses(); + const { l1ChainId, l1ContractAddresses, rollupVersion } = await aztecNode.getNodeInfo(); const configWithContracts = { ...config, ...l1ContractAddresses, + l1ChainId, + rollupVersion, } as PXEConfig; const loggers = options.loggers ?? {}; @@ -40,7 +42,17 @@ export async function createPXE( const storeLogger = loggers.store ?? createLogger('pxe:data', { actor }); const store = - options.store ?? (await createStore('pxe_data', configWithContracts, PXE_DATA_SCHEMA_VERSION, storeLogger)); + options.store ?? + (await openBrowserStore( + 'pxe_data', + PXE_DATA_SCHEMA_VERSION, + { + l1ChainId, + rollupAddress: l1ContractAddresses.rollupAddress, + dataStoreMapSizeKb: configWithContracts.dataStoreMapSizeKb, + }, + storeLogger, + )); const simulator = options.simulator ?? new WASMSimulator(); const proverLogger = loggers.prover ?? createLogger('pxe:bb:wasm:bundle', { actor }); diff --git a/yarn-project/pxe/src/entrypoints/client/store.ts b/yarn-project/pxe/src/entrypoints/client/store.ts new file mode 100644 index 000000000000..13d8583cab0d --- /dev/null +++ b/yarn-project/pxe/src/entrypoints/client/store.ts @@ -0,0 +1,38 @@ +import type { EthAddress } from '@aztec/foundation/eth-address'; +import { type Logger, createLogger } from '@aztec/foundation/log'; +import { AztecSQLiteOPFSStore, storePoolDirectory } from '@aztec/kv-store/sqlite-opfs'; + +import { assertStoreIdentity, effectiveStoreName } from '../../storage/store_identity.js'; + +/** + * Opens the persistent browser (sqlite-opfs) store selected by `name` and identity `(config.l1ChainId, + * config.rollupAddress, schemaVersion)` triple. A store exists per identity: reopening with the same identity returns + * the same data, a different identity selects a different (possibly fresh) store. + */ +export async function openBrowserStore( + name: string, + schemaVersion: number, + config: { l1ChainId: number; rollupAddress: EthAddress; dataStoreMapSizeKb?: number }, + log: Logger = createLogger('pxe:data'), +): Promise { + const identity = { l1ChainId: config.l1ChainId, rollupAddress: config.rollupAddress, schemaVersion }; + const storeName = effectiveStoreName(name, identity); + log.info(`Creating ${storeName} SQLite-OPFS data store`, { + storeName, + dataStoreMapSizeKb: config.dataStoreMapSizeKb, + }); + const store = await AztecSQLiteOPFSStore.open( + createLogger('kv-store:sqlite-opfs'), + storeName, + false, + storePoolDirectory(storeName), + ); + try { + await assertStoreIdentity(store, storeName, identity); + } catch (err) { + // The store handle owns a worker and OPFS locks; release them before surfacing the refusal. + await store.close().catch(() => {}); + throw err; + } + return store; +} diff --git a/yarn-project/pxe/src/entrypoints/server/index.ts b/yarn-project/pxe/src/entrypoints/server/index.ts index c4a96d12cd9f..aaefc18a5a8b 100644 --- a/yarn-project/pxe/src/entrypoints/server/index.ts +++ b/yarn-project/pxe/src/entrypoints/server/index.ts @@ -6,6 +6,7 @@ export * from '../../config/index.js'; export * from '../../error_enriching.js'; export * from '../../storage/index.js'; export * from './utils.js'; +export * from './store.js'; export { NoteService } from '../../notes/note_service.js'; export { ORACLE_VERSION_MAJOR, ORACLE_VERSION_MINOR } from '../../oracle_version.js'; export { type PXECreationOptions } from '../pxe_creation_options.js'; diff --git a/yarn-project/pxe/src/entrypoints/server/store.test.ts b/yarn-project/pxe/src/entrypoints/server/store.test.ts new file mode 100644 index 000000000000..006a6810b246 --- /dev/null +++ b/yarn-project/pxe/src/entrypoints/server/store.test.ts @@ -0,0 +1,67 @@ +import { EthAddress } from '@aztec/foundation/eth-address'; + +import { mkdtemp, rm, stat } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import { openStore } from './store.js'; + +describe('openStore', () => { + let dataDirectory: string; + + beforeEach(async () => { + dataDirectory = await mkdtemp(join(tmpdir(), 'store-identity-test-')); + }); + + afterEach(async () => { + await rm(dataDirectory, { recursive: true, force: true }); + }); + + const configFor = (rollupAddress: EthAddress, l1ChainId = 31337) => ({ + dataDirectory, + dataStoreMapSizeKb: 10 * 1024, + rollupAddress, + l1ChainId, + }); + + it('keeps data intact when switching rollup addresses back and forth', async () => { + const addrA = EthAddress.random(); + const addrB = EthAddress.random(); + + const storeA = await openStore('test_store', 1, configFor(addrA)); + await storeA.openSingleton('payload').set('data-for-A'); + await storeA.close(); + + const storeB = await openStore('test_store', 1, configFor(addrB)); + expect(await storeB.openSingleton('payload').getAsync()).toBeUndefined(); + await storeB.close(); + + const reopenedA = await openStore('test_store', 1, configFor(addrA)); + expect(await reopenedA.openSingleton('payload').getAsync()).toEqual('data-for-A'); + await reopenedA.close(); + }); + + it('separates stores by schema version', async () => { + const addr = EthAddress.random(); + + const v1 = await openStore('test_store', 1, configFor(addr)); + await v1.openSingleton('k').set('v1-data'); + await v1.close(); + + const v2 = await openStore('test_store', 2, configFor(addr)); + expect(await v2.openSingleton('k').getAsync()).toBeUndefined(); + await v2.close(); + + const v1Again = await openStore('test_store', 1, configFor(addr)); + expect(await v1Again.openSingleton('k').getAsync()).toEqual('v1-data'); + await v1Again.close(); + }); + + it('places stores under a sibling -stores directory, not nested in ', async () => { + const store = await openStore('test_store', 1, configFor(EthAddress.random())); + await store.close(); + + await expect(stat(join(dataDirectory, 'test_store-stores'))).resolves.toBeDefined(); + await expect(stat(join(dataDirectory, 'test_store'))).rejects.toMatchObject({ code: 'ENOENT' }); + }); +}); diff --git a/yarn-project/pxe/src/entrypoints/server/store.ts b/yarn-project/pxe/src/entrypoints/server/store.ts new file mode 100644 index 000000000000..932a13cbff83 --- /dev/null +++ b/yarn-project/pxe/src/entrypoints/server/store.ts @@ -0,0 +1,47 @@ +import type { EthAddress } from '@aztec/foundation/eth-address'; +import { type LoggerBindings, createLogger } from '@aztec/foundation/log'; +import { type AztecLMDBStoreV2, openStoreAt } from '@aztec/kv-store/lmdb-v2'; + +import { mkdir } from 'fs/promises'; +import { join } from 'path'; + +import { storeIdentitySlug } from '../../storage/store_identity.js'; + +/** Location and identity inputs for opening an identity-partitioned PXE-side store. */ +export type IdentityStoreConfig = { + dataDirectory: string; + /** Maximum LMDB map size in KB. When omitted, the kv-store default map size applies. */ + dataStoreMapSizeKb?: number; + l1ChainId: number; + rollupAddress: EthAddress; +}; + +/** + * Opens the persistent LMDB store selected by `name` and identity triple `(l1ChainId, rollupAddress, schemaVersion)`. + * A store exists per identity: reopening with the same identity returns the same data, a different identity selects + * a different (possibly fresh) store. Callers wanting an ephemeral store use `openTmpStore` explicitly instead. + */ +export async function openStore( + name: string, + schemaVersion: number, + config: IdentityStoreConfig, + bindings?: LoggerBindings, +): Promise { + // Stores live under `/-stores/--v`, a sibling of the + // legacy `/` directory: older binaries reset the legacy directory on a rollup mismatch, so + // per-identity stores must not nest inside it. + const subDir = join( + config.dataDirectory, + `${name}-stores`, + storeIdentitySlug({ l1ChainId: config.l1ChainId, rollupAddress: config.rollupAddress, schemaVersion }), + ); + await mkdir(subDir, { recursive: true }); + + createLogger(`pxe:data:${name}`, bindings).info(`Opening ${name} data store (LMDB v2)`, { + storeName: name, + subDir, + dataStoreMapSizeKb: config.dataStoreMapSizeKb, + }); + + return openStoreAt(subDir, config.dataStoreMapSizeKb, undefined, bindings); +} diff --git a/yarn-project/pxe/src/entrypoints/server/utils.ts b/yarn-project/pxe/src/entrypoints/server/utils.ts index 815c30e01071..32e0d5db5b5f 100644 --- a/yarn-project/pxe/src/entrypoints/server/utils.ts +++ b/yarn-project/pxe/src/entrypoints/server/utils.ts @@ -1,7 +1,7 @@ import { BBBundlePrivateKernelProver } from '@aztec/bb-prover/client/bundle'; import type { L1ContractAddresses } from '@aztec/ethereum/l1-contract-addresses'; import { createLogger } from '@aztec/foundation/log'; -import { createStore } from '@aztec/kv-store/lmdb-v2'; +import { openTmpStore } from '@aztec/kv-store/lmdb-v2'; import { BundledProtocolContractsProvider } from '@aztec/protocol-contracts/providers/bundle'; import { WASMSimulator } from '@aztec/simulator/client'; import { MemoryCircuitRecorder, SimulatorRecorderWrapper } from '@aztec/simulator/server'; @@ -15,6 +15,7 @@ import type { PXEConfig } from '../../config/index.js'; import { PXE } from '../../pxe.js'; import { PXE_DATA_SCHEMA_VERSION } from '../../storage/index.js'; import { type PXECreationOptions, isPrivateKernelProver } from '../pxe_creation_options.js'; +import { openStore } from './store.js'; type PXEConfigWithoutDefaults = Omit< PXEConfig, @@ -46,12 +47,25 @@ export async function createPXE( if (!options.store) { const storeLogger = loggers.store ?? createLogger('pxe:data:lmdb', { actor }); - options.store = await createStore( - 'pxe_data', - PXE_DATA_SCHEMA_VERSION, - configWithContracts, - storeLogger.getBindings(), - ); + options.store = configWithContracts.dataDirectory + ? await openStore( + 'pxe_data', + PXE_DATA_SCHEMA_VERSION, + { + dataDirectory: configWithContracts.dataDirectory, + dataStoreMapSizeKb: configWithContracts.dataStoreMapSizeKb, + l1ChainId, + rollupAddress: l1ContractAddresses.rollupAddress, + }, + storeLogger.getBindings(), + ) + : await openTmpStore( + 'pxe_data', + true, + configWithContracts.dataStoreMapSizeKb, + undefined, + storeLogger.getBindings(), + ); } const proverLogger = loggers.prover ?? createLogger('pxe:bb:native', { actor }); diff --git a/yarn-project/pxe/src/storage/backwards_compatibility_tests/pxe_db_compatibility.test.ts b/yarn-project/pxe/src/storage/backwards_compatibility_tests/pxe_db_compatibility.test.ts index e40e0209e4a1..c92a6c4eb87a 100644 --- a/yarn-project/pxe/src/storage/backwards_compatibility_tests/pxe_db_compatibility.test.ts +++ b/yarn-project/pxe/src/storage/backwards_compatibility_tests/pxe_db_compatibility.test.ts @@ -2,7 +2,7 @@ import { Fr } from '@aztec/foundation/curves/bn254'; import { EthAddress } from '@aztec/foundation/eth-address'; import { KeyStore } from '@aztec/key-store'; import type { AztecAsyncKVStore } from '@aztec/kv-store'; -import { createStore, openTmpStore } from '@aztec/kv-store/lmdb-v2'; +import { openTmpStore } from '@aztec/kv-store/lmdb-v2'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; import { GENESIS_BLOCK_HEADER_HASH } from '@aztec/stdlib/block'; @@ -12,6 +12,7 @@ import { tmpdir } from 'os'; import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; +import { openStore } from '../../entrypoints/server/store.js'; import { PXE_DATA_SCHEMA_VERSION } from '../metadata.js'; import { openPxeStores } from '../open_pxe_stores.js'; import { SCHEMA_TESTS } from './schema_tests.js'; @@ -85,9 +86,9 @@ function compatibilityTestGuidance(name: string): string { 'If intentional, take these steps in order:', ' 1. Determine whether the change is BREAKING or READ-DEFAULTABLE:', ' - If BREAKING (e.g., a sub-store was renamed or removed; existing data becomes inaccessible):', - ' bump PXE_DATA_SCHEMA_VERSION in pxe/src/storage/metadata.ts. DatabaseVersionManager wipes any', - ' DB whose stored version is below the current one when it next opens; without this bump,', - ' existing wallets see corrupted data with no migration path.', + ' bump PXE_DATA_SCHEMA_VERSION in pxe/src/storage/metadata.ts. The schema version is part of the', + ' store identity, so bumping it selects a fresh store on next open (existing data is left on disk,', + ' unread); without this bump, existing wallets see corrupted data with no migration path.', ' - If READ-DEFAULTABLE (e.g., a new sub-store was added; existing data continues to work because', ' the new sub-store starts empty for pre-existing DBs and is populated as new events arrive):', ' leave PXE_DATA_SCHEMA_VERSION alone, but document the reasoning in the commit/PR description.', @@ -115,9 +116,9 @@ function compatibilityTestGuidance(name: string): string { ' Examples: a new key whose absence resolves to a sentinel (genesis, undefined, etc.); a new', ' field with a safe default. Existing wallets continue working after upgrade and the on-disk', ' state self-heals as new events arrive.', - ' 2. If BREAKING: bump PXE_DATA_SCHEMA_VERSION in pxe/src/storage/metadata.ts. DatabaseVersionManager', - ' wipes any DB whose stored version is below the current one when it next opens; without this bump,', - ' existing wallets see corrupted data with no migration path.', + ' 2. If BREAKING: bump PXE_DATA_SCHEMA_VERSION in pxe/src/storage/metadata.ts. The schema version is', + ' part of the store identity, so bumping it selects a fresh store on next open (existing data is left', + ' on disk, unread); without this bump, existing wallets see corrupted data with no migration path.', ' If READ-DEFAULTABLE: leave PXE_DATA_SCHEMA_VERSION alone, but document the reasoning in the', ' commit/PR description (which fallback applies, where, and what state pre-upgrade DBs converge to).', ` 3. Regenerate ONLY ${name}.json. From the yarn-project directory, run:`, @@ -146,36 +147,46 @@ async function collectOpenedStores() { } /** - * Seeds rows into a `pxe_data` store opened at `oldSchemaVersion`, then reopens it at the current - * `PXE_DATA_SCHEMA_VERSION`. The version mismatch makes DatabaseVersionManager wipe the database on open, so - * `assertWiped` runs against a cleared store and can prove the legacy rows are gone. + * Seeds rows into a `pxe_data` store opened at `oldSchemaVersion`, then opens the store for the current + * `PXE_DATA_SCHEMA_VERSION`. The schema version is part of the store identity, so the current version selects a + * fresh store: `assertNotRead` proves the legacy rows are invisible to new code, and `assertLegacyIntact` proves + * they still exist untouched in the old schema's own store. */ -async function expectStoreWipedOnUpgradeFrom( +async function expectFreshStoreSelectedOnUpgradeFrom( oldSchemaVersion: number, seedLegacyRows: (oldStore: AztecAsyncKVStore) => Promise, - assertWiped: (currentStore: AztecAsyncKVStore) => Promise, + assertNotRead: (currentStore: AztecAsyncKVStore) => Promise, + assertLegacyIntact: (oldStore: AztecAsyncKVStore) => Promise, ) { - const dataDirectory = await mkdtemp(join(tmpdir(), 'pxe-schema-wipe-')); + const dataDirectory = await mkdtemp(join(tmpdir(), 'pxe-schema-upgrade-')); const config = { dataDirectory, dataStoreMapSizeKb: 1024, + l1ChainId: 31337, rollupAddress: EthAddress.ZERO, }; try { - const oldStore = await createStore('pxe_data', oldSchemaVersion, config); + const oldStore = await openStore('pxe_data', oldSchemaVersion, config); try { await seedLegacyRows(oldStore); } finally { await oldStore.close(); } - const currentStore = await createStore('pxe_data', PXE_DATA_SCHEMA_VERSION, config); + const currentStore = await openStore('pxe_data', PXE_DATA_SCHEMA_VERSION, config); try { - await assertWiped(currentStore); + await assertNotRead(currentStore); } finally { await currentStore.close(); } + + const reopenedOldStore = await openStore('pxe_data', oldSchemaVersion, config); + try { + await assertLegacyIntact(reopenedOldStore); + } finally { + await reopenedOldStore.close(); + } } finally { await rm(dataDirectory, { recursive: true, force: true, maxRetries: 3 }); } @@ -198,36 +209,38 @@ async function expectStoreWipedOnUpgradeFrom( * class and compares the resulting bytes to a committed per-store snapshot. */ describe('PXE storage compatibility test suite', () => { - it('wipes key-store rows written before the message-signing and fallback secret keys were removed', async () => { + it('never reads key-store rows written under an older schema version, and leaves them intact', async () => { const account = AztecAddress.fromStringUnsafe('0x0b3683ee9df3ed6ed7027145bd6093f783b0bb4d8354501d906db7bb8cb58ea3'); - await expectStoreWipedOnUpgradeFrom( + const ivskKey = `${account.toString()}-ivsk_m`; + const ivskValue = Buffer.from('1fb01c42d1aaa2662041b899c77cb19e08192193acc5a94405f1b43c974eba7a', 'hex'); + await expectFreshStoreSelectedOnUpgradeFrom( PRE_MESSAGE_AND_FALLBACK_SECRET_KEY_REMOVAL_PXE_SCHEMA_VERSION, - oldStore => - oldStore - .openMap('key_store') - .set( - `${account.toString()}-ivsk_m`, - Buffer.from('1fb01c42d1aaa2662041b899c77cb19e08192193acc5a94405f1b43c974eba7a', 'hex'), - ), + oldStore => oldStore.openMap('key_store').set(ivskKey, ivskValue), async currentStore => { const keyStore = new KeyStore(currentStore); await expect(keyStore.hasAccount(account)).resolves.toBe(false); }, + async oldStore => { + expect(await oldStore.openMap('key_store').getAsync(ivskKey)).toEqual(ivskValue); + }, ); }); - it('wipes tagging-store rows written under the legacy two-part AppTaggingSecret key format', async () => { + it('never reads tagging-store rows written under the legacy two-part AppTaggingSecret key format, and leaves them intact', async () => { // The current toString() only emits the three-part `::` form, so build the legacy two-part // `:` key by hand. const legacyKey = `${new Fr(2n).toString()}:${AztecAddress.fromBigIntUnsafe(3n).toString()}`; - await expectStoreWipedOnUpgradeFrom( + await expectFreshStoreSelectedOnUpgradeFrom( PRE_KIND_PREFIXED_TAGGING_KEY_PXE_SCHEMA_VERSION, oldStore => oldStore.openMap('highest_aged_index').set(legacyKey, 13), async currentStore => { // Assert on the raw map, not a high-level getter: the new three-part toString() never reconstructs the - // legacy two-part key, so a getter would report it absent (false-pass) whether or not the wipe happened. + // legacy two-part key, so a getter would report it absent (false-pass) regardless of which store was opened. await expect(currentStore.openMap('highest_aged_index').sizeAsync()).resolves.toBe(0); }, + async oldStore => { + expect(await oldStore.openMap('highest_aged_index').getAsync(legacyKey)).toBe(13); + }, ); }); diff --git a/yarn-project/pxe/src/storage/store_identity.test.ts b/yarn-project/pxe/src/storage/store_identity.test.ts new file mode 100644 index 000000000000..b8288bbf2232 --- /dev/null +++ b/yarn-project/pxe/src/storage/store_identity.test.ts @@ -0,0 +1,68 @@ +import { EthAddress } from '@aztec/foundation/eth-address'; +import { openTmpStore } from '@aztec/kv-store/lmdb-v2'; + +import { + StoreIdentityMismatchError, + assertStoreIdentity, + effectiveStoreName, + storeIdentitySlug, +} from './store_identity.js'; + +describe('storeIdentitySlug', () => { + it('composes chain id, rollup address and schema version', () => { + const rollupAddress = EthAddress.fromString('0x1234567890abcdef1234567890abcdef12345678'); + expect(storeIdentitySlug({ l1ChainId: 31337, rollupAddress, schemaVersion: 12 })).toEqual( + '31337-0x1234567890abcdef1234567890abcdef12345678-v12', + ); + }); + + it('normalizes the rollup address to lowercase hex', () => { + const rollupAddress = EthAddress.fromString('0x1234567890ABCDEF1234567890ABCDEF12345678'); + expect(storeIdentitySlug({ l1ChainId: 0, rollupAddress, schemaVersion: 1 })).toEqual( + '0-0x1234567890abcdef1234567890abcdef12345678-v1', + ); + }); +}); + +describe('effectiveStoreName', () => { + it('joins the logical name and the slug with an underscore', () => { + const rollupAddress = EthAddress.fromString('0x1234567890abcdef1234567890abcdef12345678'); + expect(effectiveStoreName('pxe_data', { l1ChainId: 1, rollupAddress, schemaVersion: 2 })).toEqual( + 'pxe_data_1-0x1234567890abcdef1234567890abcdef12345678-v2', + ); + }); +}); + +describe('assertStoreIdentity', () => { + const identityFor = (rollupAddress: EthAddress) => ({ l1ChainId: 31337, rollupAddress, schemaVersion: 1 }); + + it('writes the marker on first open and accepts a matching reopen', async () => { + const store = await openTmpStore('identity-test'); + const identity = identityFor(EthAddress.random()); + await assertStoreIdentity(store, 'test_store', identity); + await expect(assertStoreIdentity(store, 'test_store', identity)).resolves.toBeUndefined(); + await store.close(); + }); + + it('refuses a mismatching identity and leaves data untouched', async () => { + const store = await openTmpStore('identity-test'); + const identity = identityFor(EthAddress.random()); + await assertStoreIdentity(store, 'test_store', identity); + await store.openSingleton('payload').set('precious'); + + const bumped = { ...identity, schemaVersion: identity.schemaVersion + 1 }; + await expect(assertStoreIdentity(store, 'test_store', bumped)).rejects.toThrow(StoreIdentityMismatchError); + + expect(await store.openSingleton('payload').getAsync()).toEqual('precious'); + await store.close(); + }); + + it('refuses a corrupted marker', async () => { + const store = await openTmpStore('identity-test'); + await store.openSingleton('dbVersion').set('garbage'); + await expect(assertStoreIdentity(store, 'test_store', identityFor(EthAddress.random()))).rejects.toThrow( + StoreIdentityMismatchError, + ); + await store.close(); + }); +}); diff --git a/yarn-project/pxe/src/storage/store_identity.ts b/yarn-project/pxe/src/storage/store_identity.ts new file mode 100644 index 000000000000..6daeb4a2e5a7 --- /dev/null +++ b/yarn-project/pxe/src/storage/store_identity.ts @@ -0,0 +1,72 @@ +import type { EthAddress } from '@aztec/foundation/eth-address'; +import type { AztecAsyncKVStore } from '@aztec/kv-store'; +import { DatabaseVersion } from '@aztec/stdlib/database-version/version'; + +/** The triple that determine which physical store a logical store name maps to. */ +export type StoreIdentity = { + /** Chain ID of the L1 the rollup is deployed to. */ + l1ChainId: number; + /** Address of the rollup contract the store's data pertains to. */ + rollupAddress: EthAddress; + /** Schema version of the data held in the store. */ + schemaVersion: number; +}; + +/** + * Composes the store-name discriminator for a store identity. Two identities map to the same physical store iff + * their slugs are equal, so the format must stay stable: `--v` — changing + * it orphans every existing store. + */ +export function storeIdentitySlug({ l1ChainId, rollupAddress, schemaVersion }: StoreIdentity): string { + return `${l1ChainId}-${rollupAddress.toString()}-v${schemaVersion}`; +} + +/** Composes the physical store name for a logical store name and identity. */ +export function effectiveStoreName(name: string, identity: StoreIdentity): string { + return `${name}_${storeIdentitySlug(identity)}`; +} + +/** + * Invariant check for stores whose physical name carries their identity: the recorded version can + * only disagree with the expected one if there is a store-naming bug. + */ +export async function assertStoreIdentity( + store: AztecAsyncKVStore, + storeName: string, + identity: StoreIdentity, +): Promise { + const expected = new DatabaseVersion(identity.schemaVersion, identity.rollupAddress); + const singleton = store.openSingleton('dbVersion'); + const stored = await singleton.getAsync(); + if (stored === undefined) { + await singleton.set(expected.toBuffer().toString('utf-8')); + return; + } + let storedVersion: DatabaseVersion; + try { + storedVersion = DatabaseVersion.fromBuffer(Buffer.from(stored, 'utf-8')); + } catch { + throw new StoreIdentityMismatchError(storeName, expected.toString(), stored); + } + if (!storedVersion.equals(expected)) { + throw new StoreIdentityMismatchError(storeName, expected.toString(), storedVersion.toString()); + } +} + +/** + * Thrown when a store's recorded identity does not match the identity it was opened under. Since the identity is + * part of the physical store name, this can only indicate a store-naming bug; the store is left untouched. + */ +export class StoreIdentityMismatchError extends Error { + constructor( + public readonly storeName: string, + public readonly expected: string, + public readonly actual: string, + ) { + super( + `Store '${storeName}' records identity ${actual} but was opened as ${expected}. ` + + `Refusing to open; data was NOT modified.`, + ); + this.name = 'StoreIdentityMismatchError'; + } +} diff --git a/yarn-project/wallets/src/embedded/entrypoints/browser.ts b/yarn-project/wallets/src/embedded/entrypoints/browser.ts index 8a5a7bdaf1ca..2b34f83ffbca 100644 --- a/yarn-project/wallets/src/embedded/entrypoints/browser.ts +++ b/yarn-project/wallets/src/embedded/entrypoints/browser.ts @@ -1,7 +1,7 @@ import { type AztecNode, createAztecNodeClient } from '@aztec/aztec.js/node'; import { type Logger, createLogger } from '@aztec/foundation/log'; -import { createStore, openTmpStore } from '@aztec/kv-store/sqlite-opfs'; -import { type PXE, type PXECreationOptions, createPXE } from '@aztec/pxe/client/lazy'; +import { openTmpStore } from '@aztec/kv-store/sqlite-opfs'; +import { type PXE, type PXECreationOptions, createPXE, openBrowserStore } from '@aztec/pxe/client/lazy'; import { type PXEConfig, getPXEConfig } from '@aztec/pxe/config'; import { getStandardAuthRegistry } from '@aztec/standard-contracts/auth-registry/lazy'; import { getStandardHandshakeRegistry } from '@aztec/standard-contracts/handshake-registry/lazy'; @@ -10,7 +10,7 @@ import { getStandardMultiCallEntrypoint } from '@aztec/standard-contracts/multi- import { LazyAccountContractsProvider } from '../account-contract-providers/lazy.js'; import type { AccountContractsProvider } from '../account-contract-providers/types.js'; import { EmbeddedWallet, type EmbeddedWalletOptions, splitPxeOptions } from '../embedded_wallet.js'; -import { WalletDB } from '../wallet_db.js'; +import { WALLET_DATA_SCHEMA_VERSION, WalletDB } from '../wallet_db.js'; export class BrowserEmbeddedWallet extends EmbeddedWallet { static async create( @@ -27,7 +27,6 @@ export class BrowserEmbeddedWallet extends EmbeddedWallet { const rootLogger = options.logger ?? createLogger('embedded-wallet'); const aztecNode = typeof nodeOrUrl === 'string' ? createAztecNodeClient(nodeOrUrl) : nodeOrUrl; - const l1Contracts = await aztecNode.getL1ContractAddresses(); // Support both the new unified `pxe` option and the deprecated `pxeConfig`/`pxeOptions`. const { config: pxeConfigFromPxe, creation: pxeCreationFromPxe } = splitPxeOptions(options.pxe); @@ -36,7 +35,8 @@ export class BrowserEmbeddedWallet extends EmbeddedWallet { const pxeConfig: PXEConfig = Object.assign(getPXEConfig(), { proverEnabled: mergedConfigOverrides.proverEnabled, - dataDirectory: `pxe_data_${l1Contracts.rollupAddress}`, + // Unused in the browser: sqlite-opfs keys stores by name, not directory. + dataDirectory: 'pxe_data', autoSync: false, ...mergedConfigOverrides, }); @@ -64,20 +64,20 @@ export class BrowserEmbeddedWallet extends EmbeddedWallet { const pxe = await createPXE(aztecNode, pxeConfig, pxeOptions); - const walletDBStore = - options.walletDb?.store ?? - (options.ephemeral - ? await openTmpStore(true) - : await createStore( - 'wallet_data', - { - dataDirectory: `wallet_data_${l1Contracts.rollupAddress}`, - dataStoreMapSizeKb: pxeConfig.dataStoreMapSizeKb, - rollupAddress: l1Contracts.rollupAddress, - }, - 1, - rootLogger.createChild('wallet:data'), - )); + let walletDBStore = options.walletDb?.store; + if (!walletDBStore) { + if (options.ephemeral) { + walletDBStore = await openTmpStore(true); + } else { + const { l1ChainId, l1ContractAddresses } = await aztecNode.getNodeInfo(); + walletDBStore = await openBrowserStore( + 'wallet_data', + WALLET_DATA_SCHEMA_VERSION, + { l1ChainId, rollupAddress: l1ContractAddresses.rollupAddress }, + rootLogger.createChild('wallet:data'), + ); + } + } const walletDB = new WalletDB(walletDBStore, rootLogger.createChild('wallet:db').info); const wallet = new this(pxe, aztecNode, walletDB, new LazyAccountContractsProvider(), rootLogger) as T; diff --git a/yarn-project/wallets/src/embedded/entrypoints/node.ts b/yarn-project/wallets/src/embedded/entrypoints/node.ts index f7772599c5a8..46b04d6899f7 100644 --- a/yarn-project/wallets/src/embedded/entrypoints/node.ts +++ b/yarn-project/wallets/src/embedded/entrypoints/node.ts @@ -1,8 +1,8 @@ import { createAztecNodeClient } from '@aztec/aztec.js/node'; import { type Logger, createLogger } from '@aztec/foundation/log'; -import { createStore, openTmpStore } from '@aztec/kv-store/lmdb-v2'; +import { openTmpStore } from '@aztec/kv-store/lmdb-v2'; import { type PXEConfig, getPXEConfig } from '@aztec/pxe/config'; -import { type PXE, type PXECreationOptions, createPXE } from '@aztec/pxe/server'; +import { type PXE, type PXECreationOptions, createPXE, openStore } from '@aztec/pxe/server'; import { getStandardAuthRegistry } from '@aztec/standard-contracts/auth-registry'; import { getStandardHandshakeRegistry } from '@aztec/standard-contracts/handshake-registry'; import { getStandardMultiCallEntrypoint } from '@aztec/standard-contracts/multi-call-entrypoint'; @@ -11,7 +11,9 @@ import type { AztecNode } from '@aztec/stdlib/interfaces/client'; import { BundleAccountContractsProvider } from '../account-contract-providers/bundle.js'; import type { AccountContractsProvider } from '../account-contract-providers/types.js'; import { EmbeddedWallet, type EmbeddedWalletOptions, splitPxeOptions } from '../embedded_wallet.js'; -import { WalletDB } from '../wallet_db.js'; +import { WALLET_DATA_SCHEMA_VERSION, WalletDB } from '../wallet_db.js'; + +const DEFAULT_WALLET_DATA_DIRECTORY = 'aztec-wallet-data'; export class NodeEmbeddedWallet extends EmbeddedWallet { static async create( @@ -28,7 +30,6 @@ export class NodeEmbeddedWallet extends EmbeddedWallet { const rootLogger = options.logger ?? createLogger('embedded-wallet'); const aztecNode = typeof nodeOrUrl === 'string' ? createAztecNodeClient(nodeOrUrl) : nodeOrUrl; - const l1Contracts = await aztecNode.getL1ContractAddresses(); // Support both the new unified `pxe` option and the deprecated `pxeConfig`/`pxeOptions`. const { config: pxeConfigFromPxe, creation: pxeCreationFromPxe } = splitPxeOptions(options.pxe); @@ -37,7 +38,7 @@ export class NodeEmbeddedWallet extends EmbeddedWallet { const pxeConfig: PXEConfig = Object.assign(getPXEConfig(), { proverEnabled: mergedConfigOverrides.proverEnabled, - dataDirectory: `pxe_data_${l1Contracts.rollupAddress}`, + dataDirectory: DEFAULT_WALLET_DATA_DIRECTORY, autoSync: false, ...mergedConfigOverrides, }); @@ -65,26 +66,26 @@ export class NodeEmbeddedWallet extends EmbeddedWallet { const pxe = await createPXE(aztecNode, pxeConfig, pxeOptions); - const walletDBStore = - options.walletDb?.store ?? - (options.ephemeral - ? await openTmpStore( - `wallet_data_${l1Contracts.rollupAddress}`, - true, - undefined, - undefined, - rootLogger.createChild('wallet:data').getBindings(), - ) - : await createStore( - 'wallet_data', - 1, - { - dataDirectory: `wallet_data_${l1Contracts.rollupAddress}`, - dataStoreMapSizeKb: pxeConfig.dataStoreMapSizeKb, - rollupAddress: l1Contracts.rollupAddress, - }, - rootLogger.createChild('wallet:data').getBindings(), - )); + let walletDBStore = options.walletDb?.store; + if (!walletDBStore) { + const bindings = rootLogger.createChild('wallet:data').getBindings(); + if (options.ephemeral) { + walletDBStore = await openTmpStore('wallet_data', true, undefined, undefined, bindings); + } else { + const { l1ChainId, l1ContractAddresses } = await aztecNode.getNodeInfo(); + walletDBStore = await openStore( + 'wallet_data', + WALLET_DATA_SCHEMA_VERSION, + { + dataDirectory: pxeConfig.dataDirectory ?? DEFAULT_WALLET_DATA_DIRECTORY, + dataStoreMapSizeKb: pxeConfig.dataStoreMapSizeKb, + l1ChainId, + rollupAddress: l1ContractAddresses.rollupAddress, + }, + bindings, + ); + } + } const walletDB = new WalletDB(walletDBStore, rootLogger.createChild('wallet:db').info); const wallet = new this(pxe, aztecNode, walletDB, new BundleAccountContractsProvider(), rootLogger) as T; diff --git a/yarn-project/wallets/src/embedded/wallet_db.ts b/yarn-project/wallets/src/embedded/wallet_db.ts index 6671cdee0171..3feba81ab538 100644 --- a/yarn-project/wallets/src/embedded/wallet_db.ts +++ b/yarn-project/wallets/src/embedded/wallet_db.ts @@ -11,6 +11,9 @@ function accountKey(field: string, address: AztecAddress | string): string { return `${field}:${address.toString()}`; } +/** Bump when the WalletDB layout changes; a new version selects a fresh store, leaving the old one intact. */ +export const WALLET_DATA_SCHEMA_VERSION = 1; + export class WalletDB { private accounts: AztecAsyncMap; private aliases: AztecAsyncMap; From da9ac1c8835c696ae1693ea593e9264557224929 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Venturo?= Date: Fri, 10 Jul 2026 15:20:21 -0300 Subject: [PATCH 3/4] feat: assert non revertible phase when setting fee payer (#24479) The fee payer should only be set in the non-revertible phase, but we were not asserting this. --- .../docs/resources/migration_notes.md | 8 +++-- .../aztec/src/context/private_context.nr | 11 +++++++ .../src/automine/phase_check.parallel.test.ts | 30 +++++++++++++++++++ 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/docs/docs-developers/docs/resources/migration_notes.md b/docs/docs-developers/docs/resources/migration_notes.md index 81090922254b..0d7a518af63c 100644 --- a/docs/docs-developers/docs/resources/migration_notes.md +++ b/docs/docs-developers/docs/resources/migration_notes.md @@ -9,6 +9,12 @@ Aztec is in active development. Each version may introduce breaking changes that ## TBD +### [Aztec.nr] `set_as_fee_payer` now asserts it is called during the setup phase + +`PrivateContext::set_as_fee_payer` now asserts that execution is still in the setup (non-revertible) phase, i.e. that `end_setup` has not yet been called by any function in the transaction. Electing a fee payer in the revertible phase was never safe: compensation collected by the fee payer after `end_setup` can be discarded if a public call later reverts, while the protocol still debits the fee payer's fee-juice balance. + +**Impact**: A transaction in which `set_as_fee_payer` runs after the setup phase has ended now fails with `fee payer must be elected during the setup phase`. Standard fee payment flows, which call `set_as_fee_payer` before or together with `end_setup`, are unaffected. + ### [PXE] Stores are now selected by `(l1ChainId, rollupAddress, schemaVersion)` instead of being wiped on mismatch Previously, connecting a PXE or embedded wallet to a different or redeployed rollup, or bumping the store schema version, wiped the existing on-disk store in place. That meant master account keys could be destroyed simply by pointing a wallet at a different network. PXE data stores now exist per `(l1ChainId, rollupAddress, schemaVersion)` triple, and switching networks (or upgrading) selects or creates the matching store instead of overwriting previous ones. The embedded wallet's `wallet_data` store is partitioned the same way, so accounts and aliases are per network: switching networks starts with an empty account list until accounts are re-imported, and switching back finds the originals intact. @@ -140,8 +146,6 @@ Registering classes and instances are now separate, unvalidated operations. `reg The new class is used automatically once the upgrade takes effect on chain; no further PXE action is needed. Registering it beforehand is harmless: until the update activates, the node still resolves the contract's current class to the previous one, so it keeps running its old code. - `pxe.getContractInstance(address)` and `wallet.getContractMetadata(address).instance` now return the contract's **address preimage**, which no longer includes `currentContractClassId`. - - ### [Aztec.js] `AccountWithSecretKey` removed, read account keys from the `AccountManager` or PXE `AccountWithSecretKey` was a thin wrapper that bundled an account's transaction signer with its master secret key, used mainly to print or export the secret. It has been removed, and `AccountManager.getAccount()` now returns the plain `Account` signer. The wrapper's extra methods are no longer available on that value: diff --git a/noir-projects/aztec-nr/aztec/src/context/private_context.nr b/noir-projects/aztec-nr/aztec/src/context/private_context.nr index d402973e2b6d..884e9eb54d32 100644 --- a/noir-projects/aztec-nr/aztec/src/context/private_context.nr +++ b/noir-projects/aztec-nr/aztec/src/context/private_context.nr @@ -537,11 +537,22 @@ impl PrivateContext { /// Only one contract per transaction can declare itself as the fee payer, and it must have sufficient fee-juice /// balance (>= the gas limits specified in the TxContext) by the time we reach the public setup phase of the tx. /// + /// The fee payer must be elected during the setup (non-revertible) phase, i.e. before + /// [`end_setup`](PrivateContext::end_setup) is called - this function asserts so. This is because any compensation + /// collected by the fee payer during the revertible phase can be discarded if a public call later reverts, while + /// the protocol still debits the fee payer's fee-juice balance. Note that `end_setup` does not need to be called + /// by the electing function itself: it can be called later in the transaction (e.g. by the fee-juice contract when + /// claiming fee juice that pays for the very same transaction). pub fn set_as_fee_payer(&mut self) { + assert(!self.in_revertible_phase(), "fee payer must be elected during the setup phase"); aztecnr_trace_log_format!("Setting {0} as fee payer")([self.this_address().to_field()]); self.is_fee_payer = true; } + /// Returns whether execution is currently in the revertible (app) phase of the transaction. + /// + /// A transaction is in the revertible phase if [`end_setup`](PrivateContext::end_setup) has already been called - + /// potentially by a different function of the same transaction. pub fn in_revertible_phase(&mut self) -> bool { let current_counter = self.side_effect_counter; diff --git a/yarn-project/end-to-end/src/automine/phase_check.parallel.test.ts b/yarn-project/end-to-end/src/automine/phase_check.parallel.test.ts index 36904d001078..d33eda729a71 100644 --- a/yarn-project/end-to-end/src/automine/phase_check.parallel.test.ts +++ b/yarn-project/end-to-end/src/automine/phase_check.parallel.test.ts @@ -1,4 +1,5 @@ import { AztecAddress } from '@aztec/aztec.js/addresses'; +import { BatchCall } from '@aztec/aztec.js/contracts'; import { SponsoredFeePaymentMethod } from '@aztec/aztec.js/fee'; import { SPONSORED_FPC_SALT } from '@aztec/constants'; import { Fr } from '@aztec/foundation/curves/bn254'; @@ -7,11 +8,23 @@ import { TestContract } from '@aztec/noir-test-contracts.js/Test'; import { computeFeePayerBalanceLeafSlot } from '@aztec/protocol-contracts/fee-juice'; import { getContractInstanceFromInstantiationParams } from '@aztec/stdlib/contract'; import { PublicDataTreeLeaf } from '@aztec/stdlib/trees'; +import { ExecutionPayload } from '@aztec/stdlib/tx'; import { defaultInitialAccountFeeJuice } from '@aztec/world-state/testing'; import type { TestWallet } from '../test-wallet/test_wallet.js'; import { AutomineTestContext } from './automine_test_context.js'; +/** + * Declares a contract as the tx fee payer without contributing any call to the fee payload, unlike + * SponsoredFeePaymentMethod which prepends the sponsor call (and thus makes the election run before any app call). + * This lets a test place the electing call anywhere in the app payload, e.g. after the setup phase has ended. + */ +class DeferredSponsoredFeePaymentMethod extends SponsoredFeePaymentMethod { + override async getExecutionPayload(): Promise { + return new ExecutionPayload([], [], [], [], await this.getFeePayer()); + } +} + // Private functions should receive automatically a phase check that avoids any nested call changing the phase. // Functions that opt out of this phase check can be marked with #[allow_phase_change]. // @@ -78,4 +91,21 @@ describe('automine/phase_check', () => { }, }); }); + + it('should fail when the fee payer is elected after the setup phase has ended', async () => { + // BatchCall.simulate ignores the fee payment method, which would make the wallet fall back to + // PREEXISTING_FEE_JUICE and end setup in the account entrypoint before any app call runs. Build the payload via + // request() instead, which merges the payment method's payload (and thus its fee payer), and simulate it directly. + const lateElection = await new BatchCall(wallet, [ + contract.methods.call_function_that_ends_setup_without_phase_check(), + sponsoredFPC.methods.sponsor_unconditionally(), + ]).request({ + fee: { + paymentMethod: new DeferredSponsoredFeePaymentMethod(sponsoredFPC.address), + }, + }); + await expect(wallet.simulateTx(lateElection, { from: defaultAccountAddress })).rejects.toThrow( + 'fee payer must be elected during the setup phase', + ); + }); }); From af3323bf4a5813cd281a835bbd3238d818f44706 Mon Sep 17 00:00:00 2001 From: Martin Verzilli Date: Sat, 11 Jul 2026 13:19:16 +0200 Subject: [PATCH 4/4] fix: release OPFS handles before sqlite worker close/delete ack (#24647) Fixes a flake on the SQLite db management browser tests. --- .../kv-store/scripts/run-browser-tests.sh | 4 +++- .../src/sqlite-opfs/store_management.test.ts | 12 ++++++++++++ .../kv-store/src/sqlite-opfs/worker.ts | 18 ++++++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/yarn-project/kv-store/scripts/run-browser-tests.sh b/yarn-project/kv-store/scripts/run-browser-tests.sh index d5cf19fbc98d..5281922d5e50 100755 --- a/yarn-project/kv-store/scripts/run-browser-tests.sh +++ b/yarn-project/kv-store/scripts/run-browser-tests.sh @@ -15,7 +15,9 @@ set -euo pipefail cd "$(dirname "$0")/.." -files=$(find src/deprecated/indexeddb src/sqlite-opfs -name '*.test.ts' 2>/dev/null | sort) +# -type f: vitest failure screenshots land in __screenshots__//, creating +# directories whose names match the *.test.ts glob. +files=$(find src/deprecated/indexeddb src/sqlite-opfs -type f -name '*.test.ts' 2>/dev/null | sort) if [ -z "$files" ]; then echo "No test files found in src/deprecated/indexeddb or src/sqlite-opfs" diff --git a/yarn-project/kv-store/src/sqlite-opfs/store_management.test.ts b/yarn-project/kv-store/src/sqlite-opfs/store_management.test.ts index 4a99674e9b38..3bb4f6a71e7e 100644 --- a/yarn-project/kv-store/src/sqlite-opfs/store_management.test.ts +++ b/yarn-project/kv-store/src/sqlite-opfs/store_management.test.ts @@ -47,6 +47,18 @@ describe('sqlite-opfs store management', () => { await deleteStore('mech_managed'); }); + // Regression test: close() must not resolve until the worker has released the SAH pool's OPFS + // handles, otherwise deleteStore races Chromium's async reclaim of the terminated worker and + // intermittently throws NoModificationAllowedError. Looped to amplify the race window. + it('deletes a store immediately after close, repeatedly', async () => { + for (let i = 0; i < 20; i++) { + const store = await openByName('mech_close_release'); + await store.openSingleton('k').set(`v${i}`); + await store.close(); + await deleteStore('mech_close_release'); + } + }); + it('refuses to delete a store that is currently open', async () => { const store = await openByName('mech_locked'); await expect(deleteStore('mech_locked')).rejects.toThrow(); diff --git a/yarn-project/kv-store/src/sqlite-opfs/worker.ts b/yarn-project/kv-store/src/sqlite-opfs/worker.ts index 81b0a068cf0b..275885b59971 100644 --- a/yarn-project/kv-store/src/sqlite-opfs/worker.ts +++ b/yarn-project/kv-store/src/sqlite-opfs/worker.ts @@ -94,6 +94,20 @@ function handleClose(): void { db?.close(); db = undefined; dbPath = undefined; + releasePool(); +} + +/** + * Releases the SAH pool's OPFS sync access handles before the terminal RPC is acked. Worker + * termination releases them only asynchronously, so without this a caller that deletes or reopens + * the store directory right after close()/delete() resolves races Chromium's cleanup + * (NoModificationAllowedError from removeEntry, or a hang installing a new pool on the directory). + * pauseVfs releases the handles without touching file contents; the worker is terminated right + * after, so the pool is never resumed. + */ +function releasePool(): void { + pool?.pauseVfs(); + pool = undefined; } async function handleExport(): Promise { @@ -126,6 +140,10 @@ function handleDeleteDb(dbName: string): void { } catch { // File may not exist; ignore. } + // Guarded because pauseVfs refuses (SQLITE_MISUSE) while any file is open through the VFS. + if (!db) { + releasePool(); + } } function requireDb(): Database {