Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
6bad978
docs: design for PXE store selection by identity
mverzilli Jul 9, 2026
7c3c29d
docs: implementation plan for PXE store selection by identity
mverzilli Jul 9, 2026
071b0af
feat(kv-store): store identity slug helper and DataStoreConfig.l1ChainId
mverzilli Jul 9, 2026
f8076f4
fix(stdlib): honor 'throw' mismatch policy on rollup address change
mverzilli Jul 9, 2026
4634961
feat(kv-store): sqlite-opfs stores selected by (chain, rollup, schema…
mverzilli Jul 9, 2026
38e6382
feat(kv-store): list and delete sqlite-opfs stores
mverzilli Jul 9, 2026
d3b2535
feat(kv-store): opt-in identity-partitioned lmdb-v2 stores
mverzilli Jul 9, 2026
6509dd1
feat(pxe): node PXE and embedded wallet stores partitioned by identity
mverzilli Jul 9, 2026
4e0036d
feat(pxe): browser PXE and embedded wallet stores partitioned by iden…
mverzilli Jul 9, 2026
31093a9
docs: changelog for identity-partitioned PXE stores
mverzilli Jul 9, 2026
0bdd2e4
fix(stdlib): separate rollup-address mismatch policy and fix zero-add…
mverzilli Jul 9, 2026
d4b69f1
docs: align store-selection docs with rollup-policy split and sibling…
mverzilli Jul 9, 2026
1551290
docs: mark implementation plan as historical after final-review amend…
mverzilli Jul 9, 2026
0452dbe
chore: remove local planning docs from version control
mverzilli Jul 9, 2026
f7dae32
feat(pxe): identity-partitioned store helper for node-side stores
mverzilli Jul 9, 2026
4935622
refactor(pxe): open pxe_data and wallet_data via the identity store h…
mverzilli Jul 9, 2026
d39c985
refactor(kv-store): contain store-identity logic to sqlite-opfs and p…
mverzilli Jul 9, 2026
d5c96d9
docs: cover embedded-wallet data locations in store-selection migrati…
mverzilli Jul 9, 2026
d4f8bdb
refactor(pxe): polish identity-store helper logging, exports, and wal…
mverzilli Jul 9, 2026
6864ad0
fix(kv-store): throw on incomplete store identity instead of defaulti…
mverzilli Jul 9, 2026
3b3e56b
fix(pxe): require a full identity in openStoreForIdentity
mverzilli Jul 9, 2026
5a637ce
fix(wallets): make wallet_data chain-agnostic with a schema-only iden…
mverzilli Jul 9, 2026
17f6a9d
docs: wallet_data is chain-agnostic in the store-selection migration …
mverzilli Jul 9, 2026
7a5d236
refactor(wallets): drop unused node-info destructure in browser entry…
mverzilli Jul 9, 2026
b4fe53c
test(kv-store): cover missing rollup address in identity throw; note …
mverzilli Jul 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/docs-developers/docs/resources/migration_notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,19 @@ Aztec is in active development. Each version may introduce breaking changes that

## TBD

### [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 (`pxe_data`) now exist per `(l1ChainId, rollupAddress, schemaVersion)` identity, and switching networks (or upgrading) selects the matching store instead of overwriting the previous one. The embedded wallet's `wallet_data` store takes a different approach: since its contents (account secrets and aliases) are not network-specific, it is now chain-agnostic: a single schema-versioned store (`wallet_data_v1`) shared across networks, so stored accounts and aliases survive switching networks.

**Impact**: The first start after upgrading to this version begins with a fresh, empty store; the pre-upgrade data is not deleted. In the browser (sqlite-opfs) the old store stays in OPFS but is not visible to the new `listStores()` utility; on node (lmdb-v2) pre-upgrade data stays at `<dataDirectory>/<name>` while new per-identity `pxe_data` stores live under `<dataDirectory>/<name>-stores/`. That covers the server PXE and the CLI wallet; the embedded node wallet previously stored data in cwd-relative, rollup-address-suffixed directories instead (`pxe_data_<rollupAddress>/pxe_data` for the PXE store, `wallet_data_<rollupAddress>/wallet_data` for the wallet store): if you used it before this release, that is where your old data lives. The embedded wallet now defaults its data root to `aztec-wallet-data/`, with the chain-agnostic wallet store at `<dataDirectory>/wallet_data_v1` on node and under the fixed name `wallet_data_v1` in browser OPFS. Browser apps can enumerate and clean up `pxe_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]); // the store must be closed first

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
await deleteStore(names[0]); // the store must be closed first
await deleteStore(names[0]);

```

### [Aztec.nr] `TestEnvironmentOptions::with_tagging_secret_strategy` replaced

`TestEnvironmentOptions::with_tagging_secret_strategy` is now `with_default_tag_secret_strategy_all_modes` for tests
Expand Down
2 changes: 2 additions & 0 deletions yarn-project/kv-store/src/lmdb-v2/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
export * from './store.js';
export * from './factory.js';
export { storeIdentitySlug } from '../store_identity.js';
export type { StoreIdentity } from '../store_identity.js';
136 changes: 136 additions & 0 deletions yarn-project/kv-store/src/sqlite-opfs/create_store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { EthAddress } from '@aztec/foundation/eth-address';

import { mockLogger } from '../interfaces/utils.js';
import { AztecSQLiteOPFSStore, StoreIdentityMismatchError, createStore, effectiveStoreName } from './index.js';
import { deleteStore, listStores, storePoolDirectory } from './manage.js';

const configFor = (rollupAddress: EthAddress, l1ChainId = 31337) => ({
dataDirectory: 'test',
dataStoreMapSizeKb: 1024,
rollupAddress,
l1ChainId,
});

describe('sqlite-opfs createStore', () => {
it('rejects when a required identity component is missing', async () => {
const addr = EthAddress.random();
const { l1ChainId: _l1ChainId, ...configWithoutChainId } = configFor(addr);
const { rollupAddress: _rollupAddress, ...configWithoutRollup } = configFor(addr);

await expect(createStore('incomplete_test', configWithoutChainId, 1, mockLogger)).rejects.toThrow(
/without a complete identity/,
);
await expect(createStore('incomplete_test', configWithoutRollup, 1, mockLogger)).rejects.toThrow(
/without a complete identity/,
);
await expect(createStore('incomplete_test', configFor(addr), undefined, mockLogger)).rejects.toThrow(
/without a complete identity/,
);

const storeName = effectiveStoreName('incomplete_test', {
l1ChainId: 31337,
rollupAddress: addr,
schemaVersion: 1,
});
expect(await listStores()).not.toContain(storeName);
});

it('keeps data intact when switching rollup addresses back and forth', async () => {
const addrA = EthAddress.random();
const addrB = EthAddress.random();

const storeA = await createStore('roundtrip_test', configFor(addrA), 1, mockLogger);
await storeA.openSingleton<string>('payload').set('data-for-A');
await storeA.close();

const storeB = await createStore('roundtrip_test', configFor(addrB), 1, mockLogger);
expect(await storeB.openSingleton<string>('payload').getAsync()).toBeUndefined();
await storeB.openSingleton<string>('payload').set('data-for-B');
await storeB.close();

const reopenedA = await createStore('roundtrip_test', configFor(addrA), 1, mockLogger);
expect(await reopenedA.openSingleton<string>('payload').getAsync()).toEqual('data-for-A');
await reopenedA.close();

const reopenedB = await createStore('roundtrip_test', configFor(addrB), 1, mockLogger);
expect(await reopenedB.openSingleton<string>('payload').getAsync()).toEqual('data-for-B');
await reopenedB.close();
});

it('opens two different stores concurrently in the same tab', async () => {
const addr = EthAddress.random();
const pxeStore = await createStore('pxe_data', configFor(addr), 1, mockLogger);
const walletStore = await createStore('wallet_data', configFor(addr), 1, mockLogger);

await pxeStore.openSingleton<string>('k').set('pxe');
await walletStore.openSingleton<string>('k').set('wallet');
expect(await pxeStore.openSingleton<string>('k').getAsync()).toEqual('pxe');
expect(await walletStore.openSingleton<string>('k').getAsync()).toEqual('wallet');

await pxeStore.close();
await walletStore.close();
});

it('separates stores by schema version', async () => {
const addr = EthAddress.random();
const v1 = await createStore('schema_test', configFor(addr), 1, mockLogger);
await v1.openSingleton<string>('k').set('v1-data');
await v1.close();

const v2 = await createStore('schema_test', configFor(addr), 2, mockLogger);
expect(await v2.openSingleton<string>('k').getAsync()).toBeUndefined();
await v2.close();

const v1Again = await createStore('schema_test', configFor(addr), 1, mockLogger);
expect(await v1Again.openSingleton<string>('k').getAsync()).toEqual('v1-data');
await v1Again.close();
});

it('refuses to open on a recorded-identity mismatch and leaves data untouched', async () => {
const addr = EthAddress.random();
const store = await createStore('mismatch_test', configFor(addr), 1, mockLogger);
await store.openSingleton<string>('payload').set('precious');
// Simulate a naming bug by corrupting the recorded identity.
await store.openSingleton<string>('dbVersion').set('garbage');
await store.close();

await expect(createStore('mismatch_test', configFor(addr), 1, mockLogger)).rejects.toThrow(
StoreIdentityMismatchError,
);

// The refusal must not have modified the store: read it raw, bypassing the identity check.
const storeName = effectiveStoreName('mismatch_test', { l1ChainId: 31337, rollupAddress: addr, schemaVersion: 1 });
const raw = await AztecSQLiteOPFSStore.open(mockLogger, storeName, false, storePoolDirectory(storeName));
expect(await raw.openSingleton<string>('payload').getAsync()).toEqual('precious');
await raw.close();
});

it('lists created stores and deletes them', async () => {
const addr = EthAddress.random();
const store = await createStore('managed_test', configFor(addr), 1, mockLogger);
await store.openSingleton<string>('k').set('v');
await store.close();

const storeName = effectiveStoreName('managed_test', { l1ChainId: 31337, rollupAddress: addr, schemaVersion: 1 });
expect(await listStores()).toContain(storeName);

await deleteStore(storeName);
expect(await listStores()).not.toContain(storeName);

// Recreating after deletion starts empty.
const fresh = await createStore('managed_test', configFor(addr), 1, mockLogger);
expect(await fresh.openSingleton<string>('k').getAsync()).toBeUndefined();
await fresh.close();
});

it('refuses to delete a store that is currently open', async () => {
const addr = EthAddress.random();
const store = await createStore('locked_test', configFor(addr), 1, mockLogger);
const storeName = effectiveStoreName('locked_test', { l1ChainId: 31337, rollupAddress: addr, schemaVersion: 1 });

await expect(deleteStore(storeName)).rejects.toThrow();

await store.close();
await deleteStore(storeName);
});
});
78 changes: 69 additions & 9 deletions yarn-project/kv-store/src/sqlite-opfs/index.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,87 @@
import type { EthAddress } from '@aztec/foundation/eth-address';
import { type Logger, createLogger } from '@aztec/foundation/log';
import { DatabaseVersion } from '@aztec/stdlib/database-version/version';
import type { DataStoreConfig } from '@aztec/stdlib/kv-store';

import { initStoreForRollupAndSchemaVersion } from '../utils.js';
import { StoreIdentityMismatchError, effectiveStoreName } from '../store_identity.js';
import { storePoolDirectory } from './manage.js';
import { AztecSQLiteOPFSStore } from './store.js';

export { AztecSQLiteOPFSStore } from './store.js';
export { SqliteEncryptionError } from './errors.js';
export type { SqliteEncryptionErrorCode } from './errors.js';
export { OPFS_POOL_DIR_PREFIX, deleteStore, listStores, storePoolDirectory } from './manage.js';
export { StoreIdentityMismatchError, effectiveStoreName, storeIdentitySlug } from '../store_identity.js';
export type { StoreIdentity } from '../store_identity.js';

/**
* Opens the persistent store selected by `name` and the identity `(config.l1ChainId, config.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. Nothing is ever cleared.
*
* @throws If `schemaVersion`, `config.rollupAddress`, or `config.l1ChainId` is missing — an incomplete identity
* must never silently default to the zero identity.
*/
export async function createStore(
name: string,
config: DataStoreConfig,
config: DataStoreConfig & { l1ChainId?: number },
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 { rollupAddress, l1ChainId } = config;
if (schemaVersion === undefined || rollupAddress === undefined || l1ChainId === undefined) {
const missing = [
l1ChainId === undefined && 'l1ChainId',
rollupAddress === undefined && 'rollupAddress',
schemaVersion === undefined && 'schemaVersion',
].filter((component): component is string => component !== false);
throw new Error(`Cannot open store '${name}' without a complete identity: missing ${missing.join(', ')}`);
}

const storeName = effectiveStoreName(name, { l1ChainId, rollupAddress, schemaVersion });
log.info(`Creating ${storeName} SQLite-OPFS data store with map size ${config.dataStoreMapSizeKb} KB`);
const store = await AztecSQLiteOPFSStore.open(
createLogger('kv-store:sqlite-opfs'),
storeName,
false,
storePoolDirectory(storeName),
);
const store = await AztecSQLiteOPFSStore.open(createLogger('kv-store:sqlite-opfs'), name, false);
return initStoreForRollupAndSchemaVersion(store, schemaVersion, config.rollupAddress, log);
try {
await assertStoreIdentity(store, storeName, schemaVersion, rollupAddress);
} 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;
}

/**
* Belt-and-braces invariant check: the identity is part of the physical store name, so the recorded version
* can only disagree if there is a store-naming bug. Refuses to open on mismatch; never clears.
Comment on lines +60 to +61

@mverzilli mverzilli Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* Belt-and-braces invariant check: the identity is part of the physical store name, so the recorded version
* can only disagree if there is a store-naming bug. Refuses to open on mismatch; never clears.
* Invariant check: the identity is part of the physical store name, so the recorded version can only
* disagree if there is a store-naming bug. Refuses to open on mismatch.

*/
async function assertStoreIdentity(
store: AztecSQLiteOPFSStore,
storeName: string,
schemaVersion: number,
rollupAddress: EthAddress,
): Promise<void> {
const expected = new DatabaseVersion(schemaVersion, rollupAddress);
const singleton = store.openSingleton<string>('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());
}
}

export function openTmpStore(ephemeral: boolean = false): Promise<AztecSQLiteOPFSStore> {
Expand Down
36 changes: 36 additions & 0 deletions yarn-project/kv-store/src/sqlite-opfs/manage.ts
Original file line number Diff line number Diff line change
@@ -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 effective names (logical name + identity slug) of every persistent sqlite-opfs store in this
* origin, by enumerating the per-store pool directories. Includes stores created under identities other than
* the current one — that is the point: wallets can surface and clean up data for networks no longer in use.
*/
export async function listStores(): Promise<string[]> {
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<void> {
const root = await navigator.storage.getDirectory();
await root.removeEntry(storePoolDirectory(effectiveName), { recursive: true });
}
34 changes: 34 additions & 0 deletions yarn-project/kv-store/src/store_identity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { EthAddress } from '@aztec/foundation/eth-address';

import { 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('composes the exact slug format for the zero identity', () => {
expect(storeIdentitySlug({ l1ChainId: 0, rollupAddress: EthAddress.ZERO, schemaVersion: 0 })).toEqual(
'0-0x0000000000000000000000000000000000000000-v0',
);
});

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',
);
});
});
42 changes: 42 additions & 0 deletions yarn-project/kv-store/src/store_identity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { EthAddress } from '@aztec/foundation/eth-address';

/** The coordinates 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: `<l1ChainId>-<rollupAddress>-v<schemaVersion>`.
*/
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)}`;
}

/**
* 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';
}
}
3 changes: 2 additions & 1 deletion yarn-project/kv-store/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
"rootDir": "src",
"tsBuildInfoFile": ".tsbuildinfo",
"allowJs": true,
"checkJs": false
"checkJs": false,
"lib": ["dom", "dom.asynciterable", "esnext", "es2017.object"]
},
"references": [
{
Expand Down
1 change: 1 addition & 0 deletions yarn-project/kv-store/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export default defineConfig({
name: 'node',
environment: 'node',
include: [
'./src/*.test.ts',
'./src/lmdb/**/*.test.ts',
'./src/lmdb-v2/**/*.test.ts',
'./src/stores/**/*.test.ts',
Expand Down
Loading