Skip to content

Commit 6619403

Browse files
committed
Merge remote-tracking branch 'origin/merge-train/spartan-v5' into phil/a-1418-prover-node-make-epoch-proving-robust-to-prune-induced-fork
2 parents ca712e1 + e4d8b3a commit 6619403

8 files changed

Lines changed: 187 additions & 17 deletions

File tree

docs/examples/ts/aztecjs_runner/run.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ setup_project() {
115115
[ ${#NPM_DEPS[@]} -gt 0 ] && yarn_add_with_retry "${NPM_DEPS[@]}"
116116
fi
117117

118-
yarn_add_with_retry -D typescript tsx
118+
yarn_add_with_retry -D typescript@^5.3.3 tsx
119119

120120
# Copy tsconfig
121121
cp "$EXAMPLES_DIR/tsconfig.template.json" tsconfig.json
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { isCorruptionMessage, isDecryptFailureMessage } from './errors.js';
4+
5+
/**
6+
* The two classifiers must stay disjoint: a decrypt failure (wrong/missing key)
7+
* is recoverable by re-keying, whereas a corrupt image is not. Mixing them would
8+
* make the worker mis-tag failures and consumers take the wrong recovery path.
9+
*/
10+
describe('sqlite-opfs error classification', () => {
11+
it('classifies SQLITE_CORRUPT / malformed-image messages as corruption', () => {
12+
expect(isCorruptionMessage('SQLITE_CORRUPT: sqlite3 result code 11: database disk image is malformed')).toBe(true);
13+
expect(isCorruptionMessage('database disk image is malformed')).toBe(true);
14+
});
15+
16+
it('does not classify decrypt-failure messages as corruption', () => {
17+
expect(isCorruptionMessage('file is not a database')).toBe(false);
18+
expect(isCorruptionMessage('file is encrypted or is not a database')).toBe(false);
19+
});
20+
21+
it('classifies sqlite3mc decrypt-failure messages as decrypt failures', () => {
22+
expect(isDecryptFailureMessage('file is not a database')).toBe(true);
23+
expect(isDecryptFailureMessage('file is encrypted or is not a database')).toBe(true);
24+
});
25+
26+
it('does not classify corruption messages as decrypt failures', () => {
27+
expect(isDecryptFailureMessage('database disk image is malformed')).toBe(false);
28+
expect(isDecryptFailureMessage('SQLITE_CORRUPT: sqlite3 result code 11')).toBe(false);
29+
});
30+
});

yarn-project/kv-store/src/sqlite-opfs/errors.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,37 @@ const SQLITE3MC_DECRYPT_ERROR_PATTERNS: readonly RegExp[] = [
4242
export function isDecryptFailureMessage(message: string): boolean {
4343
return SQLITE3MC_DECRYPT_ERROR_PATTERNS.some(p => p.test(message));
4444
}
45+
46+
/**
47+
* Error thrown by sqlite-opfs when the on-disk database image is corrupt
48+
* (`SQLITE_CORRUPT`, result code 11 — "database disk image is malformed").
49+
*
50+
* Distinct from {@link SqliteEncryptionError}: no key recovers a corrupt image,
51+
* so there is nothing to retry. The only escape is to delete the store and start
52+
* fresh, so consumers pattern-match on `instanceof SqliteCorruptionError` to wipe
53+
* and reopen rather than surfacing a dead-end error.
54+
**/
55+
export class SqliteCorruptionError extends Error {
56+
constructor(message: string, opts?: { cause?: unknown }) {
57+
super(message, opts?.cause !== undefined ? { cause: opts.cause } : undefined);
58+
this.name = 'SqliteCorruptionError';
59+
}
60+
}
61+
62+
/**
63+
* Strings/codes raised by SQLite when a database image is corrupt. Kept disjoint
64+
* from {@link SQLITE3MC_DECRYPT_ERROR_PATTERNS}: "file is not a database"
65+
* (SQLITE_NOTADB) is the decrypt signal, whereas a malformed image is the
66+
* genuinely-unrecoverable SQLITE_CORRUPT.
67+
**/
68+
const SQLITE_CORRUPTION_ERROR_PATTERNS: readonly RegExp[] = [
69+
/database disk image is malformed/i,
70+
/\bSQLITE_CORRUPT\b/i,
71+
];
72+
73+
/**
74+
* Returns `true` if `message` matches one of the known SQLite corruption strings.
75+
**/
76+
export function isCorruptionMessage(message: string): boolean {
77+
return SQLITE_CORRUPTION_ERROR_PATTERNS.some(p => p.test(message));
78+
}

yarn-project/kv-store/src/sqlite-opfs/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { createLogger } from '@aztec/foundation/log';
33
import { AztecSQLiteOPFSStore } from './store.js';
44

55
export { AztecSQLiteOPFSStore } from './store.js';
6-
export { SqliteEncryptionError } from './errors.js';
6+
export { SqliteCorruptionError, SqliteEncryptionError } from './errors.js';
77
export type { SqliteEncryptionErrorCode } from './errors.js';
88
export { OPFS_POOL_DIR_PREFIX, deleteStore, listStores, storePoolDirectory } from './manage.js';
99

yarn-project/kv-store/src/sqlite-opfs/store.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import type { AztecAsyncSet } from '../interfaces/set.js';
1010
import type { AztecAsyncSingleton } from '../interfaces/singleton.js';
1111
import type { AztecAsyncKVStore } from '../interfaces/store.js';
1212
import { SQLiteOPFSAztecArray } from './array.js';
13-
import { SqliteEncryptionError } from './errors.js';
13+
import { SqliteCorruptionError, SqliteEncryptionError, isCorruptionMessage } from './errors.js';
1414
import { SQLiteOPFSAztecMap } from './map.js';
1515
import type { ResultRow, SqlValue, WorkerRequest, WorkerResponse } from './messages.js';
1616
import { SQLiteOPFSAztecMultiMap } from './multi_map.js';
@@ -272,11 +272,15 @@ export class AztecSQLiteOPFSStore implements AztecAsyncKVStore {
272272
this.#pending.set(req.id, {
273273
resolve: resp => {
274274
if (resp.type === 'err') {
275-
// Re-hydrate encryption-shaped errors as the typed class so consumers
276-
// can pattern-match on `instanceof SqliteEncryptionError`. Plain
277-
// errors stay plain — the wire protocol only tags encryption paths.
275+
// Re-hydrate typed errors so consumers can pattern-match on
276+
// `instanceof`. Encryption is tagged on the wire (some cases are
277+
// pre-flight throws with no message to match); corruption is a
278+
// single unambiguous message, so we classify it here rather than
279+
// adding a redundant wire field. Everything else stays a plain Error.
278280
if (resp.encryptionCode !== undefined) {
279281
reject(new SqliteEncryptionError(resp.encryptionCode, resp.message));
282+
} else if (isCorruptionMessage(resp.message)) {
283+
reject(new SqliteCorruptionError(resp.message));
280284
} else {
281285
reject(new Error(resp.message));
282286
}

yarn-project/pxe/src/entrypoints/client/store.ts

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import type { EthAddress } from '@aztec/foundation/eth-address';
22
import { type Logger, createLogger } from '@aztec/foundation/log';
3-
import { AztecSQLiteOPFSStore, storePoolDirectory } from '@aztec/kv-store/sqlite-opfs';
3+
import {
4+
AztecSQLiteOPFSStore,
5+
SqliteCorruptionError,
6+
deleteStore,
7+
storePoolDirectory,
8+
} from '@aztec/kv-store/sqlite-opfs';
49

510
import { assertStoreIdentity, effectiveStoreName } from '../../storage/store_identity.js';
611

@@ -21,12 +26,23 @@ export async function openBrowserStore(
2126
storeName,
2227
dataStoreMapSizeKb: config.dataStoreMapSizeKb,
2328
});
24-
const store = await AztecSQLiteOPFSStore.open(
25-
createLogger('kv-store:sqlite-opfs'),
26-
storeName,
27-
false,
28-
storePoolDirectory(storeName),
29-
);
29+
const openStore = () =>
30+
AztecSQLiteOPFSStore.open(createLogger('kv-store:sqlite-opfs'), storeName, false, storePoolDirectory(storeName));
31+
32+
let store: AztecSQLiteOPFSStore;
33+
try {
34+
store = await openStore();
35+
} catch (err) {
36+
if (!(err instanceof SqliteCorruptionError)) {
37+
throw err;
38+
}
39+
// A corrupt image is unrecoverable — no retry against the same bytes helps. Wipe the store's OPFS directory
40+
// (safe: the failed open left no SAH-pool lock behind) and reopen a fresh, empty one, so the browser self-heals
41+
// into a normal first-run rather than dead-ending on "database disk image is malformed" every load.
42+
log.warn(`${storeName} store is corrupt (${err.message}); wiping and reopening fresh`);
43+
await deleteStore(storeName);
44+
store = await openStore();
45+
}
3046
try {
3147
await assertStoreIdentity(store, storeName, identity);
3248
} catch (err) {

yarn-project/wallets/src/embedded/store_encryption.test.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,12 @@
66
*/
77
import type { Logger } from '@aztec/foundation/log';
88
import type { AztecSQLiteOPFSStore } from '@aztec/kv-store/sqlite-opfs';
9-
import { SqliteEncryptionError } from '@aztec/kv-store/sqlite-opfs';
9+
import { SqliteCorruptionError, SqliteEncryptionError } from '@aztec/kv-store/sqlite-opfs';
1010

1111
import {
1212
EmbeddedWalletEncryptionError,
1313
type OpenSqliteEncryptedStoreFn,
14+
type WipeSqliteStoreFn,
1415
openEncryptedEmbeddedStores,
1516
} from './store_encryption.js';
1617

@@ -189,4 +190,54 @@ describe('openEncryptedEmbeddedStores', () => {
189190

190191
expect(keyProvider.callCount).toBe(2);
191192
});
193+
194+
it('wipes the corrupt store and reopens it fresh', async () => {
195+
const pxe = makeMockStore();
196+
const wallet = makeMockStore();
197+
const wiped: (string | undefined)[] = [];
198+
const wipeStore: WipeSqliteStoreFn = poolDirectory => {
199+
wiped.push(poolDirectory);
200+
return Promise.resolve();
201+
};
202+
let pxeOpens = 0;
203+
const openStore: OpenSqliteEncryptedStoreFn = (_log, name) => {
204+
if (name === 'pxe_data') {
205+
pxeOpens++;
206+
// First open hits a malformed image; the reopen (after wipe) succeeds.
207+
return pxeOpens === 1
208+
? Promise.reject(new SqliteCorruptionError('database disk image is malformed'))
209+
: Promise.resolve(pxe.store);
210+
}
211+
return Promise.resolve(wallet.store);
212+
};
213+
214+
const result = await openEncryptedEmbeddedStores(
215+
config,
216+
makeKeyProvider().getKey,
217+
noopLogger,
218+
openStore,
219+
wipeStore,
220+
);
221+
222+
expect(result.pxeStore).toBe(pxe.store);
223+
expect(result.walletStore).toBe(wallet.store);
224+
// Only the corrupt store's own directory is wiped, and the store is reopened once.
225+
expect(wiped).toEqual(['/pxe']);
226+
expect(pxeOpens).toBe(2);
227+
});
228+
229+
it('rethrows corruption if the store is still corrupt after a wipe (no retry loop)', async () => {
230+
const stillCorrupt = new SqliteCorruptionError('database disk image is malformed');
231+
const openStore: OpenSqliteEncryptedStoreFn = () => Promise.reject(stillCorrupt);
232+
const wipeStore: WipeSqliteStoreFn = () => Promise.resolve();
233+
234+
let caught: unknown;
235+
try {
236+
await openEncryptedEmbeddedStores(config, makeKeyProvider().getKey, noopLogger, openStore, wipeStore);
237+
} catch (err) {
238+
caught = err;
239+
}
240+
241+
expect(caught).toBe(stillCorrupt);
242+
});
192243
});

yarn-project/wallets/src/embedded/store_encryption.ts

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
* surfaces, so callers don't leak the SAH Pool's OPFS lock.
99
*/
1010
import type { Logger } from '@aztec/foundation/log';
11-
import { AztecSQLiteOPFSStore, SqliteEncryptionError } from '@aztec/kv-store/sqlite-opfs';
11+
import { AztecSQLiteOPFSStore, SqliteCorruptionError, SqliteEncryptionError } from '@aztec/kv-store/sqlite-opfs';
1212

1313
/** Which of the embedded wallet's two stores failed to open. */
1414
export type EmbeddedStoreName = 'pxe' | 'wallet';
@@ -49,6 +49,29 @@ export type OpenSqliteEncryptedStoreFn = (
4949
const defaultOpenStore: OpenSqliteEncryptedStoreFn = (log, name, poolDirectory, encryptionKey) =>
5050
AztecSQLiteOPFSStore.open(log, name, false, poolDirectory, encryptionKey);
5151

52+
/**
53+
* Internal seam for tests to inject a fake store wiper. Defaults to removing the store's OPFS pool directory
54+
* outright. Not part of the public API.
55+
*
56+
* @internal
57+
*/
58+
export type WipeSqliteStoreFn = (poolDirectory: string | undefined) => Promise<void>;
59+
60+
/**
61+
* Deletes a store's OPFS pool directory. Safe to call only after a *failed* open() — a live store's SAH pool holds
62+
* a lock on the directory and the removal would reject. A store with no `poolDirectory` lives in the shared default
63+
* pool, which we won't blow away (it would take unrelated stores with it), so wiping is a no-op there.
64+
*/
65+
const defaultWipeStore: WipeSqliteStoreFn = async poolDirectory => {
66+
if (!poolDirectory) {
67+
return;
68+
}
69+
const root = await navigator.storage.getDirectory();
70+
await root.removeEntry(poolDirectory, { recursive: true }).catch(() => {
71+
// Already gone / never created — nothing to wipe.
72+
});
73+
};
74+
5275
/**
5376
* Opens the PXE and wallet stores in sequence, both encrypted with keys obtained from `getEncryptionKey`.
5477
*
@@ -75,10 +98,11 @@ export async function openEncryptedEmbeddedStores(
7598
getEncryptionKey: () => Promise<Uint8Array>,
7699
log: Logger,
77100
openStore: OpenSqliteEncryptedStoreFn = defaultOpenStore,
101+
wipeStore: WipeSqliteStoreFn = defaultWipeStore,
78102
): Promise<{ pxeStore: AztecSQLiteOPFSStore; walletStore: AztecSQLiteOPFSStore }> {
79-
const pxeStore = await openOneStore('pxe', config.pxe, getEncryptionKey, log, openStore);
103+
const pxeStore = await openOneStore('pxe', config.pxe, getEncryptionKey, log, openStore, wipeStore);
80104
try {
81-
const walletStore = await openOneStore('wallet', config.wallet, getEncryptionKey, log, openStore);
105+
const walletStore = await openOneStore('wallet', config.wallet, getEncryptionKey, log, openStore, wipeStore);
82106
return { pxeStore, walletStore };
83107
} catch (err) {
84108
// Cleanup is best-effort — if close() itself throws (e.g. worker already dead), swallow it so the original error
@@ -94,6 +118,7 @@ async function openOneStore(
94118
getEncryptionKey: () => Promise<Uint8Array>,
95119
log: Logger,
96120
openStore: OpenSqliteEncryptedStoreFn,
121+
wipeStore: WipeSqliteStoreFn,
97122
): Promise<AztecSQLiteOPFSStore> {
98123
const key = await getEncryptionKey();
99124
try {
@@ -102,6 +127,16 @@ async function openOneStore(
102127
if (err instanceof SqliteEncryptionError && err.code === 'decrypt_failed') {
103128
throw new EmbeddedWalletEncryptionError(storeName, { cause: err });
104129
}
130+
if (err instanceof SqliteCorruptionError) {
131+
// A corrupt image is unrecoverable — no key or retry against the same bytes brings it back. Self-heal
132+
// instead of dead-ending forever: wipe the store's OPFS directory (safe — the failed open left no SAH-pool
133+
// lock behind) and reopen a fresh, empty one, so callers see a normal first-run rather than a permanent
134+
// "database disk image is malformed" on every open.
135+
log.warn(`Embedded wallet '${storeName}' store is corrupt (${err.message}); wiping and reopening fresh`);
136+
await wipeStore(poolDirectory);
137+
// open() transferred (detached) the previous key buffer, so fetch a fresh one for the reopen.
138+
return await openStore(log, name, poolDirectory, await getEncryptionKey());
139+
}
105140
throw err;
106141
}
107142
}

0 commit comments

Comments
 (0)