88 * surfaces, so callers don't leak the SAH Pool's OPFS lock.
99 */
1010import 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. */
1414export type EmbeddedStoreName = 'pxe' | 'wallet' ;
@@ -49,6 +49,29 @@ export type OpenSqliteEncryptedStoreFn = (
4949const 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