From 74df1198493ddde456714d80bdd08ac6dc1572fe Mon Sep 17 00:00:00 2001 From: Chris Duncan Date: Tue, 15 Jul 2025 11:36:48 -0700 Subject: [PATCH] Lots of refactoring to support bytes for workers and to simplify how workers work. --- src/lib/account.ts | 22 +- src/lib/block.ts | 10 +- src/lib/convert.ts | 16 ++ src/lib/rolodex.ts | 4 +- src/lib/tools.ts | 60 ++++-- src/lib/wallets/bip44-wallet.ts | 8 +- src/lib/wallets/blake2b-wallet.ts | 2 +- src/lib/wallets/index.ts | 298 +-------------------------- src/lib/wallets/wallet.ts | 299 ++++++++++++++++++++++++++++ src/lib/workers/bip44-ckd.ts | 3 +- src/lib/workers/index.ts | 209 +------------------ src/lib/workers/nano-nacl.ts | 57 +++--- src/lib/workers/queue.ts | 112 +++++++++++ src/lib/workers/safe.ts | 50 +++-- src/lib/workers/worker-interface.ts | 89 +++++++++ src/main.ts | 4 +- test/GLOBALS.mjs | 62 +++--- test/test.create-wallet.mjs | 4 +- 18 files changed, 663 insertions(+), 646 deletions(-) create mode 100644 src/lib/wallets/wallet.ts create mode 100644 src/lib/workers/queue.ts create mode 100644 src/lib/workers/worker-interface.ts diff --git a/src/lib/account.ts b/src/lib/account.ts index 0215b18..afeb38b 100644 --- a/src/lib/account.ts +++ b/src/lib/account.ts @@ -5,7 +5,7 @@ import { Blake2b } from './blake2b' import { ACCOUNT_KEY_BYTE_LENGTH, ACCOUNT_KEY_HEX_LENGTH, ALPHABET, PREFIX, PREFIX_LEGACY } from './constants' import { base32, bytes, hex, obj, utf8 } from './convert' import { Rpc } from './rpc' -import { NanoNaClWorker, Queue, SafeWorker } from '#workers' +import { NanoNaClWorker, SafeWorker } from '#workers' /** * Represents a single Nano address and the associated public key. To include the @@ -15,8 +15,6 @@ import { NanoNaClWorker, Queue, SafeWorker } from '#workers' */ export class Account { static #isInternal: boolean = false - static #poolSafe: Queue = new Queue(SafeWorker) - static #poolNanoNaCl: Queue = new Queue(NanoNaClWorker) #address: string #locked: boolean @@ -78,8 +76,8 @@ export class Account { * allow garbage collection. */ async destroy (): Promise { - this.#prv.fill(0) - await Account.#poolSafe.add({ + bytes.erase(this.#prv) + await SafeWorker.add({ method: 'destroy', name: this.#pub }) @@ -141,8 +139,8 @@ export class Account { const data = { privateKey: privateKey.buffer } - const result = await this.#poolNanoNaCl.add(headers, data) - publicKey = result.publicKey[0] + const result = await NanoNaClWorker.add(headers, data) + publicKey = bytes.toHex(new Uint8Array(result.publicKey)) } catch (err) { throw new Error(`Failed to derive public key from private key`, { cause: err }) } @@ -172,7 +170,7 @@ export class Account { privateKey: this.#prv.buffer, password: password.buffer } - const response = await Account.#poolSafe.add(headers, data) + const response = await SafeWorker.add(headers, data) const success = response?.result[0] if (!success) { throw null @@ -181,9 +179,9 @@ export class Account { console.error(`Failed to lock account ${this.address}`, err) return false } finally { - password.fill(0) + bytes.erase(password) } - this.#prv.fill(0) + bytes.erase(this.#prv) this.#locked = true return true } @@ -239,7 +237,7 @@ export class Account { const data = { password: password.buffer } - const response = await Account.#poolSafe.add(headers, data) + const response = await SafeWorker.add(headers, data) const { id, privateKey } = response?.result[0] if (id == null || id !== this.#pub) { throw null @@ -249,7 +247,7 @@ export class Account { console.error(`Failed to unlock account ${this.address}`, err) return false } finally { - password.fill(0) + bytes.erase(password) } this.#locked = false return true diff --git a/src/lib/block.ts b/src/lib/block.ts index 6f4f078..6adb3f0 100644 --- a/src/lib/block.ts +++ b/src/lib/block.ts @@ -7,7 +7,7 @@ import { Blake2b } from './blake2b' import { BURN_ADDRESS, PREAMBLE, DIFFICULTY_RECEIVE, DIFFICULTY_SEND } from './constants' import { dec, hex } from './convert' import { Rpc } from './rpc' -import { NanoNaClWorker, Queue } from '#workers' +import { NanoNaClWorker } from '#workers' /** * Represents a block as defined by the Nano cryptocurrency protocol. The Block @@ -15,8 +15,6 @@ import { NanoNaClWorker, Queue } from '#workers' * of three derived classes: SendBlock, ReceiveBlock, ChangeBlock. */ abstract class Block { - static #poolNanoNaCl: Queue = new Queue(NanoNaClWorker) - account: Account type: string = 'state' abstract subtype: 'send' | 'receive' | 'change' @@ -154,8 +152,8 @@ abstract class Block { const data = { privateKey: hex.toBytes(account.privateKey).buffer } - const result = await Block.#poolNanoNaCl.add(headers, data) - this.signature = result.signature[0] + const result = await NanoNaClWorker.add(headers, data) + this.signature = result[0].signature } catch (err) { throw new Error(`Failed to sign block`, { cause: err }) } @@ -209,7 +207,7 @@ abstract class Block { signature: this.signature ?? '', publicKey: key } - const result = await Block.#poolNanoNaCl.add(headers) + const result = await NanoNaClWorker.add(headers) return result.isVerified[0] } catch (err) { throw new Error(`Failed to derive public key from private key`, { cause: err }) diff --git a/src/lib/convert.ts b/src/lib/convert.ts index f75264e..5c347ae 100644 --- a/src/lib/convert.ts +++ b/src/lib/convert.ts @@ -76,6 +76,22 @@ export class bin { } export class bytes { + /** + * Writes zeroes to memory to erase bytes and then transfers the buffer to + * render it inaccessible to any process. + * + * @param bytes - Buffer or bytes to erase + */ + static erase (bytes: ArrayBuffer | Uint8Array): void { + if (bytes instanceof ArrayBuffer) { + if (bytes.detached) return + bytes = new Uint8Array(bytes) + } + if (bytes.buffer.detached) return + bytes.fill(0) + bytes.buffer.transfer() + } + /** * Convert a Uint8Array to an array of decimal byte values. * diff --git a/src/lib/rolodex.ts b/src/lib/rolodex.ts index 9e06e9a..203f4a2 100644 --- a/src/lib/rolodex.ts +++ b/src/lib/rolodex.ts @@ -94,11 +94,11 @@ export class Rolodex { * @returns {Promise} True if the signature was used to sign the data, else false */ async verify (name: string, signature: string, ...data: string[]): Promise { - const { Tools } = await import('./tools.js') + const { verify } = await import('./tools.js') const entries = this.#entries.filter(e => e.name === name) for (const entry of entries) { const key = entry.account.publicKey - const verified = await Tools.verify(key, signature, ...data) + const verified = await verify(key, signature, ...data) if (verified) { return true } diff --git a/src/lib/tools.ts b/src/lib/tools.ts index c02fe61..de9a98b 100644 --- a/src/lib/tools.ts +++ b/src/lib/tools.ts @@ -5,10 +5,10 @@ import { Account } from './account' import { Blake2b } from './blake2b' import { SendBlock } from './block' import { UNITS } from './constants' -import { hex } from './convert' +import { bytes, hex } from './convert' import { Rpc } from './rpc' import { Bip44Wallet, Blake2bWallet, LedgerWallet } from './wallets' -import { NanoNaCl } from '#workers' +import { NanoNaClWorker } from '#workers' type SweepResult = { status: "success" | "error" @@ -81,16 +81,29 @@ export async function convert (amount: bigint | string, inputUnit: string, outpu /** * Signs arbitrary strings with a private key using the Ed25519 signature scheme. * -* @param {string} key - Hexadecimal-formatted private key to use for signing +* @param {(string|Uint8Array)} key - Hexadecimal-formatted private key to use for signing * @param {...string} input - Data to be signed * @returns {Promise} Hexadecimal-formatted signature */ -export async function sign (key: string, ...input: string[]): Promise { - const account = await Account.fromPrivateKey(key) - const data = hash(input) - const signature = NanoNaCl.detached( - hex.toBytes(data), - hex.toBytes(`${account.privateKey}`)) +export async function sign (key: string | Uint8Array, ...input: string[]): Promise { + if (typeof key === 'string') key = hex.toBytes(key) + let signature: string + try { + const headers = { + method: 'detached', + msg: hash(input) + } + const data = { + privateKey: key.buffer + } + const result = await NanoNaClWorker.add(headers, data) + signature = result.publicKey[0] + } catch (err) { + throw new Error(`Failed to sign message with private key`, { cause: err }) + } finally { + bytes.erase(key) + } + return signature } @@ -158,22 +171,29 @@ export async function sweep ( /** * Verifies the signature of arbitrary strings using a public key. * -* @param {string} key - Hexadecimal-formatted public key to use for verification +* @param {(string|Uint8Array)} key - Hexadecimal-formatted public key to use for verification * @param {string} signature - Hexadcimal-formatted signature * @param {...string} input - Data to be verified * @returns {Promise} True if the data was signed by the public key's matching private key */ -export async function verify (key: string, signature: string, ...input: string[]): Promise { - const data = hash(input) +export async function verify (key: string | Uint8Array, signature: string, ...input: string[]): Promise { + if (typeof key === 'string') key = hex.toBytes(key) + let isVerified: boolean try { - return await NanoNaCl.verify( - hex.toBytes(data), - hex.toBytes(signature), - hex.toBytes(key)) + const headers = { + method: 'verify', + msg: hash(input), + signature + } + const data = { + privateKey: key.buffer + } + isVerified = await NanoNaClWorker.add(headers, data) } catch (err) { - console.error(err) - return false + console.log(err) + isVerified = false + } finally { + bytes.erase(key) } + return isVerified } - -export const Tools = { convert, sign, sweep, verify } diff --git a/src/lib/wallets/bip44-wallet.ts b/src/lib/wallets/bip44-wallet.ts index a5bc47c..3a49d34 100644 --- a/src/lib/wallets/bip44-wallet.ts +++ b/src/lib/wallets/bip44-wallet.ts @@ -6,7 +6,7 @@ import { Bip39Mnemonic } from '#src/lib/bip39-mnemonic.js' import { SEED_LENGTH_BIP44 } from '#src/lib/constants.js' import { hex, utf8 } from '#src/lib/convert.js' import { Entropy } from '#src/lib/entropy.js' -import { Bip44CkdWorker, Queue } from '#workers' +import { Bip44CkdWorker } from '#workers' /** * Hierarchical deterministic (HD) wallet created by using a source of entropy to @@ -32,7 +32,6 @@ import { Bip44CkdWorker, Queue } from '#workers' */ export class Bip44Wallet extends Wallet { static #isInternal: boolean = false - static #poolBip44Ckd: Queue constructor (id: Entropy, seed: string, mnemonic?: Bip39Mnemonic) { if (!Bip44Wallet.#isInternal) { @@ -40,7 +39,6 @@ export class Bip44Wallet extends Wallet { } Bip44Wallet.#isInternal = false super(id, hex.toBytes(seed), mnemonic) - Bip44Wallet.#poolBip44Ckd ??= new Queue(Bip44CkdWorker) } /** @@ -216,9 +214,9 @@ export class Bip44Wallet extends Wallet { indexes } const data = { - seed: this.seed.buffer + seed: hex.toBytes(this.seed).buffer } - const privateKeys: KeyPair[] = await Bip44Wallet.#poolBip44Ckd.add(headers, data) + const privateKeys: KeyPair[] = await Bip44CkdWorker.add(headers, data) for (let i = 0; i < privateKeys.length; i++) { if (privateKeys[i].privateKey == null) { throw new Error('Failed to derive private keys') diff --git a/src/lib/wallets/blake2b-wallet.ts b/src/lib/wallets/blake2b-wallet.ts index 9ace17a..462750d 100644 --- a/src/lib/wallets/blake2b-wallet.ts +++ b/src/lib/wallets/blake2b-wallet.ts @@ -162,7 +162,7 @@ export class Blake2bWallet extends Wallet { const results = [] for (const index of indexes) { const indexHex = index.toString(16).padStart(8, '0').toUpperCase() - const inputHex = `${bytes.toHex(this.seed)}${indexHex}`.padStart(72, '0') + const inputHex = `${this.seed}${indexHex}`.padStart(72, '0') const inputBytes = hex.toBytes(inputHex) const privateKey: string = new Blake2b(32).update(inputBytes).digest('hex') results.push({ privateKey, index }) diff --git a/src/lib/wallets/index.ts b/src/lib/wallets/index.ts index ada0f7b..24452fb 100644 --- a/src/lib/wallets/index.ts +++ b/src/lib/wallets/index.ts @@ -1,302 +1,8 @@ // SPDX-FileCopyrightText: 2025 Chris Duncan // SPDX-License-Identifier: GPL-3.0-or-later -import { Account, AccountList } from '#src/lib/account.js' -import { Bip39Mnemonic } from '#src/lib/bip39-mnemonic.js' -import { ADDRESS_GAP } from '#src/lib/constants.js' -import { hex, utf8 } from '#src/lib/convert.js' -import { Entropy } from '#src/lib/entropy.js' -import { Rpc } from '#src/lib/rpc.js' -import { Queue, SafeWorker } from '#workers' - +export type { KeyPair } from './wallet' +export { Wallet } from './wallet' export { Bip44Wallet } from './bip44-wallet' export { Blake2bWallet } from './blake2b-wallet' export { LedgerWallet } from './ledger-wallet' - -export type KeyPair = { - publicKey?: string, - privateKey?: string, - index?: number -} - -/** -* Represents a wallet containing numerous Nano accounts derived from a single -* source, the form of which can vary based on the type of wallet. The Wallet -* class itself is abstract and cannot be directly instantiated. Currently, three -* types of wallets are supported, each as a derived class: Bip44Wallet, -* Blake2bWallet, LedgerWallet. -*/ -export abstract class Wallet { - abstract ckd (index: number[]): Promise - - static #poolSafe: Queue = new Queue(SafeWorker) - - #accounts: AccountList - #id: Entropy - #locked: boolean = true - #m: Bip39Mnemonic | null - #s: Uint8Array - - get id () { return this.#id.hex } - get isLocked () { return this.#locked } - get isUnlocked () { return !this.#locked } - get mnemonic () { return this.#m instanceof Bip39Mnemonic ? this.#m.phrase : '' } - get seed () { return this.#s } - - constructor (id: Entropy, seed?: Uint8Array, mnemonic?: Bip39Mnemonic) { - if (this.constructor === Wallet) { - throw new Error('Wallet is an abstract class and cannot be instantiated directly.') - } - this.#accounts = new AccountList() - this.#id = id - this.#m = mnemonic ?? null - this.#s = seed ?? new Uint8Array(32) - } - - /** - * Retrieves an account from a wallet using its child key derivation function. - * Defaults to the first account at index 0. - * - * ``` - * console.log(await wallet.account(5)) - * // outputs sixth account of the wallet - * // { - * // privateKey: <...>, - * // index: 5 - * // } - * ``` - * - * @param {number} index - Wallet index of secret key. Default: 0 - * @returns {Account} Account derived at the specified wallet index - */ - async account (index: number = 0): Promise { - return (await this.accounts(index))[index] - } - - /** - * Retrieves accounts from a wallet using its child key derivation function. - * Defaults to the first account at index 0. - * - * 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. - * - * ``` - * console.log(await wallet.accounts(5)) - * // outputs sixth account of the wallet - * // { - * // 5: { - * // privateKey: <...>, - * // index: 5 - * // } - * // } - * ``` - * - * @param {number} from - Start index of secret keys. Default: 0 - * @param {number} to - End index of secret keys. Default: `from` - * @returns {AccountList} Object with keys of account indexes and values of the corresponding Accounts - */ - async accounts (from: number = 0, to: number = from): Promise { - if (from > to) { - const swap = from - from = to - to = swap - } - const output = new AccountList() - const indexes: number[] = [] - for (let i = from; i <= to; i++) { - if (this.#accounts[i] == null) { - indexes.push(i) - } else { - output[i] = this.#accounts[i] - } - } - if (indexes.length > 0) { - const keypairs = await this.ckd(indexes) - for (const keypair of keypairs) { - const { privateKey, publicKey, index } = keypair - if (index == null) throw new RangeError('Account keys derived but index missing') - if (privateKey != null) { - output[index] = await Account.fromPrivateKey(privateKey, index) - } else if (publicKey != null) { - output[index] = await Account.fromPublicKey(publicKey, index) - } else { - throw new RangeError('Account keys missing') - } - this.#accounts[index] = output[index] - } - } - return output - } - - /** - * Removes encrypted secrets in storage and releases variable references to - * allow garbage collection. - */ - async destroy (): Promise { - let i = 0 - for (const a in this.#accounts) { - await this.#accounts[a].destroy() - delete this.#accounts[a] - i++ - } - this.#m = null - this.#s.fill(0) - await Wallet.#poolSafe.add({ - method: 'destroy', - name: this.id - }) - } - - /** - * Locks the wallet and all currently derived accounts with a password that - * will be needed to unlock it later. - * - * @param {(string|Uint8Array)} password Used to lock the wallet - * @returns True if successfully locked - */ - async lock (password: string | Uint8Array): Promise { - if (typeof password === 'string') { - password = utf8.toBytes(password) - } - if (password == null || !(password instanceof Uint8Array)) { - throw new Error('Failed to unlock wallet') - } - try { - const headers = { - method: 'set', - name: this.id, - id: this.id, - } - const data = { - password: password.buffer, - phrase: utf8.toBytes(this.#m?.phrase ?? '').buffer, - seed: this.#s.buffer - } - const response = await Wallet.#poolSafe.add(headers, data) - const success = response?.result[0] - if (!success) { - throw null - } - const promises = [] - for (const account of this.#accounts) { - promises.push(account.lock(password)) - } - await Promise.all(promises) - } catch (err) { - throw new Error('Failed to lock wallet') - } finally { - password.fill(0) - } - this.#m = null - this.#s.fill(0) - this.#locked = true - return true - } - - /** - * Refreshes wallet account balances, frontiers, and representatives from the - * current state on the network. - * - * A successful response will set these properties on each account. - * - * @param {Rpc|string|URL} rpc - RPC node information required to refresh accounts, calculate PoW, and process blocks - * @returns {Promise} Accounts with updated balances, frontiers, and representatives - */ - async refresh (rpc: Rpc | string | URL, from: number = 0, to: number = from): Promise { - if (typeof rpc === 'string' || rpc instanceof URL) { - rpc = new Rpc(rpc) - } - if (!(rpc instanceof Rpc)) { - throw new TypeError('RPC must be a valid node') - } - const accounts = await this.accounts(from, to) - for (const a in accounts) { - try { - await accounts[a].refresh(rpc) - } catch (err) { - delete accounts[a] - } - } - return accounts - } - - /** - * Unlocks the wallet using the same password as used prior to lock it. - * - * @param {(string|Uint8Array)} password Used previously to lock the wallet - * @returns True if successfully unlocked - */ - async unlock (password: string | Uint8Array): Promise { - if (typeof password === 'string') { - password = utf8.toBytes(password) - } - if (password == null || !(password instanceof Uint8Array)) { - throw new Error('Failed to unlock wallet') - } - try { - const headers = { - method: 'get', - name: this.id - } - const data = { - password: password.buffer - } - const response = await Wallet.#poolSafe.add(headers, data) - let { id, mnemonic, seed } = response?.result[0] - if (id == null || id !== this.id) { - throw null - } - if (mnemonic != null) { - this.#m = await Bip39Mnemonic.fromPhrase(mnemonic) - mnemonic = null - } - if (seed != null) { - this.#s.set(hex.toBytes(seed)) - seed = null - } - const promises = [] - for (const account of this.#accounts) { - promises.push(account.unlock(password)) - } - await Promise.all(promises) - } catch (err) { - throw new Error('Failed to unlock wallet') - } finally { - password.fill(0) - } - this.#locked = false - return true - } - - /** - * Fetches the lowest-indexed unopened account from a wallet in sequential - * order. An account is unopened if it has no frontier block. - * - * @param {Rpc|string|URL} rpc - RPC node information required to refresh accounts, calculate PoW, and process blocks - * @param {number} batchSize - Number of accounts to fetch and check per RPC callout - * @param {number} from - Account index from which to start the search - * @returns {Promise} The lowest-indexed unopened account belonging to the wallet - */ - async unopened (rpc: Rpc, batchSize: number = ADDRESS_GAP, from: number = 0): Promise { - if (!Number.isSafeInteger(batchSize) || batchSize < 1) { - throw new RangeError(`Invalid batch size ${batchSize}`) - } - const accounts = await this.accounts(from, from + batchSize - 1) - const addresses = [] - for (const a in accounts) { - addresses.push(accounts[a].address) - } - const data = { - "accounts": addresses - } - const { errors } = await rpc.call('accounts_frontiers', data) - for (const key of Object.keys(errors ?? {})) { - const value = errors[key] - if (value === 'Account not found') { - return Account.fromAddress(key) - } - } - return await this.unopened(rpc, batchSize, from + batchSize) - } -} diff --git a/src/lib/wallets/wallet.ts b/src/lib/wallets/wallet.ts new file mode 100644 index 0000000..5a1574d --- /dev/null +++ b/src/lib/wallets/wallet.ts @@ -0,0 +1,299 @@ +// SPDX-FileCopyrightText: 2025 Chris Duncan +// SPDX-License-Identifier: GPL-3.0-or-later + +import { Account, AccountList } from '#src/lib/account.js' +import { Bip39Mnemonic } from '#src/lib/bip39-mnemonic.js' +import { ADDRESS_GAP } from '#src/lib/constants.js' +import { bytes, utf8 } from '#src/lib/convert.js' +import { Entropy } from '#src/lib/entropy.js' +import { Rpc } from '#src/lib/rpc.js' +import { SafeWorker } from '#workers' + +export type KeyPair = { + publicKey?: string, + privateKey?: string, + index?: number +} + +/** +* Represents a wallet containing numerous Nano accounts derived from a single +* source, the form of which can vary based on the type of wallet. The Wallet +* class itself is abstract and cannot be directly instantiated. Currently, three +* types of wallets are supported, each as a derived class: Bip44Wallet, +* Blake2bWallet, LedgerWallet. +*/ +export abstract class Wallet { + abstract ckd (index: number[]): Promise + + #accounts: AccountList + #id: Entropy + #locked: boolean = true + #m: Bip39Mnemonic | null + #s: Uint8Array + + get id () { return `libnemo_${this.#id.hex}` } + get isLocked () { return this.#locked } + get isUnlocked () { return !this.#locked } + get mnemonic () { return this.#m instanceof Bip39Mnemonic ? this.#m.phrase : '' } + get seed () { return bytes.toHex(this.#s) } + + constructor (id: Entropy, seed?: Uint8Array, mnemonic?: Bip39Mnemonic) { + if (this.constructor === Wallet) { + throw new Error('Wallet is an abstract class and cannot be instantiated directly.') + } + this.#accounts = new AccountList() + this.#id = id + this.#m = mnemonic ?? null + this.#s = seed ?? new Uint8Array(32) + } + + /** + * Retrieves an account from a wallet using its child key derivation function. + * Defaults to the first account at index 0. + * + * ``` + * console.log(await wallet.account(5)) + * // outputs sixth account of the wallet + * // { + * // privateKey: <...>, + * // index: 5 + * // } + * ``` + * + * @param {number} index - Wallet index of secret key. Default: 0 + * @returns {Account} Account derived at the specified wallet index + */ + async account (index: number = 0): Promise { + return (await this.accounts(index))[index] + } + + /** + * Retrieves accounts from a wallet using its child key derivation function. + * Defaults to the first account at index 0. + * + * 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. + * + * ``` + * console.log(await wallet.accounts(5)) + * // outputs sixth account of the wallet + * // { + * // 5: { + * // privateKey: <...>, + * // index: 5 + * // } + * // } + * ``` + * + * @param {number} from - Start index of secret keys. Default: 0 + * @param {number} to - End index of secret keys. Default: `from` + * @returns {AccountList} Object with keys of account indexes and values of the corresponding Accounts + */ + async accounts (from: number = 0, to: number = from): Promise { + if (from > to) { + const swap = from + from = to + to = swap + } + const output = new AccountList() + const indexes: number[] = [] + for (let i = from; i <= to; i++) { + if (this.#accounts[i] == null) { + indexes.push(i) + } else { + output[i] = this.#accounts[i] + } + } + if (indexes.length > 0) { + const keypairs = await this.ckd(indexes) + for (const keypair of keypairs) { + const { privateKey, publicKey, index } = keypair + if (index == null) throw new RangeError('Account keys derived but index missing') + if (privateKey != null) { + output[index] = await Account.fromPrivateKey(privateKey, index) + } else if (publicKey != null) { + output[index] = await Account.fromPublicKey(publicKey, index) + } else { + throw new RangeError('Account keys missing') + } + this.#accounts[index] = output[index] + } + } + return output + } + + /** + * Removes encrypted secrets in storage and releases variable references to + * allow garbage collection. + */ + async destroy (): Promise { + let i = 0 + for (const a in this.#accounts) { + await this.#accounts[a].destroy() + delete this.#accounts[a] + i++ + } + this.#m = null + bytes.erase(this.#s) + await SafeWorker.add({ + method: 'destroy', + name: this.id + }) + } + + /** + * Locks the wallet and all currently derived accounts with a password that + * will be needed to unlock it later. + * + * @param {(string|Uint8Array)} password Used to lock the wallet + * @returns True if successfully locked + */ + async lock (password: string | Uint8Array): Promise { + if (typeof password === 'string') { + password = utf8.toBytes(password) + } + if (password == null || !(password instanceof Uint8Array)) { + throw new Error('Failed to lock wallet') + } + try { + const headers = { + method: 'set', + name: this.id + } + const data = { + password: new Uint8Array(password).buffer, + id: new Uint8Array(this.#id.bytes).buffer, + mnemonic: utf8.toBytes(this.#m?.phrase ?? '').buffer, + seed: this.#s.buffer + } + const response = await SafeWorker.add(headers, data) + const success = response[0].result + if (!success) { + throw null + } + const promises = [] + for (const account of this.#accounts) { + promises.push(account.lock(new Uint8Array(password))) + } + await Promise.all(promises) + } catch (err) { + throw new Error('Failed to lock wallet') + } finally { + bytes.erase(password) + } + this.#m = null + this.#locked = true + return true + } + + /** + * Refreshes wallet account balances, frontiers, and representatives from the + * current state on the network. + * + * A successful response will set these properties on each account. + * + * @param {Rpc|string|URL} rpc - RPC node information required to refresh accounts, calculate PoW, and process blocks + * @returns {Promise} Accounts with updated balances, frontiers, and representatives + */ + async refresh (rpc: Rpc | string | URL, from: number = 0, to: number = from): Promise { + if (typeof rpc === 'string' || rpc instanceof URL) { + rpc = new Rpc(rpc) + } + if (!(rpc instanceof Rpc)) { + throw new TypeError('RPC must be a valid node') + } + const accounts = await this.accounts(from, to) + for (const a in accounts) { + try { + await accounts[a].refresh(rpc) + } catch (err) { + delete accounts[a] + } + } + return accounts + } + + /** + * Unlocks the wallet using the same password as used prior to lock it. + * + * @param {(string|Uint8Array)} password Used previously to lock the wallet + * @returns True if successfully unlocked + */ + async unlock (password: string | Uint8Array): Promise { + if (typeof password === 'string') { + password = utf8.toBytes(password) + } + if (password == null || !(password instanceof Uint8Array)) { + throw new Error('Failed to unlock wallet') + } + try { + const headers = { + method: 'get', + name: this.id + } + const data = { + password: new Uint8Array(password).buffer + } + const response = await SafeWorker.add(headers, data) + let { id, mnemonic, seed } = response[0].result + if (id == null) { + throw null + } + id = await Entropy.import(id as ArrayBuffer) + if (id.hex !== this.#id.hex) { + throw null + } + if (mnemonic != null) { + this.#m = await Bip39Mnemonic.fromPhrase(bytes.toUtf8(mnemonic)) + mnemonic = null + } + if (seed != null) { + this.#s = new Uint8Array(seed as ArrayBuffer) + seed = null + } + const promises = [] + for (const account of this.#accounts) { + promises.push(account.unlock(new Uint8Array(password))) + } + await Promise.all(promises) + } catch (err) { + throw new Error('Failed to unlock wallet') + } finally { + bytes.erase(password) + } + this.#locked = false + return true + } + + /** + * Fetches the lowest-indexed unopened account from a wallet in sequential + * order. An account is unopened if it has no frontier block. + * + * @param {Rpc|string|URL} rpc - RPC node information required to refresh accounts, calculate PoW, and process blocks + * @param {number} batchSize - Number of accounts to fetch and check per RPC callout + * @param {number} from - Account index from which to start the search + * @returns {Promise} The lowest-indexed unopened account belonging to the wallet + */ + async unopened (rpc: Rpc, batchSize: number = ADDRESS_GAP, from: number = 0): Promise { + if (!Number.isSafeInteger(batchSize) || batchSize < 1) { + throw new RangeError(`Invalid batch size ${batchSize}`) + } + const accounts = await this.accounts(from, from + batchSize - 1) + const addresses = [] + for (const a in accounts) { + addresses.push(accounts[a].address) + } + const data = { + "accounts": addresses + } + const { errors } = await rpc.call('accounts_frontiers', data) + for (const key of Object.keys(errors ?? {})) { + const value = errors[key] + if (value === 'Account not found') { + return Account.fromAddress(key) + } + } + return await this.unopened(rpc, batchSize, from + batchSize) + } +} diff --git a/src/lib/workers/bip44-ckd.ts b/src/lib/workers/bip44-ckd.ts index 7d634f6..afe3bf8 100644 --- a/src/lib/workers/bip44-ckd.ts +++ b/src/lib/workers/bip44-ckd.ts @@ -1,7 +1,8 @@ // SPDX-FileCopyrightText: 2025 Chris Duncan // SPDX-License-Identifier: GPL-3.0-or-later -import { Data, Headers, WorkerInterface } from '.' +import { Data, Headers } from '.' +import { WorkerInterface } from './worker-interface' type ExtendedKey = { privateKey: DataView diff --git a/src/lib/workers/index.ts b/src/lib/workers/index.ts index f2b9d2e..e4143f0 100644 --- a/src/lib/workers/index.ts +++ b/src/lib/workers/index.ts @@ -1,215 +1,12 @@ // SPDX-FileCopyrightText: 2025 Chris Duncan // SPDX-License-Identifier: GPL-3.0-or-later -import { default as Bip44CkdWorker, Bip44Ckd } from './bip44-ckd' -import { default as NanoNaClWorker, NanoNaCl } from './nano-nacl' -import { default as SafeWorker, Safe } from './safe' - -export { - Bip44Ckd, - Bip44CkdWorker, - NanoNaCl, - NanoNaClWorker, - Safe, - SafeWorker -} - -export type Headers = { - [key: string]: any -} - export type Data = { [key: string]: ArrayBuffer } -type Task = { - id: number - headers: Headers | null - data?: Data - reject: (value: any) => void - resolve: (value: any) => void -} - -/** -* Processes a queue of tasks using Web Workers. -*/ -export class Queue { - static #decoder: TextDecoder = new TextDecoder() - static #encoder: TextEncoder = new TextEncoder() - static #instances: Queue[] = [] - static get instances (): Queue[] { return this.#instances } - - #job?: Task - #isIdle: boolean - #queue: Task[] = [] - #url: string - #worker: Worker - - /** - * Creates a Web Worker from a stringified script. - * - * @param {string} worker - Stringified worker class body - * @param {number} [count=1] - Integer between 1 and CPU thread count shared among all Pools - */ - constructor (worker: string) { - this.#isIdle = true - this.#queue = [] - this.#url = URL.createObjectURL(new Blob([worker], { type: 'text/javascript' })) - this.#worker = new Worker(this.#url, { type: 'module' }) - this.#worker.addEventListener('message', message => { - let result = JSON.parse(Queue.#decoder.decode(message.data) || '[]') - if (!Array.isArray(result)) result = [result] - debugger - this.#report(result) - }) - Queue.#instances.push(this) - } - - async add (headers: Headers | null, data?: Data): Promise { - return await this.#assign(task => this.#queue.push(task), headers, data) - } - - async prioritize (headers: Headers | null, data?: Data): Promise { - return await this.#assign(task => this.#queue.unshift(task), headers, data) - } - - terminate (): void { - this.#job = undefined - this.#worker.terminate() - } - - async #assign (enqueue: (task: Task) => number, headers: Headers | null, data?: Data) { - return new Promise(async (resolve, reject): Promise => { - const task: Task = { - id: performance.now(), - headers, - data, - resolve, - reject - } - await enqueue(task) - debugger - if (this.#isIdle) this.#process() - }) - } - - #process = (): void => { - debugger - this.#job = this.#queue.shift() - if (this.#job == null) { - throw new Error('Failed to get job from empty task queue.') - } - const { id, headers, data, reject } = this.#job - this.#isIdle = !id - try { - const buffers: ArrayBuffer[] = [] - if (data != null) { - for (let d of Object.keys(data)) { - buffers.push(data[d]) - } - } - this.#worker.postMessage({ headers, data }, buffers) - } catch (err) { - reject(err) - } - } - - #report (results: any[]): void { - if (this.#job == null) { - throw new Error('Worker returned results but had nowhere to report it.') - } - const { resolve, reject } = this.#job - debugger - try { - resolve(results) - } catch (err) { - reject(err) - } finally { - this.#process() - } - } +export type Headers = { + [key: string]: any } -/** -* Provides basic worker event messaging to extending classes. -* -* In order to be properly bundled in a format that can be used to create an -* inline Web Worker, the extending classes must export WorkerInterface and -* themselves as a string: -*``` -* export default ` -* const WorkerInterface = ${WorkerInterface} -* const Pow = ${Pow} -* ` -* ``` -* They must also initialize the event listener by calling their inherited -* `listen()` function. Finally, they must override the implementation of the -* `work()` function. See the documentation of those functions for details. -*/ -export abstract class WorkerInterface { - /** - * Processes data through a worker. - * - * Extending classes must override this template by implementing the same - * function signature and providing their own processing call in the try-catch - * block. - * - * @param {Header} headers - Flat object of header data - * @param {any[]} data - Transferred buffer of data to process - * @returns Promise for processed data - */ - static async work (headers: Headers | null, data?: Data): Promise { - return new Promise(async (resolve, reject): Promise => { - try { - let x, y - if (headers != null) { - const { sample } = headers - x = sample - } - if (data != null) { - const { buf } = data - y = buf - } - resolve({ x, y }) - } catch (err) { - reject(err) - } - }) - } - - /** - * Encodes worker results as an ArrayBuffer so it can be transferred back to - * the main thread. - * - * @param {any[]} results - Array of processed data - */ - static report (results: any[]): void { - const buffer = new TextEncoder().encode(JSON.stringify(results)).buffer - //@ts-expect-error - postMessage(buffer, [buffer]) - } - - /** - * Listens for messages from the main thread. - * - * Extending classes must call this in a static initialization block: - * ``` - * static { - * Extension.listen() - * } - * ``` - */ - static listen (): void { - addEventListener('message', (message: any): void => { - const { name, headers, data } = message - if (name === 'STOP') { - close() - const buffer = new ArrayBuffer(0) - //@ts-expect-error - postMessage(buffer, [buffer]) - } else { - this.work(headers, data).then(this.report).catch(this.report) - } - }) - } -} +export { Bip44CkdWorker, NanoNaClWorker, SafeWorker } from './queue' diff --git a/src/lib/workers/nano-nacl.ts b/src/lib/workers/nano-nacl.ts index 1950fdf..b90a644 100644 --- a/src/lib/workers/nano-nacl.ts +++ b/src/lib/workers/nano-nacl.ts @@ -3,8 +3,10 @@ 'use strict' -import { Data, Headers, WorkerInterface } from '.' +import { Data, Headers } from '.' +import { WorkerInterface } from './worker-interface' import { Blake2b } from '#src/lib/blake2b.js' +import { default as Convert, bytes, hex } from '#src/lib/convert.js' /** * Ported in 2014 by Dmitry Chestnykh and Devi Mandiri. @@ -24,15 +26,15 @@ export class NanoNaCl extends WorkerInterface { NanoNaCl.listen() } - static async work (headers: Headers, data: Data): Promise { + static async work (headers: Headers, data: Data): Promise { const { method, msg, signature, publicKey } = headers const privateKey = new Uint8Array(data.privateKey) switch (method) { case 'convert': { - return await this.convert(privateKey) + return bytes.toHex(await this.convert(privateKey)) } case 'detached': { - return await this.detached(msg, privateKey) + return bytes.toHex(await this.detached(msg, privateKey)) } case 'verify': { return await this.verify(msg, signature, publicKey) @@ -493,7 +495,7 @@ export class NanoNaCl extends WorkerInterface { static crypto_sign_SEEDBYTES: 32 = 32 /* High-level API */ - static checkArrayTypes (...args: Uint8Array[]): void { + static checkArrayTypes (...args: Uint8Array[]): void { for (let i = 0; i < args.length; i++) { if (!(args[i] instanceof Uint8Array)) { throw new TypeError(`expected Uint8Array; actual ${args[i].constructor?.name ?? typeof args[i]}`) @@ -501,38 +503,18 @@ export class NanoNaCl extends WorkerInterface { } } - static parseHex (hex: string): Uint8Array { - if (hex.length % 2 === 1) hex = `0${hex}` - const arr = hex.match(/.{1,2}/g)?.map(byte => parseInt(byte, 16)) - return Uint8Array.from(arr ?? []) - } - - static hexify (buf: Uint8Array): string { - let str = '' - for (let i = 0; i < buf.length; i++) { - if (typeof buf[i] !== 'number') { - throw new TypeError(`expected number to convert to hex; received ${typeof buf[i]}`) - } - if (buf[i] < 0 || buf[i] > 255) { - throw new RangeError(`expected byte value 0-255; received ${buf[i]}`) - } - str += buf[i].toString(16).padStart(2, '0') - } - return str - } - - static sign (msg: Uint8Array, privateKey: Uint8Array): Uint8Array { + static sign (msg: Uint8Array, privateKey: Uint8Array): Uint8Array { this.checkArrayTypes(msg, privateKey) if (privateKey.byteLength !== this.crypto_sign_PRIVATEKEYBYTES) { throw new Error(`expected key size ${this.crypto_sign_PRIVATEKEYBYTES} bytes; actual key size ${privateKey.byteLength} bytes`) } const signedMsg = new Uint8Array(this.crypto_sign_BYTES + msg.length) - const publicKey = this.parseHex(this.convert(privateKey)) + const publicKey = this.convert(privateKey) this.crypto_sign(signedMsg, msg, msg.length, privateKey, publicKey) return signedMsg } - static open (signedMsg: Uint8Array, publicKey: Uint8Array): Uint8Array { + static open (signedMsg: Uint8Array, publicKey: Uint8Array): Uint8Array { this.checkArrayTypes(signedMsg, publicKey) if (publicKey.length !== this.crypto_sign_PUBLICKEYBYTES) { throw new Error('bad public key size') @@ -549,16 +531,16 @@ export class NanoNaCl extends WorkerInterface { return m } - static detached (msg: Uint8Array, privateKey: Uint8Array): string { + static detached (msg: Uint8Array, privateKey: Uint8Array): Uint8Array { const signedMsg = this.sign(msg, privateKey) const sig = new Uint8Array(this.crypto_sign_BYTES) for (let i = 0; i < sig.length; i++) { sig[i] = signedMsg[i] } - return this.hexify(sig).toUpperCase() + return sig } - static verify (msg: Uint8Array, sig: Uint8Array, publicKey: Uint8Array): boolean { + static verify (msg: Uint8Array, sig: Uint8Array, publicKey: Uint8Array): boolean { this.checkArrayTypes(msg, sig, publicKey) if (sig.length !== this.crypto_sign_BYTES) { throw new Error('bad signature size') @@ -577,8 +559,14 @@ export class NanoNaCl extends WorkerInterface { return (this.crypto_sign_open(m, sm, sm.length, publicKey) >= 0) } - static convert (seed: string | Uint8Array): string { - if (typeof seed === 'string') seed = this.parseHex(seed) + /** + * Derives a public key from a private key. + * + * @param {(string|Uint8Array)} seed - 32-byte private key + * @returns 32-byte public key byte array + */ + static convert (seed: string | Uint8Array): Uint8Array { + if (typeof seed === 'string') seed = hex.toBytes(seed) this.checkArrayTypes(seed) if (seed.length !== this.crypto_sign_SEEDBYTES) { throw new Error('bad seed size') @@ -594,11 +582,12 @@ export class NanoNaCl extends WorkerInterface { this.scalarbase(p, hash) this.pack(pk, p) - return this.hexify(pk).toUpperCase() + return pk } } export default ` + ${Convert} const Blake2b = ${Blake2b} const WorkerInterface = ${WorkerInterface} const NanoNaCl = ${NanoNaCl} diff --git a/src/lib/workers/queue.ts b/src/lib/workers/queue.ts new file mode 100644 index 0000000..09b3f2a --- /dev/null +++ b/src/lib/workers/queue.ts @@ -0,0 +1,112 @@ +// SPDX-FileCopyrightText: 2025 Chris Duncan +// SPDX-License-Identifier: GPL-3.0-or-later + +import { Data, Headers } from '.' +import { default as bip44 } from './bip44-ckd' +import { default as nacl } from './nano-nacl' +import { default as safe } from './safe' + +type Task = { + id: number + headers: Headers | null + data?: Data + reject: (value: any) => void + resolve: (value: any) => void +} + +/** +* Processes a queue of tasks using Web Workers. +*/ +export class Queue { + static #instances: Queue[] = [] + static get instances (): Queue[] { return this.#instances } + + #job?: Task + #isIdle: boolean + #queue: Task[] = [] + #url: string + #worker: Worker + + /** + * Creates a Web Worker from a stringified script. + * + * @param {string} worker - Stringified worker class body + * @param {number} [count=1] - Integer between 1 and CPU thread count shared among all Pools + */ + constructor (worker: string) { + this.#isIdle = true + this.#queue = [] + this.#url = URL.createObjectURL(new Blob([worker], { type: 'text/javascript' })) + this.#worker = new Worker(this.#url, { type: 'module' }) + this.#worker.addEventListener('message', message => { + let result = message.data + if (!Array.isArray(result)) result = [result] + this.#report(result) + }) + Queue.#instances.push(this) + } + + async add (headers: Headers | null, data?: Data): Promise { + return await this.#assign(task => this.#queue.push(task), headers, data) + } + + async prioritize (headers: Headers | null, data?: Data): Promise { + return await this.#assign(task => this.#queue.unshift(task), headers, data) + } + + terminate (): void { + this.#job = undefined + this.#worker.terminate() + } + + async #assign (enqueue: (task: Task) => number, headers: Headers | null, data?: Data): Promise { + return new Promise(async (resolve, reject): Promise => { + const task: Task = { + id: performance.now(), + headers, + data, + resolve, + reject + } + await enqueue(task) + if (this.#isIdle) this.#process() + }) + } + + #process = (): void => { + this.#job = this.#queue.shift() + this.#isIdle = this.#job == null + if (this.#job != null) { + const { id, headers, data, reject } = this.#job + try { + const buffers: ArrayBuffer[] = [] + if (data != null) { + for (let d of Object.keys(data)) { + buffers.push(data[d]) + } + } + this.#worker.postMessage({ headers, data }, buffers) + } catch (err) { + reject(err) + } + } + } + + #report (results: any): void { + if (this.#job == null) { + throw new Error('Worker returned results but had nowhere to report it.') + } + const { resolve, reject } = this.#job + try { + resolve(results) + } catch (err) { + reject(err) + } finally { + this.#process() + } + } +} + +export const Bip44CkdWorker = new Queue(bip44) +export const NanoNaClWorker = new Queue(nacl) +export const SafeWorker = new Queue(safe) diff --git a/src/lib/workers/safe.ts b/src/lib/workers/safe.ts index 464548a..ece1735 100644 --- a/src/lib/workers/safe.ts +++ b/src/lib/workers/safe.ts @@ -3,13 +3,14 @@ 'use strict' -import { Data, Headers, WorkerInterface } from '.' -import { bytes, hex, utf8, default as Convert } from '#src/lib/convert.js' +import { Data, Headers } from '.' +import { WorkerInterface } from './worker-interface' +import { default as Convert, bytes } from '#src/lib/convert.js' import { Entropy } from '#src/lib/entropy.js' type SafeRecord = { - encrypted: string, iv: string + data: Data } /** @@ -27,18 +28,17 @@ export class Safe extends WorkerInterface { static async work (headers: Headers, data: Data): Promise { this.#storage = await this.#open(this.DB_NAME) - const { method, name, id } = headers - const { password, phrase, seed } = data + const { method, name } = headers const results = [] let result try { switch (method) { case 'set': { - result = await this.set(name, password, { id, phrase, seed }) + result = await this.set(name, data) break } case 'get': { - result = await this.get(name, password) + result = await this.get(name, data) break } case 'destroy': { @@ -49,7 +49,7 @@ export class Safe extends WorkerInterface { result = `unknown Safe method ${method}` } } - results.push({ name, method, result }) + results.push({ method, name, result }) } catch (err) { result = false } @@ -70,7 +70,9 @@ export class Safe extends WorkerInterface { /** * Encrypts data with a password byte array and stores it in the Safe. */ - static async set (name: string, password: ArrayBuffer, data: any): Promise { + static async set (name: string, data: Data): Promise { + const { password } = data + delete data.password let passkey: CryptoKey try { if (await this.#exists(name)) throw new Error('Record is already locked') @@ -78,7 +80,7 @@ export class Safe extends WorkerInterface { } catch { throw new Error(this.ERR_MSG) } finally { - new Uint8Array(password).fill(0) + bytes.erase(password) } if (this.#isInvalid(name, passkey, data)) { throw new Error(this.ERR_MSG) @@ -86,7 +88,6 @@ export class Safe extends WorkerInterface { try { const iv = await Entropy.create() - data = JSON.stringify(data, (k, v) => typeof v === 'bigint' ? v.toString() : v) const derivationAlgorithm: Pbkdf2Params = { name: 'PBKDF2', hash: 'SHA-512', @@ -98,10 +99,12 @@ export class Safe extends WorkerInterface { length: 256 } passkey = await globalThis.crypto.subtle.deriveKey(derivationAlgorithm, passkey, derivedKeyType, false, ['encrypt']) - const encrypted = await globalThis.crypto.subtle.encrypt({ name: 'AES-GCM', iv: iv.buffer }, passkey, utf8.toBytes(data)) - const record = { - encrypted: bytes.toHex(new Uint8Array(encrypted)), - iv: iv.hex + for (const d of Object.keys(data)) { + data[d] = await globalThis.crypto.subtle.encrypt({ name: 'AES-GCM', iv: iv.buffer }, passkey, data[d]) + } + const record: SafeRecord = { + iv: iv.hex, + data } return await this.#add(record, name) } catch (err) { @@ -112,14 +115,16 @@ export class Safe extends WorkerInterface { /** * Retrieves data from the Safe and decrypts it with a password byte array. */ - static async get (name: string, password: ArrayBuffer): Promise { + static async get (name: string, data: Data): Promise { + const { password } = data + delete data.password let passkey: CryptoKey try { passkey = await globalThis.crypto.subtle.importKey('raw', password, 'PBKDF2', false, ['deriveBits', 'deriveKey']) } catch { return null } finally { - new Uint8Array(password).fill(0) + bytes.erase(password) } if (this.#isInvalid(name, passkey)) { return null @@ -136,8 +141,8 @@ export class Safe extends WorkerInterface { } try { - const encrypted = hex.toBytes(record.encrypted) const iv = await Entropy.import(record.iv) + const { data } = record const derivationAlgorithm: Pbkdf2Params = { name: 'PBKDF2', hash: 'SHA-512', @@ -149,9 +154,10 @@ export class Safe extends WorkerInterface { length: 256 } passkey = await globalThis.crypto.subtle.deriveKey(derivationAlgorithm, passkey, derivedKeyType, false, ['decrypt']) - const decrypted = await globalThis.crypto.subtle.decrypt({ name: 'AES-GCM', iv: iv.buffer }, passkey, encrypted) - const decoded = bytes.toUtf8(new Uint8Array(decrypted)) - const data = JSON.parse(decoded) + for (const d of Object.keys(data)) { + const decrypted = await globalThis.crypto.subtle.decrypt({ name: 'AES-GCM', iv: iv.buffer }, passkey, data[d]) + data[d] = decrypted + } await this.destroy(name) return data } catch (err) { @@ -183,7 +189,7 @@ export class Safe extends WorkerInterface { static async #delete (name: string): Promise { try { const result = await this.#transact('readwrite', db => db.delete(name)) - return !(await this.#exists(name)) + return !(result || await this.#exists(name)) } catch { throw new Error(this.ERR_MSG) } diff --git a/src/lib/workers/worker-interface.ts b/src/lib/workers/worker-interface.ts new file mode 100644 index 0000000..5fa0560 --- /dev/null +++ b/src/lib/workers/worker-interface.ts @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: 2025 Chris Duncan +// SPDX-License-Identifier: GPL-3.0-or-later + +import { Data, Headers } from '.' +/** +* Provides basic worker event messaging to extending classes. +* +* In order to be properly bundled in a format that can be used to create an +* inline Web Worker, the extending classes must export WorkerInterface and +* themselves as a string: +*``` +* export default ` +* const WorkerInterface = ${WorkerInterface} +* const WorkerImplementation = ${WorkerImplementation} +* ` +* ``` +* They must also initialize the event listener by calling their inherited +* `listen()` function. Finally, they must override the implementation of the +* `work()` function. See the documentation of those functions for details. +*/ +export abstract class WorkerInterface { + /** + * Processes data through a worker. + * + * Extending classes must override this template by implementing the same + * function signature and providing their own processing call in the try-catch + * block. + * + * @param {Header} headers - Flat object of header data + * @param {Data} data - String keys for ArrayBuffer values to transfer and process + * @returns Promise for processed data + */ + static async work (headers: Headers | null, data?: Data): Promise { + return new Promise(async (resolve, reject): Promise => { + try { + let x, y = new ArrayBuffer(0) + if (headers != null) { + const { sample } = headers + x = sample + } + if (data != null) { + const { buf } = data + y = buf + } + resolve({ x, y }) + } catch (err) { + reject(err) + } + }) + } + + /** + * Transfers buffers of worker results back to the main thread. + * + * @param {Headers} results - Key-value pairs of processed data + */ + static report (results: Headers): void { + const buffers = [] + for (const d of Object.keys(results)) { + if (results[d] instanceof ArrayBuffer) { + buffers.push(results[d]) + } + } + //@ts-expect-error + postMessage(results, buffers) + } + + /** + * Listens for messages from the main thread. + * + * Extending classes must call this in a static initialization block: + * ``` + * static { + * Extension.listen() + * } + * ``` + */ + static listen (): void { + addEventListener('message', (message: MessageEvent): void => { + const { name, headers, data } = message.data + if (name === 'STOP') { + close() + this.report({}) + } else { + this.work(headers, data).then(this.report).catch(this.report) + } + }) + } +} diff --git a/src/main.ts b/src/main.ts index d80e88b..3229ad6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,7 +6,7 @@ import { Blake2b } from './lib/blake2b' import { SendBlock, ReceiveBlock, ChangeBlock } from './lib/block' import { Rolodex } from './lib/rolodex' import { Rpc } from './lib/rpc' -import { Tools } from './lib/tools' import { Bip44Wallet, Blake2bWallet, LedgerWallet } from './lib/wallets' +import * as Tools from './lib/tools' -export { Account, Blake2b, SendBlock, ReceiveBlock, ChangeBlock, Rpc, Rolodex, Tools, Bip44Wallet, Blake2bWallet, LedgerWallet } +export { Account, Blake2b, SendBlock, ReceiveBlock, ChangeBlock, Rpc, Rolodex, Bip44Wallet, Blake2bWallet, LedgerWallet, Tools } diff --git a/test/GLOBALS.mjs b/test/GLOBALS.mjs index 88a4490..3973857 100644 --- a/test/GLOBALS.mjs +++ b/test/GLOBALS.mjs @@ -105,13 +105,13 @@ export function stats (times) { const failures = [] const passes = [] -function fail (...args) { - failures.push(args) - console.error(`%cFAIL `, 'color:red', ...args) +function fail (err) { + failures.push(err.message) + console.error(`%cFAIL `, 'color:red', err.message, err.cause) } -function pass (...args) { - passes.push(args) - console.log(`%cPASS `, 'color:green', ...args) +function pass (name) { + passes.push(name) + console.log(`%cPASS `, 'color:green', name) } await suite('TEST RUNNER CHECK', async () => { @@ -122,27 +122,27 @@ await suite('TEST RUNNER CHECK', async () => { await test('promise should pass', new Promise(resolve => { resolve('') })) console.assert(failures.some(call => /.*promise should pass.*/.test(call[0])) === false, `good promise errored`) - console.assert(passes.some(call => /.*promise should pass.*/.test(call[0])) === true, `good promise not logged`) + console.assert(passes.some(call => /.*promise should pass.*/.test(call)) === true, `good promise not logged`) await test('promise should fail', new Promise((resolve, reject) => { reject('FAILURE EXPECTED HERE') })) - console.assert(failures.some(call => /.*promise should fail.*/.test(call[0])) === true, `bad promise not errored`) - console.assert(passes.some(call => /.*promise should fail.*/.test(call[0])) === false, 'bad promise logged') + console.assert(failures.some(call => /.*promise should fail.*/.test(call)) === true, `bad promise not errored`) + console.assert(passes.some(call => /.*promise should fail.*/.test(call)) === false, 'bad promise logged') await test('async should pass', async () => {}) - console.assert(failures.some(call => /.*async should pass.*/.test(call[0])) === false, 'good async errored') - console.assert(passes.some(call => /.*async should pass.*/.test(call[0])) === true, 'good async not logged') + console.assert(failures.some(call => /.*async should pass.*/.test(call)) === false, 'good async errored') + console.assert(passes.some(call => /.*async should pass.*/.test(call)) === true, 'good async not logged') await test('async should fail', async () => { throw new Error('FAILURE EXPECTED HERE') }) - console.assert(failures.some(call => /.*async should fail.*/.test(call[0])) === true, 'bad async not errored') - console.assert(passes.some(call => /.*async should fail.*/.test(call[0])) === false, 'bad async logged') + console.assert(failures.some(call => /.*async should fail.*/.test(call)) === true, 'bad async not errored') + console.assert(passes.some(call => /.*async should fail.*/.test(call)) === false, 'bad async logged') await test('function should pass', () => {}) - console.assert(failures.some(call => /.*function should pass.*/.test(call[0])) === false, 'good function errored') - console.assert(passes.some(call => /.*function should pass.*/.test(call[0])) === true, 'good function not logged') + console.assert(failures.some(call => /.*function should pass.*/.test(call)) === false, 'good function errored') + console.assert(passes.some(call => /.*function should pass.*/.test(call)) === true, 'good function not logged') await test('function should fail', 'FAILURE EXPECTED HERE') - console.assert(failures.some(call => /.*function should fail.*/.test(call[0])) === true, 'bad function not errored') - console.assert(passes.some(call => /.*function should fail.*/.test(call[0])) === false, 'bad function logged') + console.assert(failures.some(call => /.*function should fail.*/.test(call)) === true, 'bad function not errored') + console.assert(passes.some(call => /.*function should fail.*/.test(call)) === false, 'bad function logged') console.log(`%cTEST RUNNER CHECK DONE`, 'font-weight:bold') }) @@ -163,37 +163,25 @@ export function suite (name, opts, fn) { }) } -export function test (name, opts, fn) { +export async function test (name, opts, fn) { if (opts?.skip) return console.log(`%cSKIP `, 'color:CornflowerBlue', name) if (fn === undefined) fn = opts if (fn instanceof Promise) { try { - return fn - .then(() => pass(name)) - .catch((err) => fail(`${name}: ${err}`)) - } catch (err) { - fail(`${name}: ${err.message}`) - fail(err) - } - } else if (fn?.constructor?.name === 'AsyncFunction') { - try { - return fn() - .then(() => pass(name)) - .catch((err) => fail(`${name}: ${err}`)) + await fn + pass(name) } catch (err) { - fail(`${name}: ${err.message}`) - fail(err) + fail(new Error(name, { cause: err })) } } else if (typeof fn === 'function') { try { - fn() + await fn() pass(name) } catch (err) { - fail(`${name}: ${err}`) - fail(err) + fail(new Error(name, { cause: err })) } } else { - fail(`${name}: test cannot execute on ${typeof fn} ${fn}`) + fail(new Error(name, { cause: `test cannot execute on ${typeof fn} ${fn}` })) } } @@ -203,7 +191,7 @@ export const assert = { throw new Error('Invalid assertion') } if (!bool) { - throw new Error(`test result falsy`) + throw new Error(`test result falsy`, { cause: bool }) } return true }, diff --git a/test/test.create-wallet.mjs b/test/test.create-wallet.mjs index a159b49..efceb56 100644 --- a/test/test.create-wallet.mjs +++ b/test/test.create-wallet.mjs @@ -14,7 +14,7 @@ await suite('Create wallets', async () => { await wallet.unlock(NANO_TEST_VECTORS.PASSWORD) assert.ok('id' in wallet) - assert.ok(/[A-Fa-f0-9]{32,64}/.test(wallet.id)) + assert.ok(/libnemo_[A-Fa-f0-9]{32,64}/.test(wallet.id)) assert.ok('mnemonic' in wallet) assert.ok(/^(?:[a-z]{3,} ){11,23}[a-z]{3,}$/.test(wallet.mnemonic)) assert.ok('seed' in wallet) @@ -28,7 +28,7 @@ await suite('Create wallets', async () => { await wallet.unlock(NANO_TEST_VECTORS.PASSWORD) assert.ok('id' in wallet) - assert.ok(/[A-Fa-f0-9]{32,64}/.test(wallet.id)) + assert.ok(/libnemo_[A-Fa-f0-9]{32,64}/.test(wallet.id)) assert.ok('mnemonic' in wallet) assert.ok(/^(?:[a-z]{3,} ){11,23}[a-z]{3,}$/.test(wallet.mnemonic)) assert.ok('seed' in wallet) -- 2.47.3