From 5acd78aac1513a6beb9e1d2d9d737b0f160e74cf Mon Sep 17 00:00:00 2001 From: Chris Duncan Date: Fri, 7 Aug 2026 16:19:50 -0700 Subject: [PATCH] Start refactoring vault to derive accounts in batches which is way more performant. --- src/lib/vault/index.ts | 11 +++++---- src/lib/vault/parsers.ts | 10 +++++++- src/lib/vault/vault-worker.ts | 45 ++++++++++++++++++++++++++--------- src/lib/wallet/accounts.ts | 34 ++++++++++++++++---------- src/lib/wallet/index.ts | 5 ++++ test/test.derive-accounts.mjs | 13 ++++++++++ 6 files changed, 89 insertions(+), 29 deletions(-) diff --git a/src/lib/vault/index.ts b/src/lib/vault/index.ts index ca5a9d5..a021204 100644 --- a/src/lib/vault/index.ts +++ b/src/lib/vault/index.ts @@ -42,12 +42,15 @@ export class Vault { const listener = (message: MessageEvent) => { this.#report(message) } + const terminator = () => { + this.terminate() + } BROWSER: { this.#url = URL.createObjectURL(new Blob([vaultWorker], { type: 'text/javascript' })) this.#worker = new Worker(this.#url, { type: 'module' }) this.#worker.addEventListener('message', listener) - this.#worker.addEventListener('error', this.terminate) - this.#worker.addEventListener('messageerror', this.terminate) + this.#worker.addEventListener('error', terminator) + this.#worker.addEventListener('messageerror', terminator) } NODE: { this.#worker = new NodeWorker(vaultWorker, { @@ -57,8 +60,8 @@ export class Vault { }) this.#url = dec.toString(this.#worker.threadId) this.#worker.on('message', listener) - this.#worker.on('error', this.terminate) - this.#worker.on('messageerror', this.terminate) + this.#worker.on('error', terminator) + this.#worker.on('messageerror', terminator) } } diff --git a/src/lib/vault/parsers.ts b/src/lib/vault/parsers.ts index db696f2..f910e5b 100644 --- a/src/lib/vault/parsers.ts +++ b/src/lib/vault/parsers.ts @@ -89,6 +89,14 @@ export function parseData (action: string, data: Record) { ? data.index : undefined + // Number of public keys to derive + if (action === 'derive' && (typeof data.count !== 'number' || data.count < 1)) { + throw new TypeError('Count is required to derive a batch of keys') + } + const count = typeof data.count === 'number' + ? data.count + : undefined + // Data to sign if ('message' in data) { if (action === 'sign') { @@ -114,7 +122,7 @@ export function parseData (action: string, data: Record) { ? data.timeout : undefined - return { seed, mnemonicPhrase, mnemonicSalt, encrypted, index, message, timeout } + return { seed, mnemonicPhrase, mnemonicSalt, encrypted, index, count, message, timeout } } catch (err) { new Uint8Array(seed ?? []).fill(0) new Uint8Array(encrypted ?? []).fill(0) diff --git a/src/lib/vault/vault-worker.ts b/src/lib/vault/vault-worker.ts index c976ea5..6363247 100644 --- a/src/lib/vault/vault-worker.ts +++ b/src/lib/vault/vault-worker.ts @@ -40,7 +40,7 @@ const listener = (event: MessageEvent): void => { const id = parseId(action, data) const iv = parseIv(action, data) const parsed = parseData(action, data) - const { seed, mnemonicPhrase, mnemonicSalt, index, encrypted, message, timeout } = parsed + const { seed, mnemonicPhrase, mnemonicSalt, index, count, encrypted, message, timeout } = parsed passkey(action, keySalt, data) .then((key: CryptoKey | undefined): Promise | void> => { switch (action) { @@ -56,7 +56,7 @@ const listener = (event: MessageEvent): void => { return create(type, id, key, keySalt, mnemonicSalt) } case 'derive': { - return derive(index) + return derive(index, count) } case 'load': { return load(type, id, key, keySalt, mnemonicPhrase ?? seed, mnemonicSalt) @@ -170,7 +170,7 @@ function create (type?: WalletType, id?: UUID, key?: CryptoKey, keySalt?: ArrayB * wallet seed at a specified index and then returns the public key. The wallet * must be unlocked prior to derivation. */ -function derive (index?: number): Promise> { +function derive (index?: number, count?: number): Promise> { try { _timer.pause() if (_locked) { @@ -182,16 +182,39 @@ function derive (index?: number): Promise> if (_type !== 'BIP-44' && _type !== 'BLAKE2b' && _type !== 'Exodus') { throw new Error('Invalid wallet type') } - if (typeof index !== 'number') { + if (typeof index !== 'number' || index < 0) { throw new Error('Invalid wallet account index') } - return _ckd(index).then(result => { - const prv = new Uint8Array(result) - const pub = nano25519_derive(prv) - prv.fill(0) - _timer = new VaultTimer(_autolock, _timeout) - return { index, publicKey: pub.buffer } - }) + if (typeof count !== 'number' || count < 1) { + throw new Error('Invalid wallet account batch size') + } + const max = index + count + if (max > (_type === 'BLAKE2b' ? 0xffffffff : 0x7fffffff)) { + throw new Error('Wallet account range exceeded ') + } + const promises = [] + for (let i = index; i < max; i++) { + promises.push(_ckd(index).then(result => { + const prv = new Uint8Array(result) + const pub = nano25519_derive(prv) + prv.fill(0) + return { index, publicKey: pub.buffer } + })) + } + return Promise.all(promises) + .then(results => { + const data: Record = {} + for (const result of results) { + data[result.index] = result.publicKey + } + _timer = new VaultTimer(_autolock, _timeout) + return data + }) + .catch(err => { + console.error(err) + _timer.resume() + throw new Error('Failed to derive account', { cause: err }) + }) } catch (err) { console.error(err) _timer.resume() diff --git a/src/lib/wallet/accounts.ts b/src/lib/wallet/accounts.ts index 65f79e2..1a74fd6 100644 --- a/src/lib/wallet/accounts.ts +++ b/src/lib/wallet/accounts.ts @@ -22,7 +22,7 @@ export async function _accounts (type: WalletType, accounts: Map 100) { - console.warn(`libnemo performance may degrade when deriving many accounts at once`) + console.warn('libnemo performance may degrade when deriving many accounts at once') } const output = new Map() const indexes: number[] = [] @@ -46,19 +46,27 @@ export async function _accounts (type: WalletType, accounts: Map({ - action: 'derive', - index - })) - } - const publicKeys = await Promise.all(promises) - for (const { index, publicKey } of publicKeys) { - if (typeof index === 'number' && publicKey instanceof ArrayBuffer) { + // const promises = [] + // for (const index of indexes) { + // promises.push(vault.request({ + // action: 'derive', + // index + // })) + // } + // const publicKeys = await Promise.all(promises) + const min = Math.min(...indexes) + const count = Math.max(...indexes) - min + 1 + const publicKeys = await vault.request({ + action: 'derive', + index: min, + count + }) + console.log(publicKeys) + for (const [index, publicKey] of Object.entries(publicKeys)) { + if (typeof Number(index) === 'number' && publicKey instanceof ArrayBuffer) { const account = new Account(publicKey) - output.set(index, account) - accounts.set(index, account) + output.set(Number(index), account) + accounts.set(Number(index), account) } } } diff --git a/src/lib/wallet/index.ts b/src/lib/wallet/index.ts index 6db838e..a866e73 100644 --- a/src/lib/wallet/index.ts +++ b/src/lib/wallet/index.ts @@ -268,6 +268,11 @@ export class Wallet { * Retrieves accounts from a wallet using its child key derivation function. * Defaults to the first account at index 0. * + * Derives a maximum of 1000 accounts per call and will throw an error if + * exceeded. This is not a hard limit since the method can be called multiple + * times, but it is a safety measure to let developers know when they may be + * creating a poor user experience. + * * The returned object will have keys corresponding with the requested range * of account indexes. The value of each key will be the Account derived for * that index in the wallet. diff --git a/test/test.derive-accounts.mjs b/test/test.derive-accounts.mjs index de7a935..d3e7cd6 100644 --- a/test/test.derive-accounts.mjs +++ b/test/test.derive-accounts.mjs @@ -10,6 +10,19 @@ import { CUSTOM_TEST_VECTORS, NANO_TEST_VECTORS, TEST_PASSWORD } from './VECTORS await Promise.all([ suite('Derive accounts from BIP-44 wallet', async () => { + await test('derive the first 10000 accounts from the given BIP-44 seed', async () => { + const wallet = await Wallet.load('BIP-44', TEST_PASSWORD, NANO_TEST_VECTORS.BIP39_SEED) + await wallet.unlock(TEST_PASSWORD) + + let start, end + start = performance.now() + await wallet.accounts(0, 10000) + end = performance.now() + console.log('duration', end - start) + + await assert.resolves(wallet.destroy()) + }) + await test('derive the first account from the given BIP-44 seed', async () => { const wallet = await Wallet.load('BIP-44', TEST_PASSWORD, NANO_TEST_VECTORS.BIP39_SEED) await wallet.unlock(TEST_PASSWORD) -- 2.52.0