diff --git a/yarn-project/aztec.js/src/contract/batch_call.test.ts b/yarn-project/aztec.js/src/contract/batch_call.test.ts index 1b87a7d27d7d..a0e60fd3124a 100644 --- a/yarn-project/aztec.js/src/contract/batch_call.test.ts +++ b/yarn-project/aztec.js/src/contract/batch_call.test.ts @@ -14,6 +14,7 @@ import { import { type MockProxy, mock } from 'jest-mock-extended'; +import type { FeePaymentMethod } from '../fee/fee_payment_method.js'; import { TxSimulationResultWithAppOffset } from '../wallet/tx_simulation_result_with_app_offset.js'; import type { Wallet } from '../wallet/wallet.js'; import { BatchCall } from './batch_call.js'; @@ -383,6 +384,101 @@ describe('BatchCall', () => { }); }); + describe('simulate with fee payment method', () => { + it('offsets return-value indices by the calls the fee payment method prepends', async () => { + const appContract = await AztecAddress.random(); + const feeContract = await AztecAddress.random(); + + const appPrivatePayload = createPrivateExecutionPayload('appPrivate', [Fr.random()], appContract, 1); + const appPublicPayload = createPublicExecutionPayload('appPublic', [Fr.random()], appContract); + + batchCall = new BatchCall(wallet, [appPrivatePayload, appPublicPayload]); + + // The fee method contributes one private and one public call, prepended ahead of the batch. + const feePrivateCall = createPrivateExecutionPayload('feePrivate', [], feeContract).calls[0]; + const feePublicCall = createPublicExecutionPayload('feePublic', [], feeContract).calls[0]; + const feePayload = new ExecutionPayload([feePrivateCall, feePublicCall], [], [], [], await AztecAddress.random()); + + const paymentMethod = mock(); + paymentMethod.getExecutionPayload.mockResolvedValue(feePayload); + + const appPrivateReturnValues = [Fr.random()]; + const appPublicReturnValues = [Fr.random()]; + + const txSimResult = mockTxSimResult(); + // Private nested index 0 is the fee call, index 1 is the app call. Returning distinct values lets us detect + // an off-by-one that would decode the fee call's return values as the app call's. + txSimResult.getPrivateReturnValuesOfAppCall.mockImplementation( + (idx?: number) => (idx === 1 ? { values: appPrivateReturnValues } : { values: [Fr.random()] }) as any, + ); + // Public index 0 is the fee call, index 1 is the app call. + txSimResult.getPublicReturnValues.mockReturnValue([ + { values: [Fr.random()] }, + { values: appPublicReturnValues }, + ] as any); + + wallet.batch.mockResolvedValue([{ name: 'simulateTx', result: txSimResult }] as any); + + const { result: results } = await batchCall.simulate({ + from: await AztecAddress.random(), + fee: { paymentMethod }, + }); + + expect(txSimResult.getPrivateReturnValuesOfAppCall).toHaveBeenCalledWith(1); + expect(results).toHaveLength(2); + expect(results[0].result).toEqual(appPrivateReturnValues[0].toBigInt()); + expect(results[1].result).toEqual(appPublicReturnValues[0].toBigInt()); + }); + + it('merges the fee payment method payload and preserves its fee payer', async () => { + const appContract = await AztecAddress.random(); + const feeContract = await AztecAddress.random(); + const feePayer = await AztecAddress.random(); + + const appPayload = createPrivateExecutionPayload('app', [Fr.random()], appContract, 1); + batchCall = new BatchCall(wallet, [appPayload]); + + const feeCall = createPrivateExecutionPayload('payFee', [], feeContract).calls[0]; + const feePayload = new ExecutionPayload([feeCall], [], [], [], feePayer); + + const paymentMethod = mock(); + paymentMethod.getExecutionPayload.mockResolvedValue(feePayload); + + const txSimResult = mockTxSimResult(); + txSimResult.getPrivateReturnValuesOfAppCall.mockReturnValue({ values: [Fr.random()] } as any); + wallet.batch.mockResolvedValue([{ name: 'simulateTx', result: txSimResult }] as any); + + await batchCall.simulate({ from: await AztecAddress.random(), fee: { paymentMethod } }); + + const methods = wallet.batch.mock.calls[0][0] as any[]; + const { args } = methods.find(m => m.name === 'simulateTx')!; + const [executionPayload] = args; + expect(executionPayload.calls).toHaveLength(2); + expect(executionPayload.calls[0]).toEqual(feeCall); + expect(executionPayload.calls[1]).toEqual(appPayload.calls[0]); + expect(executionPayload.feePayer).toEqual(feePayer); + }); + + it('preserves a fee payer carried by a batched execution payload', async () => { + const appContract = await AztecAddress.random(); + const feePayer = await AztecAddress.random(); + + const appCall = createPrivateExecutionPayload('app', [Fr.random()], appContract, 1).calls[0]; + const payloadWithFeePayer = new ExecutionPayload([appCall], [], [], [], feePayer); + batchCall = new BatchCall(wallet, [payloadWithFeePayer]); + + const txSimResult = mockTxSimResult(); + txSimResult.getPrivateReturnValuesOfAppCall.mockReturnValue({ values: [Fr.random()] } as any); + wallet.batch.mockResolvedValue([{ name: 'simulateTx', result: txSimResult }] as any); + + await batchCall.simulate({ from: await AztecAddress.random() }); + + const methods = wallet.batch.mock.calls[0][0] as any[]; + const { args } = methods.find(m => m.name === 'simulateTx')!; + expect(args[0].feePayer).toEqual(feePayer); + }); + }); + describe('request', () => { it('should include fee payment method if provided', async () => { const contractAddress = await AztecAddress.random(); diff --git a/yarn-project/aztec.js/src/contract/batch_call.ts b/yarn-project/aztec.js/src/contract/batch_call.ts index 8a304c4d4dc7..7b7a7241aaaf 100644 --- a/yarn-project/aztec.js/src/contract/batch_call.ts +++ b/yarn-project/aztec.js/src/contract/batch_call.ts @@ -59,6 +59,16 @@ export class BatchCall extends BaseContractInteraction { * @returns The results of all the interactions that make up the batch */ public async simulate(options: SimulateInteractionOptions): Promise { + const feeExecutionPayload = options.fee?.paymentMethod + ? await options.fee.paymentMethod.getExecutionPayload() + : undefined; + // A call-contributing fee payment method prepends its calls in front of the batch (see the merge below). Those + // calls shift the return-value indices, and the wallet-side app-call offset does not absorb them: it only counts + // the wallet's own fee payment method, not one supplied through options.fee. We offset each app call's result + // index by the number of prepended fee calls of the matching type. + const feePrivateCallCount = feeExecutionPayload?.calls.filter(c => c.type === FunctionType.PRIVATE).length ?? 0; + const feePublicCallCount = feeExecutionPayload?.calls.filter(c => c.type === FunctionType.PUBLIC).length ?? 0; + const { indexedExecutionPayloads, utility } = (await this.getExecutionPayloads()).reduce<{ /** Keep track of the number of private calls to retrieve the return values */ privateIndex: 0; @@ -98,12 +108,15 @@ export class BatchCall extends BaseContractInteraction { // Add tx simulation to batch if there are any private/public calls if (indexedExecutionPayloads.length > 0) { const payloads = indexedExecutionPayloads.map(([request]) => request); - const combinedPayload = mergeExecutionPayloads(payloads); + const combinedPayload = mergeExecutionPayloads( + feeExecutionPayload ? [feeExecutionPayload, ...payloads] : payloads, + ); const executionPayload = new ExecutionPayload( combinedPayload.calls, combinedPayload.authWitnesses.concat(options.authWitnesses ?? []), combinedPayload.capsules.concat(options.capsules ?? []), combinedPayload.extraHashedArgs, + combinedPayload.feePayer, ); batchRequests.push({ @@ -139,11 +152,12 @@ export class BatchCall extends BaseContractInteraction { simulatedTx = txResultWrapper.result as TxSimulationResultWithAppOffset; indexedExecutionPayloads.forEach(([request, callIndex, resultIndex]) => { const call = request.calls[0]; - // For public functions we retrieve the values directly from the public output. + // For public functions we retrieve the values directly from the public output. Both indices are offset by + // the fee payment method's own calls, which are prepended ahead of the batch. const rawReturnValues = call.type == FunctionType.PRIVATE - ? simulatedTx!.getPrivateReturnValuesOfAppCall(resultIndex)?.values - : simulatedTx!.getPublicReturnValues()?.[resultIndex].values; + ? simulatedTx!.getPrivateReturnValuesOfAppCall(feePrivateCallCount + resultIndex)?.values + : simulatedTx!.getPublicReturnValues()?.[feePublicCallCount + resultIndex].values; results[callIndex] = { result: rawReturnValues ? decodeFromAbi(call.returnTypes, rawReturnValues) : [], diff --git a/yarn-project/aztec.js/src/contract/contract_function_interaction.ts b/yarn-project/aztec.js/src/contract/contract_function_interaction.ts index 61ab4a6e4dd7..df4c7903bb24 100644 --- a/yarn-project/aztec.js/src/contract/contract_function_interaction.ts +++ b/yarn-project/aztec.js/src/contract/contract_function_interaction.ts @@ -157,10 +157,16 @@ export class ContractFunctionInteraction extends BaseContractInteraction { let rawReturnValues; if (this.functionDao.functionType == FunctionType.PRIVATE) { - rawReturnValues = simulatedTx.getPrivateReturnValuesOfAppCall(0)?.values; + // request() prepends the fee payment method's calls (if any) before this interaction's single call, so the app + // call is the last call of its type in the payload. Its position among the private return values is the number + // of private calls that precede it. + const appCallIndex = executionPayload.calls.filter(c => c.type === FunctionType.PRIVATE).length - 1; + rawReturnValues = simulatedTx.getPrivateReturnValuesOfAppCall(appCallIndex)?.values; } else { - // For public functions we retrieve the first values directly from the public output. - rawReturnValues = simulatedTx.getPublicReturnValues()?.[0]?.values; + // For public functions we retrieve the values directly from the public output, offset by any public fee calls + // that request() prepended ahead of the app call. + const appCallIndex = executionPayload.calls.filter(c => c.type === FunctionType.PUBLIC).length - 1; + rawReturnValues = simulatedTx.getPublicReturnValues()?.[appCallIndex]?.values; } const returnValue = rawReturnValues ? decodeFromAbi(this.functionDao.returnTypes, rawReturnValues) : []; 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 d33eda729a71..93d97a6f2cdc 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 @@ -93,19 +93,16 @@ 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', - ); + await expect( + new BatchCall(wallet, [ + contract.methods.call_function_that_ends_setup_without_phase_check(), + sponsoredFPC.methods.sponsor_unconditionally(), + ]).simulate({ + from: defaultAccountAddress, + fee: { + paymentMethod: new DeferredSponsoredFeePaymentMethod(sponsoredFPC.address), + }, + }), + ).rejects.toThrow('fee payer must be elected during the setup phase'); }); }); diff --git a/yarn-project/txe/esbuild.config.mjs b/yarn-project/txe/esbuild.config.mjs index 46be5f765882..f50b7582f350 100644 --- a/yarn-project/txe/esbuild.config.mjs +++ b/yarn-project/txe/esbuild.config.mjs @@ -217,10 +217,18 @@ const result = await build({ // Dump the full metafile so we can audit chunk graph / imports separately from the build. await writeFile('dest/metafile.json', JSON.stringify(result.metafile, null, 2)); -const totalBytes = Object.values(result.metafile.outputs).reduce((sum, o) => sum + o.bytes, 0); +// Report runtime JS and external sourcemaps separately: the size guard bounds the JS (what V8 +// parses at cold start), while the `.map` files are loaded lazily and only tracked for growth. +const jsBytes = Object.entries(result.metafile.outputs) + .filter(([p]) => !p.endsWith('.map')) + .reduce((sum, [, o]) => sum + o.bytes, 0); +const mapBytes = Object.entries(result.metafile.outputs) + .filter(([p]) => p.endsWith('.map')) + .reduce((sum, [, o]) => sum + o.bytes, 0); const ms = Date.now() - start; +const toMiB = b => (b / 1024 / 1024).toFixed(1); // eslint-disable-next-line no-console -console.log(`Bundled TXE in ${ms}ms (${(totalBytes / 1024 / 1024).toFixed(1)} MiB total)`); +console.log(`Bundled TXE in ${ms}ms (${toMiB(jsBytes)} MiB JS + ${toMiB(mapBytes)} MiB sourcemaps)`); // Surface the heaviest inputs per bundle. Pass `--inspect` on the command line to print. if (process.argv.includes('--inspect')) { diff --git a/yarn-project/txe/esbuild/plugins/size_guard.mjs b/yarn-project/txe/esbuild/plugins/size_guard.mjs index df14e4d1d29e..f1b63bae919d 100644 --- a/yarn-project/txe/esbuild/plugins/size_guard.mjs +++ b/yarn-project/txe/esbuild/plugins/size_guard.mjs @@ -1,20 +1,30 @@ /** * Post-build size guard. Catches unintended bundle growth without involving CI separately. * - * Each entry pairs a regex against the output path with a `maxKB` cap and a `description` that - * shows up in the failure message. The build fails (exit 1) if any matching file exceeds its - * cap, or if the total bundle size exceeds `totalLimitMiB`. + * Each entry in `sizeLimits` pairs a regex against the output path with a `maxKB` cap and a + * `description` that shows up in the failure message. The build fails (exit 1) if any matching + * file exceeds its cap, if the total runtime JS exceeds `totalLimitMiB`, or if the total external + * sourcemap size exceeds `sourcemapLimitMiB`. * - * When a legitimate change pushes a chunk over its limit, raise the number AND append a one-line - * entry to the bump log so the history of size bumps stays auditable. + * The JS total and the sourcemap total are tracked separately. `sourcemap: 'external'` in + * esbuild.config.mjs keeps the `.map` files out of the runtime parse path (V8 only parses the + * `.js`; Node loads a `.map` lazily for stack traces), so folding them into the runtime-bundle + * number would measure the wrong thing — sourcemaps are ~2x the JS here. The sourcemap cap exists + * only to notice runaway map growth, so it is deliberately loose. + * + * When a legitimate change pushes a chunk or total over its limit, raise the number AND append a + * one-line entry to the bump log so the history of size bumps stays auditable. */ // Bump log: // - 2026-05-27: initial limits. // - 2026-07-08: total 14 -> 15 MiB. Merging public-v5-next into v5-next pulled in the interactive-handshake // support (recipient- and sender-side) and the enlarged HandshakeRegistry contract chunk, pushing the TXE -// bundle to ~14.01 MiB. No individual chunk exceeded its cap. -// - 2026-07-13: bumped total to 14.5 MiB. +// bundle to ~14.01 MiB. No individual chunk exceeded its cap. (Total counted JS + sourcemaps back then.) +// - 2026-07-13: bumped total to 14.5 MiB (stopgap; total still counted JS + sourcemaps). +// - 2026-07-20: total now counts runtime JS only, excluding external `.map` sourcemaps (~66% of the old +// number). Reset to 6 MiB against ~4.74 MiB of JS today; reverts the 14.5 stopgap. Sourcemap total gets +// its own loose cap (`sourcemapLimitMiB`, 12 MiB against ~9.3 MiB today) so map growth is still watched. export const sizeLimits = [ // Shared chunks emitted by code-splitting; carry the simulator + PXE + world-state graph. // Spikes here usually mean a heavy dep crept into the eager import path. @@ -26,7 +36,12 @@ export const sizeLimits = [ { pattern: /^dest\/bin\/index\.js$/, maxKB: 8, description: 'CLI entrypoint stub' }, ]; -export const totalLimitMiB = 15; +// Cap on the total runtime JS (`.js` outputs). This is what V8 parses at cold start. +export const totalLimitMiB = 6; + +// Loose cap on the total external sourcemap size (`.map` outputs). Not on the runtime parse path; +// exists only to flag runaway growth. +export const sourcemapLimitMiB = 12; /** * Validates a built esbuild `metafile` against the configured limits. Logs all violations then @@ -34,9 +49,14 @@ export const totalLimitMiB = 15; */ export function enforceSizeLimits(metafile) { const violations = []; - let totalBytes = 0; + let totalJsBytes = 0; + let totalMapBytes = 0; for (const [outPath, out] of Object.entries(metafile.outputs)) { - totalBytes += out.bytes; + if (outPath.endsWith('.map')) { + totalMapBytes += out.bytes; + continue; + } + totalJsBytes += out.bytes; for (const limit of sizeLimits) { if (limit.pattern.test(outPath)) { const sizeKB = out.bytes / 1024; @@ -46,9 +66,13 @@ export function enforceSizeLimits(metafile) { } } } - const totalMiB = totalBytes / 1024 / 1024; - if (totalMiB > totalLimitMiB) { - violations.push(` total: ${totalMiB.toFixed(2)} MiB > ${totalLimitMiB} MiB`); + const totalJsMiB = totalJsBytes / 1024 / 1024; + if (totalJsMiB > totalLimitMiB) { + violations.push(` runtime JS total: ${totalJsMiB.toFixed(2)} MiB > ${totalLimitMiB} MiB`); + } + const totalMapMiB = totalMapBytes / 1024 / 1024; + if (totalMapMiB > sourcemapLimitMiB) { + violations.push(` sourcemap total: ${totalMapMiB.toFixed(2)} MiB > ${sourcemapLimitMiB} MiB`); } if (violations.length === 0) { return; diff --git a/yarn-project/wallets/src/embedded/wallet_db.test.ts b/yarn-project/wallets/src/embedded/wallet_db.test.ts index a97095fa2c7d..5f7f09f30a2a 100644 --- a/yarn-project/wallets/src/embedded/wallet_db.test.ts +++ b/yarn-project/wallets/src/embedded/wallet_db.test.ts @@ -1,4 +1,5 @@ import { Fq, Fr } from '@aztec/foundation/curves/bn254'; +import type { AztecAsyncKVStore, AztecAsyncMap } from '@aztec/kv-store'; import { openTmpStore } from '@aztec/kv-store/lmdb-v2'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; @@ -6,10 +7,11 @@ import { WalletDB } from './wallet_db.js'; import type { AccountType } from './wallet_db.js'; describe('WalletDB', () => { + let store: AztecAsyncKVStore; let db: WalletDB; beforeEach(async () => { - const store = await openTmpStore('wallet-db-test'); + store = await openTmpStore('wallet-db-test'); db = new WalletDB(store, () => {}); }); @@ -146,6 +148,20 @@ describe('WalletDB', () => { expect(accounts[0].alias).toEqual('bob'); expect(accounts[0].item.toString()).toEqual(addr2.toString()); }); + + it('deletes every alias pointing at the account', async () => { + const address = await AztecAddress.random(); + const data = makeAccountData('schnorr', 'alice'); + await db.storeAccount(address, data); + await db.storeAccount(address, { ...data, alias: 'alice-again' }); + + await db.deleteAccount(address); + + expect(await db.listAccounts()).toHaveLength(0); + const aliases = store.openMap('aliases'); + expect(await aliases.getAsync('accounts:alice')).toBeUndefined(); + expect(await aliases.getAsync('accounts:alice-again')).toBeUndefined(); + }); }); describe('storeSender / listSenders', () => { @@ -182,6 +198,80 @@ describe('WalletDB', () => { }); }); + describe('atomicity', () => { + /** Wraps a store so map writes whose key matches `shouldCrash` fail, simulating a crash mid-operation. */ + function crashingStore(store: AztecAsyncKVStore, shouldCrash: (key: string) => boolean): AztecAsyncKVStore { + const wrapMap = (map: AztecAsyncMap): AztecAsyncMap => + new Proxy(map, { + get(target, prop) { + if (prop === 'set' || prop === 'delete') { + const write = (target[prop] as (key: string, value?: Buffer) => Promise).bind(target); + return (key: string, value?: Buffer) => + shouldCrash(key) ? Promise.reject(new Error('simulated write failure')) : write(key, value); + } + const member = Reflect.get(target, prop); + return typeof member === 'function' ? member.bind(target) : member; + }, + }); + return new Proxy(store, { + get(target, prop) { + if (prop === 'openMap') { + return (name: string) => wrapMap(target.openMap(name)); + } + const member = Reflect.get(target, prop); + return typeof member === 'function' ? member.bind(target) : member; + }, + }); + } + + it('persists no partial account data when a write fails midway through storeAccount', async () => { + const store = await openTmpStore('wallet-db-atomicity-test'); + const failingDb = new WalletDB( + crashingStore(store, key => key.startsWith('signingKey:')), + () => {}, + ); + const address = await AztecAddress.random(); + + await expect(failingDb.storeAccount(address, makeAccountData('schnorr', 'alice'))).rejects.toThrow( + 'simulated write failure', + ); + + // Inspect the same underlying store with a fresh WalletDB: no trace of the account may remain + const dbAfterCrash = new WalletDB(store, () => {}); + await expect(dbAfterCrash.retrieveAccount(address)).rejects.toThrow('does not exist'); + expect(await dbAfterCrash.listAccounts()).toEqual([]); + expect(await store.openMap('aliases').getAsync('accounts:alice')).toBeUndefined(); + }); + + it('deletes no account data when a write fails midway through deleteAccount', async () => { + const store = await openTmpStore('wallet-db-atomicity-test'); + const seedDb = new WalletDB(store, () => {}); + const address = await AztecAddress.random(); + const data = makeAccountData('schnorr', 'alice'); + await seedDb.storeAccount(address, data); + + // Fail the alias delete, which happens after all the account entry deletes + const failingDb = new WalletDB( + crashingStore(store, key => key.startsWith('accounts:')), + () => {}, + ); + await expect(failingDb.deleteAccount(address)).rejects.toThrow('simulated write failure'); + + // Inspect the same underlying store with a fresh WalletDB: the account must be fully intact + const dbAfterCrash = new WalletDB(store, () => {}); + const retrieved = await dbAfterCrash.retrieveAccount(address); + expect(retrieved.secretKey).toEqual(data.secretKey); + expect(retrieved.salt).toEqual(data.salt); + expect(retrieved.type).toEqual('schnorr'); + expect(retrieved.signingKey).toEqual(data.signingKey.toBuffer()); + + const accounts = await dbAfterCrash.listAccounts(); + expect(accounts).toHaveLength(1); + expect(accounts[0].alias).toEqual('alice'); + expect(accounts[0].item.toString()).toEqual(address.toString()); + }); + }); + describe('all account types', () => { it.each(['schnorr', 'ecdsasecp256r1', 'ecdsasecp256k1'] as AccountType[])( 'stores and retrieves %s account', diff --git a/yarn-project/wallets/src/embedded/wallet_db.ts b/yarn-project/wallets/src/embedded/wallet_db.ts index 3feba81ab538..fa8ff4f6a928 100644 --- a/yarn-project/wallets/src/embedded/wallet_db.ts +++ b/yarn-project/wallets/src/embedded/wallet_db.ts @@ -43,16 +43,18 @@ export class WalletDB { }, log: LogFn = this.userLog, ) { - if (alias) { - await this.aliases.set(`accounts:${alias}`, Buffer.from(address.toString())); - } - await this.accounts.set(accountKey('type', address), Buffer.from(type)); - await this.accounts.set(accountKey('sk', address), secretKey.toBuffer()); - await this.accounts.set(accountKey('salt', address), salt.toBuffer()); - await this.accounts.set( - accountKey('signingKey', address), - 'toBuffer' in signingKey ? signingKey.toBuffer() : signingKey, - ); + await this.store.transactionAsync(async () => { + if (alias) { + await this.aliases.set(`accounts:${alias}`, Buffer.from(address.toString())); + } + await this.accounts.set(accountKey('type', address), Buffer.from(type)); + await this.accounts.set(accountKey('sk', address), secretKey.toBuffer()); + await this.accounts.set(accountKey('salt', address), salt.toBuffer()); + await this.accounts.set( + accountKey('signingKey', address), + 'toBuffer' in signingKey ? signingKey.toBuffer() : signingKey, + ); + }); log(`Account stored in database${alias ? ` with alias ${alias}` : ''}`); } @@ -119,19 +121,27 @@ export class WalletDB { return addresses; } + /** + * Deletes an account's stored data and every alias pointing at it atomically. Deletion is local to this store; + * any state the PXE holds for the account is unaffected. + */ async deleteAccount(address: AztecAddress) { - await Promise.all([ - this.accounts.delete(accountKey('sk', address)), - this.accounts.delete(accountKey('salt', address)), - this.accounts.delete(accountKey('type', address)), - this.accounts.delete(accountKey('signingKey', address)), - ]); - // Clean up alias if one exists - const aliasesByAddress = await this.#readAccountAliases(); - const alias = aliasesByAddress.get(address.toString()); - if (alias) { - await this.aliases.delete(`accounts:${alias}`); - } + await this.store.transactionAsync(async () => { + await Promise.all([ + this.accounts.delete(accountKey('sk', address)), + this.accounts.delete(accountKey('salt', address)), + this.accounts.delete(accountKey('type', address)), + this.accounts.delete(accountKey('signingKey', address)), + ]); + + const aliasKeys: string[] = []; + for await (const [key, item] of this.aliases.entriesAsync({ start: 'accounts:', end: 'accounts:\uffff' })) { + if (item.toString() === address.toString()) { + aliasKeys.push(key); + } + } + await Promise.all(aliasKeys.map(key => this.aliases.delete(key))); + }); } async close() {