]> git.codecow.com Git - libnemo.git/commitdiff
Lots of refactoring to support bytes for workers and to simplify how workers work.
authorChris Duncan <chris@zoso.dev>
Tue, 15 Jul 2025 18:36:48 +0000 (11:36 -0700)
committerChris Duncan <chris@zoso.dev>
Tue, 15 Jul 2025 18:36:48 +0000 (11:36 -0700)
18 files changed:
src/lib/account.ts
src/lib/block.ts
src/lib/convert.ts
src/lib/rolodex.ts
src/lib/tools.ts
src/lib/wallets/bip44-wallet.ts
src/lib/wallets/blake2b-wallet.ts
src/lib/wallets/index.ts
src/lib/wallets/wallet.ts [new file with mode: 0644]
src/lib/workers/bip44-ckd.ts
src/lib/workers/index.ts
src/lib/workers/nano-nacl.ts
src/lib/workers/queue.ts [new file with mode: 0644]
src/lib/workers/safe.ts
src/lib/workers/worker-interface.ts [new file with mode: 0644]
src/main.ts
test/GLOBALS.mjs
test/test.create-wallet.mjs

index 0215b18c408ba505f6eb10959cfec2067b0a47bb..afeb38bb380e7e97d91f1edd1147db499dc8a5fa 100644 (file)
@@ -5,7 +5,7 @@ import { Blake2b } from './blake2b'
 import { ACCOUNT_KEY_BYTE_LENGTH, ACCOUNT_KEY_HEX_LENGTH, ALPHABET, PREFIX, PREFIX_LEGACY } from './constants'\r
 import { base32, bytes, hex, obj, utf8 } from './convert'\r
 import { Rpc } from './rpc'\r
-import { NanoNaClWorker, Queue, SafeWorker } from '#workers'\r
+import { NanoNaClWorker, SafeWorker } from '#workers'\r
 \r
 /**\r
 * Represents a single Nano address and the associated public key. To include the\r
@@ -15,8 +15,6 @@ import { NanoNaClWorker, Queue, SafeWorker } from '#workers'
 */\r
 export class Account {\r
        static #isInternal: boolean = false\r
-       static #poolSafe: Queue = new Queue(SafeWorker)\r
-       static #poolNanoNaCl: Queue = new Queue(NanoNaClWorker)\r
 \r
        #address: string\r
        #locked: boolean\r
@@ -78,8 +76,8 @@ export class Account {
        * allow garbage collection.\r
        */\r
        async destroy (): Promise<void> {\r
-               this.#prv.fill(0)\r
-               await Account.#poolSafe.add({\r
+               bytes.erase(this.#prv)\r
+               await SafeWorker.add({\r
                        method: 'destroy',\r
                        name: this.#pub\r
                })\r
@@ -141,8 +139,8 @@ export class Account {
                        const data = {\r
                                privateKey: privateKey.buffer\r
                        }\r
-                       const result = await this.#poolNanoNaCl.add(headers, data)\r
-                       publicKey = result.publicKey[0]\r
+                       const result = await NanoNaClWorker.add(headers, data)\r
+                       publicKey = bytes.toHex(new Uint8Array(result.publicKey))\r
                } catch (err) {\r
                        throw new Error(`Failed to derive public key from private key`, { cause: err })\r
                }\r
@@ -172,7 +170,7 @@ export class Account {
                                privateKey: this.#prv.buffer,\r
                                password: password.buffer\r
                        }\r
-                       const response = await Account.#poolSafe.add(headers, data)\r
+                       const response = await SafeWorker.add(headers, data)\r
                        const success = response?.result[0]\r
                        if (!success) {\r
                                throw null\r
@@ -181,9 +179,9 @@ export class Account {
                        console.error(`Failed to lock account ${this.address}`, err)\r
                        return false\r
                } finally {\r
-                       password.fill(0)\r
+                       bytes.erase(password)\r
                }\r
-               this.#prv.fill(0)\r
+               bytes.erase(this.#prv)\r
                this.#locked = true\r
                return true\r
        }\r
@@ -239,7 +237,7 @@ export class Account {
                        const data = {\r
                                password: password.buffer\r
                        }\r
-                       const response = await Account.#poolSafe.add(headers, data)\r
+                       const response = await SafeWorker.add(headers, data)\r
                        const { id, privateKey } = response?.result[0]\r
                        if (id == null || id !== this.#pub) {\r
                                throw null\r
@@ -249,7 +247,7 @@ export class Account {
                        console.error(`Failed to unlock account ${this.address}`, err)\r
                        return false\r
                } finally {\r
-                       password.fill(0)\r
+                       bytes.erase(password)\r
                }\r
                this.#locked = false\r
                return true\r
index 6f4f078f25bae9828bf14f2ae5506116a1543da8..6adb3f08673cf45a000c8be6f8eb10399de4df2c 100644 (file)
@@ -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 })
index f75264e1f0eca75c43173276ad6a80e94490c025..5c347aeab426b4f529f2a246a05ddb70fe41bae5 100644 (file)
@@ -76,6 +76,22 @@ export class bin {
 }\r
 \r
 export class bytes {\r
+       /**\r
+       * Writes zeroes to memory to erase bytes and then transfers the buffer to\r
+       * render it inaccessible to any process.\r
+       *\r
+       * @param bytes - Buffer or bytes to erase\r
+       */\r
+       static erase (bytes: ArrayBuffer | Uint8Array<ArrayBuffer>): void {\r
+               if (bytes instanceof ArrayBuffer) {\r
+                       if (bytes.detached) return\r
+                       bytes = new Uint8Array(bytes)\r
+               }\r
+               if (bytes.buffer.detached) return\r
+               bytes.fill(0)\r
+               bytes.buffer.transfer()\r
+       }\r
+\r
        /**\r
        * Convert a Uint8Array to an array of decimal byte values.\r
        *\r
index 9e06e9a0c7814be07bcb60bdbec06ed7e4e19eb6..203f4a27bbbc68b0c545d90a9b94ff7156f7f59c 100644 (file)
@@ -94,11 +94,11 @@ export class Rolodex {
        * @returns {Promise<boolean>} True if the signature was used to sign the data, else false
        */
        async verify (name: string, signature: string, ...data: string[]): Promise<boolean> {
-               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
                        }
index c02fe61a6e3ca265d01ec9bb6c806bab55a36994..de9a98b00707900d874b0e0b5695760e8d639ff3 100644 (file)
@@ -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<string>} Hexadecimal-formatted signature
 */
-export async function sign (key: string, ...input: string[]): Promise<string> {
-       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<ArrayBuffer>, ...input: string[]): Promise<string> {
+       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<boolean>} 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<boolean> {
-       const data = hash(input)
+export async function verify (key: string | Uint8Array<ArrayBuffer>, signature: string, ...input: string[]): Promise<boolean> {
+       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 }
index a5bc47cb6439944a2a0d6437ff77a2733c60f303..3a49d34d03afd59530287913c8c88160a9b6fbc1 100644 (file)
@@ -6,7 +6,7 @@ import { Bip39Mnemonic } from '#src/lib/bip39-mnemonic.js'
 import { SEED_LENGTH_BIP44 } from '#src/lib/constants.js'\r
 import { hex, utf8 } from '#src/lib/convert.js'\r
 import { Entropy } from '#src/lib/entropy.js'\r
-import { Bip44CkdWorker, Queue } from '#workers'\r
+import { Bip44CkdWorker } from '#workers'\r
 \r
 /**\r
 * Hierarchical deterministic (HD) wallet created by using a source of entropy to\r
@@ -32,7 +32,6 @@ import { Bip44CkdWorker, Queue } from '#workers'
 */\r
 export class Bip44Wallet extends Wallet {\r
        static #isInternal: boolean = false\r
-       static #poolBip44Ckd: Queue\r
 \r
        constructor (id: Entropy, seed: string, mnemonic?: Bip39Mnemonic) {\r
                if (!Bip44Wallet.#isInternal) {\r
@@ -40,7 +39,6 @@ export class Bip44Wallet extends Wallet {
                }\r
                Bip44Wallet.#isInternal = false\r
                super(id, hex.toBytes(seed), mnemonic)\r
-               Bip44Wallet.#poolBip44Ckd ??= new Queue(Bip44CkdWorker)\r
        }\r
 \r
        /**\r
@@ -216,9 +214,9 @@ export class Bip44Wallet extends Wallet {
                        indexes\r
                }\r
                const data = {\r
-                       seed: this.seed.buffer\r
+                       seed: hex.toBytes(this.seed).buffer\r
                }\r
-               const privateKeys: KeyPair[] = await Bip44Wallet.#poolBip44Ckd.add(headers, data)\r
+               const privateKeys: KeyPair[] = await Bip44CkdWorker.add(headers, data)\r
                for (let i = 0; i < privateKeys.length; i++) {\r
                        if (privateKeys[i].privateKey == null) {\r
                                throw new Error('Failed to derive private keys')\r
index 9ace17a9e2479bb3a0c2996368d1a094a593b785..462750dfc45026ee9d6aa9491f983012d3d42b29 100644 (file)
@@ -162,7 +162,7 @@ export class Blake2bWallet extends Wallet {
                const results = []\r
                for (const index of indexes) {\r
                        const indexHex = index.toString(16).padStart(8, '0').toUpperCase()\r
-                       const inputHex = `${bytes.toHex(this.seed)}${indexHex}`.padStart(72, '0')\r
+                       const inputHex = `${this.seed}${indexHex}`.padStart(72, '0')\r
                        const inputBytes = hex.toBytes(inputHex)\r
                        const privateKey: string = new Blake2b(32).update(inputBytes).digest('hex')\r
                        results.push({ privateKey, index })\r
index ada0f7bca39f338b126a1a3530db22526da339d8..24452fb206a5ed842410cd3922ea77262bccf44e 100644 (file)
@@ -1,302 +1,8 @@
 // SPDX-FileCopyrightText: 2025 Chris Duncan <chris@zoso.dev>\r
 // SPDX-License-Identifier: GPL-3.0-or-later\r
 \r
-import { Account, AccountList } from '#src/lib/account.js'\r
-import { Bip39Mnemonic } from '#src/lib/bip39-mnemonic.js'\r
-import { ADDRESS_GAP } from '#src/lib/constants.js'\r
-import { hex, utf8 } from '#src/lib/convert.js'\r
-import { Entropy } from '#src/lib/entropy.js'\r
-import { Rpc } from '#src/lib/rpc.js'\r
-import { Queue, SafeWorker } from '#workers'\r
-\r
+export type { KeyPair } from './wallet'\r
+export { Wallet } from './wallet'\r
 export { Bip44Wallet } from './bip44-wallet'\r
 export { Blake2bWallet } from './blake2b-wallet'\r
 export { LedgerWallet } from './ledger-wallet'\r
-\r
-export type KeyPair = {\r
-       publicKey?: string,\r
-       privateKey?: string,\r
-       index?: number\r
-}\r
-\r
-/**\r
-* Represents a wallet containing numerous Nano accounts derived from a single\r
-* source, the form of which can vary based on the type of wallet. The Wallet\r
-* class itself is abstract and cannot be directly instantiated. Currently, three\r
-* types of wallets are supported, each as a derived class: Bip44Wallet,\r
-* Blake2bWallet, LedgerWallet.\r
-*/\r
-export abstract class Wallet {\r
-       abstract ckd (index: number[]): Promise<KeyPair[]>\r
-\r
-       static #poolSafe: Queue = new Queue(SafeWorker)\r
-\r
-       #accounts: AccountList\r
-       #id: Entropy\r
-       #locked: boolean = true\r
-       #m: Bip39Mnemonic | null\r
-       #s: Uint8Array<ArrayBuffer>\r
-\r
-       get id () { return this.#id.hex }\r
-       get isLocked () { return this.#locked }\r
-       get isUnlocked () { return !this.#locked }\r
-       get mnemonic () { return this.#m instanceof Bip39Mnemonic ? this.#m.phrase : '' }\r
-       get seed () { return this.#s }\r
-\r
-       constructor (id: Entropy, seed?: Uint8Array<ArrayBuffer>, mnemonic?: Bip39Mnemonic) {\r
-               if (this.constructor === Wallet) {\r
-                       throw new Error('Wallet is an abstract class and cannot be instantiated directly.')\r
-               }\r
-               this.#accounts = new AccountList()\r
-               this.#id = id\r
-               this.#m = mnemonic ?? null\r
-               this.#s = seed ?? new Uint8Array(32)\r
-       }\r
-\r
-       /**\r
-       * Retrieves an account from a wallet using its child key derivation function.\r
-       * Defaults to the first account at index 0.\r
-       *\r
-       * ```\r
-       * console.log(await wallet.account(5))\r
-       * // outputs sixth account of the wallet\r
-       * // {\r
-       * //   privateKey: <...>,\r
-       * //   index: 5\r
-       * // }\r
-       * ```\r
-       *\r
-       * @param {number} index - Wallet index of secret key. Default: 0\r
-       * @returns {Account} Account derived at the specified wallet index\r
-       */\r
-       async account (index: number = 0): Promise<Account> {\r
-               return (await this.accounts(index))[index]\r
-       }\r
-\r
-       /**\r
-       * Retrieves accounts from a wallet using its child key derivation function.\r
-       * Defaults to the first account at index 0.\r
-       *\r
-       * The returned object will have keys corresponding with the requested range\r
-       * of account indexes. The value of each key will be the Account derived for\r
-       * that index in the wallet.\r
-       *\r
-       * ```\r
-       * console.log(await wallet.accounts(5))\r
-       * // outputs sixth account of the wallet\r
-       * // {\r
-       * //   5: {\r
-       * //     privateKey: <...>,\r
-       * //     index: 5\r
-       * //   }\r
-       * // }\r
-       * ```\r
-       *\r
-       * @param {number} from - Start index of secret keys. Default: 0\r
-       * @param {number} to - End index of secret keys. Default: `from`\r
-       * @returns {AccountList} Object with keys of account indexes and values of the corresponding Accounts\r
-       */\r
-       async accounts (from: number = 0, to: number = from): Promise<AccountList> {\r
-               if (from > to) {\r
-                       const swap = from\r
-                       from = to\r
-                       to = swap\r
-               }\r
-               const output = new AccountList()\r
-               const indexes: number[] = []\r
-               for (let i = from; i <= to; i++) {\r
-                       if (this.#accounts[i] == null) {\r
-                               indexes.push(i)\r
-                       } else {\r
-                               output[i] = this.#accounts[i]\r
-                       }\r
-               }\r
-               if (indexes.length > 0) {\r
-                       const keypairs = await this.ckd(indexes)\r
-                       for (const keypair of keypairs) {\r
-                               const { privateKey, publicKey, index } = keypair\r
-                               if (index == null) throw new RangeError('Account keys derived but index missing')\r
-                               if (privateKey != null) {\r
-                                       output[index] = await Account.fromPrivateKey(privateKey, index)\r
-                               } else if (publicKey != null) {\r
-                                       output[index] = await Account.fromPublicKey(publicKey, index)\r
-                               } else {\r
-                                       throw new RangeError('Account keys missing')\r
-                               }\r
-                               this.#accounts[index] = output[index]\r
-                       }\r
-               }\r
-               return output\r
-       }\r
-\r
-       /**\r
-       * Removes encrypted secrets in storage and releases variable references to\r
-       * allow garbage collection.\r
-       */\r
-       async destroy (): Promise<void> {\r
-               let i = 0\r
-               for (const a in this.#accounts) {\r
-                       await this.#accounts[a].destroy()\r
-                       delete this.#accounts[a]\r
-                       i++\r
-               }\r
-               this.#m = null\r
-               this.#s.fill(0)\r
-               await Wallet.#poolSafe.add({\r
-                       method: 'destroy',\r
-                       name: this.id\r
-               })\r
-       }\r
-\r
-       /**\r
-       * Locks the wallet and all currently derived accounts with a password that\r
-       * will be needed to unlock it later.\r
-       *\r
-       * @param {(string|Uint8Array)} password Used to lock the wallet\r
-       * @returns True if successfully locked\r
-       */\r
-       async lock (password: string | Uint8Array<ArrayBuffer>): Promise<boolean> {\r
-               if (typeof password === 'string') {\r
-                       password = utf8.toBytes(password)\r
-               }\r
-               if (password == null || !(password instanceof Uint8Array)) {\r
-                       throw new Error('Failed to unlock wallet')\r
-               }\r
-               try {\r
-                       const headers = {\r
-                               method: 'set',\r
-                               name: this.id,\r
-                               id: this.id,\r
-                       }\r
-                       const data = {\r
-                               password: password.buffer,\r
-                               phrase: utf8.toBytes(this.#m?.phrase ?? '').buffer,\r
-                               seed: this.#s.buffer\r
-                       }\r
-                       const response = await Wallet.#poolSafe.add(headers, data)\r
-                       const success = response?.result[0]\r
-                       if (!success) {\r
-                               throw null\r
-                       }\r
-                       const promises = []\r
-                       for (const account of this.#accounts) {\r
-                               promises.push(account.lock(password))\r
-                       }\r
-                       await Promise.all(promises)\r
-               } catch (err) {\r
-                       throw new Error('Failed to lock wallet')\r
-               } finally {\r
-                       password.fill(0)\r
-               }\r
-               this.#m = null\r
-               this.#s.fill(0)\r
-               this.#locked = true\r
-               return true\r
-       }\r
-\r
-       /**\r
-       * Refreshes wallet account balances, frontiers, and representatives from the\r
-       * current state on the network.\r
-       *\r
-       * A successful response will set these properties on each account.\r
-       *\r
-       * @param {Rpc|string|URL} rpc - RPC node information required to refresh accounts, calculate PoW, and process blocks\r
-       * @returns {Promise<Account[]>} Accounts with updated balances, frontiers, and representatives\r
-       */\r
-       async refresh (rpc: Rpc | string | URL, from: number = 0, to: number = from): Promise<AccountList> {\r
-               if (typeof rpc === 'string' || rpc instanceof URL) {\r
-                       rpc = new Rpc(rpc)\r
-               }\r
-               if (!(rpc instanceof Rpc)) {\r
-                       throw new TypeError('RPC must be a valid node')\r
-               }\r
-               const accounts = await this.accounts(from, to)\r
-               for (const a in accounts) {\r
-                       try {\r
-                               await accounts[a].refresh(rpc)\r
-                       } catch (err) {\r
-                               delete accounts[a]\r
-                       }\r
-               }\r
-               return accounts\r
-       }\r
-\r
-       /**\r
-       * Unlocks the wallet using the same password as used prior to lock it.\r
-       *\r
-       * @param {(string|Uint8Array)} password Used previously to lock the wallet\r
-       * @returns True if successfully unlocked\r
-       */\r
-       async unlock (password: string | Uint8Array<ArrayBuffer>): Promise<boolean> {\r
-               if (typeof password === 'string') {\r
-                       password = utf8.toBytes(password)\r
-               }\r
-               if (password == null || !(password instanceof Uint8Array)) {\r
-                       throw new Error('Failed to unlock wallet')\r
-               }\r
-               try {\r
-                       const headers = {\r
-                               method: 'get',\r
-                               name: this.id\r
-                       }\r
-                       const data = {\r
-                               password: password.buffer\r
-                       }\r
-                       const response = await Wallet.#poolSafe.add(headers, data)\r
-                       let { id, mnemonic, seed } = response?.result[0]\r
-                       if (id == null || id !== this.id) {\r
-                               throw null\r
-                       }\r
-                       if (mnemonic != null) {\r
-                               this.#m = await Bip39Mnemonic.fromPhrase(mnemonic)\r
-                               mnemonic = null\r
-                       }\r
-                       if (seed != null) {\r
-                               this.#s.set(hex.toBytes(seed))\r
-                               seed = null\r
-                       }\r
-                       const promises = []\r
-                       for (const account of this.#accounts) {\r
-                               promises.push(account.unlock(password))\r
-                       }\r
-                       await Promise.all(promises)\r
-               } catch (err) {\r
-                       throw new Error('Failed to unlock wallet')\r
-               } finally {\r
-                       password.fill(0)\r
-               }\r
-               this.#locked = false\r
-               return true\r
-       }\r
-\r
-       /**\r
-       * Fetches the lowest-indexed unopened account from a wallet in sequential\r
-       * order. An account is unopened if it has no frontier block.\r
-       *\r
-       * @param {Rpc|string|URL} rpc - RPC node information required to refresh accounts, calculate PoW, and process blocks\r
-       * @param {number} batchSize - Number of accounts to fetch and check per RPC callout\r
-       * @param {number} from - Account index from which to start the search\r
-       * @returns {Promise<Account>} The lowest-indexed unopened account belonging to the wallet\r
-       */\r
-       async unopened (rpc: Rpc, batchSize: number = ADDRESS_GAP, from: number = 0): Promise<Account> {\r
-               if (!Number.isSafeInteger(batchSize) || batchSize < 1) {\r
-                       throw new RangeError(`Invalid batch size ${batchSize}`)\r
-               }\r
-               const accounts = await this.accounts(from, from + batchSize - 1)\r
-               const addresses = []\r
-               for (const a in accounts) {\r
-                       addresses.push(accounts[a].address)\r
-               }\r
-               const data = {\r
-                       "accounts": addresses\r
-               }\r
-               const { errors } = await rpc.call('accounts_frontiers', data)\r
-               for (const key of Object.keys(errors ?? {})) {\r
-                       const value = errors[key]\r
-                       if (value === 'Account not found') {\r
-                               return Account.fromAddress(key)\r
-                       }\r
-               }\r
-               return await this.unopened(rpc, batchSize, from + batchSize)\r
-       }\r
-}\r
diff --git a/src/lib/wallets/wallet.ts b/src/lib/wallets/wallet.ts
new file mode 100644 (file)
index 0000000..5a1574d
--- /dev/null
@@ -0,0 +1,299 @@
+// SPDX-FileCopyrightText: 2025 Chris Duncan <chris@zoso.dev>\r
+// SPDX-License-Identifier: GPL-3.0-or-later\r
+\r
+import { Account, AccountList } from '#src/lib/account.js'\r
+import { Bip39Mnemonic } from '#src/lib/bip39-mnemonic.js'\r
+import { ADDRESS_GAP } from '#src/lib/constants.js'\r
+import { bytes, utf8 } from '#src/lib/convert.js'\r
+import { Entropy } from '#src/lib/entropy.js'\r
+import { Rpc } from '#src/lib/rpc.js'\r
+import { SafeWorker } from '#workers'\r
+\r
+export type KeyPair = {\r
+       publicKey?: string,\r
+       privateKey?: string,\r
+       index?: number\r
+}\r
+\r
+/**\r
+* Represents a wallet containing numerous Nano accounts derived from a single\r
+* source, the form of which can vary based on the type of wallet. The Wallet\r
+* class itself is abstract and cannot be directly instantiated. Currently, three\r
+* types of wallets are supported, each as a derived class: Bip44Wallet,\r
+* Blake2bWallet, LedgerWallet.\r
+*/\r
+export abstract class Wallet {\r
+       abstract ckd (index: number[]): Promise<KeyPair[]>\r
+\r
+       #accounts: AccountList\r
+       #id: Entropy\r
+       #locked: boolean = true\r
+       #m: Bip39Mnemonic | null\r
+       #s: Uint8Array<ArrayBuffer>\r
+\r
+       get id () { return `libnemo_${this.#id.hex}` }\r
+       get isLocked () { return this.#locked }\r
+       get isUnlocked () { return !this.#locked }\r
+       get mnemonic () { return this.#m instanceof Bip39Mnemonic ? this.#m.phrase : '' }\r
+       get seed () { return bytes.toHex(this.#s) }\r
+\r
+       constructor (id: Entropy, seed?: Uint8Array<ArrayBuffer>, mnemonic?: Bip39Mnemonic) {\r
+               if (this.constructor === Wallet) {\r
+                       throw new Error('Wallet is an abstract class and cannot be instantiated directly.')\r
+               }\r
+               this.#accounts = new AccountList()\r
+               this.#id = id\r
+               this.#m = mnemonic ?? null\r
+               this.#s = seed ?? new Uint8Array(32)\r
+       }\r
+\r
+       /**\r
+       * Retrieves an account from a wallet using its child key derivation function.\r
+       * Defaults to the first account at index 0.\r
+       *\r
+       * ```\r
+       * console.log(await wallet.account(5))\r
+       * // outputs sixth account of the wallet\r
+       * // {\r
+       * //   privateKey: <...>,\r
+       * //   index: 5\r
+       * // }\r
+       * ```\r
+       *\r
+       * @param {number} index - Wallet index of secret key. Default: 0\r
+       * @returns {Account} Account derived at the specified wallet index\r
+       */\r
+       async account (index: number = 0): Promise<Account> {\r
+               return (await this.accounts(index))[index]\r
+       }\r
+\r
+       /**\r
+       * Retrieves accounts from a wallet using its child key derivation function.\r
+       * Defaults to the first account at index 0.\r
+       *\r
+       * The returned object will have keys corresponding with the requested range\r
+       * of account indexes. The value of each key will be the Account derived for\r
+       * that index in the wallet.\r
+       *\r
+       * ```\r
+       * console.log(await wallet.accounts(5))\r
+       * // outputs sixth account of the wallet\r
+       * // {\r
+       * //   5: {\r
+       * //     privateKey: <...>,\r
+       * //     index: 5\r
+       * //   }\r
+       * // }\r
+       * ```\r
+       *\r
+       * @param {number} from - Start index of secret keys. Default: 0\r
+       * @param {number} to - End index of secret keys. Default: `from`\r
+       * @returns {AccountList} Object with keys of account indexes and values of the corresponding Accounts\r
+       */\r
+       async accounts (from: number = 0, to: number = from): Promise<AccountList> {\r
+               if (from > to) {\r
+                       const swap = from\r
+                       from = to\r
+                       to = swap\r
+               }\r
+               const output = new AccountList()\r
+               const indexes: number[] = []\r
+               for (let i = from; i <= to; i++) {\r
+                       if (this.#accounts[i] == null) {\r
+                               indexes.push(i)\r
+                       } else {\r
+                               output[i] = this.#accounts[i]\r
+                       }\r
+               }\r
+               if (indexes.length > 0) {\r
+                       const keypairs = await this.ckd(indexes)\r
+                       for (const keypair of keypairs) {\r
+                               const { privateKey, publicKey, index } = keypair\r
+                               if (index == null) throw new RangeError('Account keys derived but index missing')\r
+                               if (privateKey != null) {\r
+                                       output[index] = await Account.fromPrivateKey(privateKey, index)\r
+                               } else if (publicKey != null) {\r
+                                       output[index] = await Account.fromPublicKey(publicKey, index)\r
+                               } else {\r
+                                       throw new RangeError('Account keys missing')\r
+                               }\r
+                               this.#accounts[index] = output[index]\r
+                       }\r
+               }\r
+               return output\r
+       }\r
+\r
+       /**\r
+       * Removes encrypted secrets in storage and releases variable references to\r
+       * allow garbage collection.\r
+       */\r
+       async destroy (): Promise<void> {\r
+               let i = 0\r
+               for (const a in this.#accounts) {\r
+                       await this.#accounts[a].destroy()\r
+                       delete this.#accounts[a]\r
+                       i++\r
+               }\r
+               this.#m = null\r
+               bytes.erase(this.#s)\r
+               await SafeWorker.add({\r
+                       method: 'destroy',\r
+                       name: this.id\r
+               })\r
+       }\r
+\r
+       /**\r
+       * Locks the wallet and all currently derived accounts with a password that\r
+       * will be needed to unlock it later.\r
+       *\r
+       * @param {(string|Uint8Array)} password Used to lock the wallet\r
+       * @returns True if successfully locked\r
+       */\r
+       async lock (password: string | Uint8Array<ArrayBuffer>): Promise<boolean> {\r
+               if (typeof password === 'string') {\r
+                       password = utf8.toBytes(password)\r
+               }\r
+               if (password == null || !(password instanceof Uint8Array)) {\r
+                       throw new Error('Failed to lock wallet')\r
+               }\r
+               try {\r
+                       const headers = {\r
+                               method: 'set',\r
+                               name: this.id\r
+                       }\r
+                       const data = {\r
+                               password: new Uint8Array(password).buffer,\r
+                               id: new Uint8Array(this.#id.bytes).buffer,\r
+                               mnemonic: utf8.toBytes(this.#m?.phrase ?? '').buffer,\r
+                               seed: this.#s.buffer\r
+                       }\r
+                       const response = await SafeWorker.add(headers, data)\r
+                       const success = response[0].result\r
+                       if (!success) {\r
+                               throw null\r
+                       }\r
+                       const promises = []\r
+                       for (const account of this.#accounts) {\r
+                               promises.push(account.lock(new Uint8Array(password)))\r
+                       }\r
+                       await Promise.all(promises)\r
+               } catch (err) {\r
+                       throw new Error('Failed to lock wallet')\r
+               } finally {\r
+                       bytes.erase(password)\r
+               }\r
+               this.#m = null\r
+               this.#locked = true\r
+               return true\r
+       }\r
+\r
+       /**\r
+       * Refreshes wallet account balances, frontiers, and representatives from the\r
+       * current state on the network.\r
+       *\r
+       * A successful response will set these properties on each account.\r
+       *\r
+       * @param {Rpc|string|URL} rpc - RPC node information required to refresh accounts, calculate PoW, and process blocks\r
+       * @returns {Promise<Account[]>} Accounts with updated balances, frontiers, and representatives\r
+       */\r
+       async refresh (rpc: Rpc | string | URL, from: number = 0, to: number = from): Promise<AccountList> {\r
+               if (typeof rpc === 'string' || rpc instanceof URL) {\r
+                       rpc = new Rpc(rpc)\r
+               }\r
+               if (!(rpc instanceof Rpc)) {\r
+                       throw new TypeError('RPC must be a valid node')\r
+               }\r
+               const accounts = await this.accounts(from, to)\r
+               for (const a in accounts) {\r
+                       try {\r
+                               await accounts[a].refresh(rpc)\r
+                       } catch (err) {\r
+                               delete accounts[a]\r
+                       }\r
+               }\r
+               return accounts\r
+       }\r
+\r
+       /**\r
+       * Unlocks the wallet using the same password as used prior to lock it.\r
+       *\r
+       * @param {(string|Uint8Array)} password Used previously to lock the wallet\r
+       * @returns True if successfully unlocked\r
+       */\r
+       async unlock (password: string | Uint8Array<ArrayBuffer>): Promise<boolean> {\r
+               if (typeof password === 'string') {\r
+                       password = utf8.toBytes(password)\r
+               }\r
+               if (password == null || !(password instanceof Uint8Array)) {\r
+                       throw new Error('Failed to unlock wallet')\r
+               }\r
+               try {\r
+                       const headers = {\r
+                               method: 'get',\r
+                               name: this.id\r
+                       }\r
+                       const data = {\r
+                               password: new Uint8Array(password).buffer\r
+                       }\r
+                       const response = await SafeWorker.add(headers, data)\r
+                       let { id, mnemonic, seed } = response[0].result\r
+                       if (id == null) {\r
+                               throw null\r
+                       }\r
+                       id = await Entropy.import(id as ArrayBuffer)\r
+                       if (id.hex !== this.#id.hex) {\r
+                               throw null\r
+                       }\r
+                       if (mnemonic != null) {\r
+                               this.#m = await Bip39Mnemonic.fromPhrase(bytes.toUtf8(mnemonic))\r
+                               mnemonic = null\r
+                       }\r
+                       if (seed != null) {\r
+                               this.#s = new Uint8Array(seed as ArrayBuffer)\r
+                               seed = null\r
+                       }\r
+                       const promises = []\r
+                       for (const account of this.#accounts) {\r
+                               promises.push(account.unlock(new Uint8Array(password)))\r
+                       }\r
+                       await Promise.all(promises)\r
+               } catch (err) {\r
+                       throw new Error('Failed to unlock wallet')\r
+               } finally {\r
+                       bytes.erase(password)\r
+               }\r
+               this.#locked = false\r
+               return true\r
+       }\r
+\r
+       /**\r
+       * Fetches the lowest-indexed unopened account from a wallet in sequential\r
+       * order. An account is unopened if it has no frontier block.\r
+       *\r
+       * @param {Rpc|string|URL} rpc - RPC node information required to refresh accounts, calculate PoW, and process blocks\r
+       * @param {number} batchSize - Number of accounts to fetch and check per RPC callout\r
+       * @param {number} from - Account index from which to start the search\r
+       * @returns {Promise<Account>} The lowest-indexed unopened account belonging to the wallet\r
+       */\r
+       async unopened (rpc: Rpc, batchSize: number = ADDRESS_GAP, from: number = 0): Promise<Account> {\r
+               if (!Number.isSafeInteger(batchSize) || batchSize < 1) {\r
+                       throw new RangeError(`Invalid batch size ${batchSize}`)\r
+               }\r
+               const accounts = await this.accounts(from, from + batchSize - 1)\r
+               const addresses = []\r
+               for (const a in accounts) {\r
+                       addresses.push(accounts[a].address)\r
+               }\r
+               const data = {\r
+                       "accounts": addresses\r
+               }\r
+               const { errors } = await rpc.call('accounts_frontiers', data)\r
+               for (const key of Object.keys(errors ?? {})) {\r
+                       const value = errors[key]\r
+                       if (value === 'Account not found') {\r
+                               return Account.fromAddress(key)\r
+                       }\r
+               }\r
+               return await this.unopened(rpc, batchSize, from + batchSize)\r
+       }\r
+}\r
index 7d634f6e41d61e3d7e769c87f08a10b46f008b5c..afe3bf8ac91ce538fee8b1dc0a971630ec778775 100644 (file)
@@ -1,7 +1,8 @@
 // SPDX-FileCopyrightText: 2025 Chris Duncan <chris@zoso.dev>
 // 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<ArrayBuffer>
index f2b9d2ee7d6a383499f31c0b5c50201d41e06adf..e4143f0185ece6d7bf292a894c7559aa100da204 100644 (file)
 // SPDX-FileCopyrightText: 2025 Chris Duncan <chris@zoso.dev>
 // 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<any> {
-               return await this.#assign(task => this.#queue.push(task), headers, data)
-       }
-
-       async prioritize (headers: Headers | null, data?: Data): Promise<any> {
-               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<void> => {
-                       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<any> {
-               return new Promise(async (resolve, reject): Promise<void> => {
-                       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'
index 1950fdf66e3829e5a782bb46481bdf1333a3d922..b90a64423d3116dde70cb6712116f1f4c3ce3f12 100644 (file)
@@ -3,8 +3,10 @@
 \r
 'use strict'\r
 \r
-import { Data, Headers, WorkerInterface } from '.'\r
+import { Data, Headers } from '.'\r
+import { WorkerInterface } from './worker-interface'\r
 import { Blake2b } from '#src/lib/blake2b.js'\r
+import { default as Convert, bytes, hex } from '#src/lib/convert.js'\r
 \r
 /**\r
 * Ported in 2014 by Dmitry Chestnykh and Devi Mandiri.\r
@@ -24,15 +26,15 @@ export class NanoNaCl extends WorkerInterface {
                NanoNaCl.listen()\r
        }\r
 \r
-       static async work (headers: Headers, data: Data): Promise<any> {\r
+       static async work (headers: Headers, data: Data): Promise<boolean | string> {\r
                const { method, msg, signature, publicKey } = headers\r
                const privateKey = new Uint8Array(data.privateKey)\r
                switch (method) {\r
                        case 'convert': {\r
-                               return await this.convert(privateKey)\r
+                               return bytes.toHex(await this.convert(privateKey))\r
                        }\r
                        case 'detached': {\r
-                               return await this.detached(msg, privateKey)\r
+                               return bytes.toHex(await this.detached(msg, privateKey))\r
                        }\r
                        case 'verify': {\r
                                return await this.verify(msg, signature, publicKey)\r
@@ -493,7 +495,7 @@ export class NanoNaCl extends WorkerInterface {
        static crypto_sign_SEEDBYTES: 32 = 32\r
 \r
        /* High-level API */\r
-       static checkArrayTypes (...args: Uint8Array[]): void {\r
+       static checkArrayTypes (...args: Uint8Array<ArrayBuffer>[]): void {\r
                for (let i = 0; i < args.length; i++) {\r
                        if (!(args[i] instanceof Uint8Array)) {\r
                                throw new TypeError(`expected Uint8Array; actual ${args[i].constructor?.name ?? typeof args[i]}`)\r
@@ -501,38 +503,18 @@ export class NanoNaCl extends WorkerInterface {
                }\r
        }\r
 \r
-       static parseHex (hex: string): Uint8Array {\r
-               if (hex.length % 2 === 1) hex = `0${hex}`\r
-               const arr = hex.match(/.{1,2}/g)?.map(byte => parseInt(byte, 16))\r
-               return Uint8Array.from(arr ?? [])\r
-       }\r
-\r
-       static hexify (buf: Uint8Array): string {\r
-               let str = ''\r
-               for (let i = 0; i < buf.length; i++) {\r
-                       if (typeof buf[i] !== 'number') {\r
-                               throw new TypeError(`expected number to convert to hex; received ${typeof buf[i]}`)\r
-                       }\r
-                       if (buf[i] < 0 || buf[i] > 255) {\r
-                               throw new RangeError(`expected byte value 0-255; received ${buf[i]}`)\r
-                       }\r
-                       str += buf[i].toString(16).padStart(2, '0')\r
-               }\r
-               return str\r
-       }\r
-\r
-       static sign (msg: Uint8Array, privateKey: Uint8Array): Uint8Array {\r
+       static sign (msg: Uint8Array<ArrayBuffer>, privateKey: Uint8Array<ArrayBuffer>): Uint8Array<ArrayBuffer> {\r
                this.checkArrayTypes(msg, privateKey)\r
                if (privateKey.byteLength !== this.crypto_sign_PRIVATEKEYBYTES) {\r
                        throw new Error(`expected key size ${this.crypto_sign_PRIVATEKEYBYTES} bytes; actual key size ${privateKey.byteLength} bytes`)\r
                }\r
                const signedMsg = new Uint8Array(this.crypto_sign_BYTES + msg.length)\r
-               const publicKey = this.parseHex(this.convert(privateKey))\r
+               const publicKey = this.convert(privateKey)\r
                this.crypto_sign(signedMsg, msg, msg.length, privateKey, publicKey)\r
                return signedMsg\r
        }\r
 \r
-       static open (signedMsg: Uint8Array, publicKey: Uint8Array): Uint8Array {\r
+       static open (signedMsg: Uint8Array<ArrayBuffer>, publicKey: Uint8Array<ArrayBuffer>): Uint8Array<ArrayBuffer> {\r
                this.checkArrayTypes(signedMsg, publicKey)\r
                if (publicKey.length !== this.crypto_sign_PUBLICKEYBYTES) {\r
                        throw new Error('bad public key size')\r
@@ -549,16 +531,16 @@ export class NanoNaCl extends WorkerInterface {
                return m\r
        }\r
 \r
-       static detached (msg: Uint8Array, privateKey: Uint8Array): string {\r
+       static detached (msg: Uint8Array<ArrayBuffer>, privateKey: Uint8Array<ArrayBuffer>): Uint8Array<ArrayBuffer> {\r
                const signedMsg = this.sign(msg, privateKey)\r
                const sig = new Uint8Array(this.crypto_sign_BYTES)\r
                for (let i = 0; i < sig.length; i++) {\r
                        sig[i] = signedMsg[i]\r
                }\r
-               return this.hexify(sig).toUpperCase()\r
+               return sig\r
        }\r
 \r
-       static verify (msg: Uint8Array, sig: Uint8Array, publicKey: Uint8Array): boolean {\r
+       static verify (msg: Uint8Array<ArrayBuffer>, sig: Uint8Array<ArrayBuffer>, publicKey: Uint8Array<ArrayBuffer>): boolean {\r
                this.checkArrayTypes(msg, sig, publicKey)\r
                if (sig.length !== this.crypto_sign_BYTES) {\r
                        throw new Error('bad signature size')\r
@@ -577,8 +559,14 @@ export class NanoNaCl extends WorkerInterface {
                return (this.crypto_sign_open(m, sm, sm.length, publicKey) >= 0)\r
        }\r
 \r
-       static convert (seed: string | Uint8Array): string {\r
-               if (typeof seed === 'string') seed = this.parseHex(seed)\r
+       /**\r
+       * Derives a public key from a private key.\r
+       *\r
+       * @param {(string|Uint8Array)} seed - 32-byte private key\r
+       * @returns 32-byte public key byte array\r
+       */\r
+       static convert (seed: string | Uint8Array<ArrayBuffer>): Uint8Array<ArrayBuffer> {\r
+               if (typeof seed === 'string') seed = hex.toBytes(seed)\r
                this.checkArrayTypes(seed)\r
                if (seed.length !== this.crypto_sign_SEEDBYTES) {\r
                        throw new Error('bad seed size')\r
@@ -594,11 +582,12 @@ export class NanoNaCl extends WorkerInterface {
                this.scalarbase(p, hash)\r
                this.pack(pk, p)\r
 \r
-               return this.hexify(pk).toUpperCase()\r
+               return pk\r
        }\r
 }\r
 \r
 export default `\r
+       ${Convert}\r
        const Blake2b = ${Blake2b}\r
        const WorkerInterface = ${WorkerInterface}\r
        const NanoNaCl = ${NanoNaCl}\r
diff --git a/src/lib/workers/queue.ts b/src/lib/workers/queue.ts
new file mode 100644 (file)
index 0000000..09b3f2a
--- /dev/null
@@ -0,0 +1,112 @@
+// SPDX-FileCopyrightText: 2025 Chris Duncan <chris@zoso.dev>
+// 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<any> {
+               return await this.#assign(task => this.#queue.push(task), headers, data)
+       }
+
+       async prioritize (headers: Headers | null, data?: Data): Promise<any> {
+               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<Data> {
+               return new Promise(async (resolve, reject): Promise<void> => {
+                       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)
index 464548a8aed2eed0077910530c10f2b615f219d1..ece1735b749ea42aba8f2c05869fe28bda14bd49 100644 (file)
@@ -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<any> {
                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<boolean> {
+       static async set (name: string, data: Data): Promise<boolean> {
+               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<any> {
+       static async get (name: string, data: Data): Promise<Data | null> {
+               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<boolean> {
                try {
                        const result = await this.#transact<undefined>('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 (file)
index 0000000..5fa0560
--- /dev/null
@@ -0,0 +1,89 @@
+// SPDX-FileCopyrightText: 2025 Chris Duncan <chris@zoso.dev>
+// 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<any> {
+               return new Promise(async (resolve, reject): Promise<void> => {
+                       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<any>): void => {
+                       const { name, headers, data } = message.data
+                       if (name === 'STOP') {
+                               close()
+                               this.report({})
+                       } else {
+                               this.work(headers, data).then(this.report).catch(this.report)
+                       }
+               })
+       }
+}
index d80e88b8685987f46c87b7d8b543b870cae6bdce..3229ad69fa8aa9042cf53553a7f74f7c70f05d1a 100644 (file)
@@ -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 }
index 88a44904067a0c7f5c42d508865d03d6e3a50f61..3973857e9b6cc9575587a88bce634862ccc906f3 100644 (file)
@@ -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
        },
index a159b49dcac74e95f0c8937e50562839372da604..efceb56c33697f8a514f31f121b91590e70c3dd5 100644 (file)
@@ -14,7 +14,7 @@ await suite('Create wallets', async () => {
                await wallet.unlock(NANO_TEST_VECTORS.PASSWORD)\r
 \r
                assert.ok('id' in wallet)\r
-               assert.ok(/[A-Fa-f0-9]{32,64}/.test(wallet.id))\r
+               assert.ok(/libnemo_[A-Fa-f0-9]{32,64}/.test(wallet.id))\r
                assert.ok('mnemonic' in wallet)\r
                assert.ok(/^(?:[a-z]{3,} ){11,23}[a-z]{3,}$/.test(wallet.mnemonic))\r
                assert.ok('seed' in wallet)\r
@@ -28,7 +28,7 @@ await suite('Create wallets', async () => {
                await wallet.unlock(NANO_TEST_VECTORS.PASSWORD)\r
 \r
                assert.ok('id' in wallet)\r
-               assert.ok(/[A-Fa-f0-9]{32,64}/.test(wallet.id))\r
+               assert.ok(/libnemo_[A-Fa-f0-9]{32,64}/.test(wallet.id))\r
                assert.ok('mnemonic' in wallet)\r
                assert.ok(/^(?:[a-z]{3,} ){11,23}[a-z]{3,}$/.test(wallet.mnemonic))\r
                assert.ok('seed' in wallet)\r