From: Chris Duncan Date: Tue, 15 Jul 2025 12:25:52 +0000 (-0700) Subject: Merge base wallet with barrel module. X-Git-Tag: v0.10.5~57^2~19 X-Git-Url: https://git.codecow.com/?a=commitdiff_plain;h=11083780afeb2804f6b944a991d361fe87cf017e;p=libnemo.git Merge base wallet with barrel module. --- diff --git a/src/lib/wallets/bip44-wallet.ts b/src/lib/wallets/bip44-wallet.ts index 3959cf9..21c2538 100644 --- a/src/lib/wallets/bip44-wallet.ts +++ b/src/lib/wallets/bip44-wallet.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2025 Chris Duncan // SPDX-License-Identifier: GPL-3.0-or-later -import { KeyPair, Wallet } from './wallet' +import { KeyPair, Wallet } from '.' 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' diff --git a/src/lib/wallets/blake2b-wallet.ts b/src/lib/wallets/blake2b-wallet.ts index 5dfed71..9ace17a 100644 --- a/src/lib/wallets/blake2b-wallet.ts +++ b/src/lib/wallets/blake2b-wallet.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2025 Chris Duncan // SPDX-License-Identifier: GPL-3.0-or-later -import { KeyPair, Wallet } from './wallet' +import { KeyPair, Wallet } from '.' import { Bip39Mnemonic } from '#src/lib/bip39-mnemonic.js' import { Blake2b } from '#src/lib/blake2b.js' import { SEED_LENGTH_BLAKE2B } from '#src/lib/constants.js' diff --git a/src/lib/wallets/index.ts b/src/lib/wallets/index.ts index 574fc70..b7161e4 100644 --- a/src/lib/wallets/index.ts +++ b/src/lib/wallets/index.ts @@ -1,6 +1,303 @@ // 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 { Queue } from '#src/lib/pool.js' +import { Rpc } from '#src/lib/rpc.js' +import { SafeWorker } from '#workers' + 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/ledger-wallet.ts b/src/lib/wallets/ledger-wallet.ts index dbf630d..6601020 100644 --- a/src/lib/wallets/ledger-wallet.ts +++ b/src/lib/wallets/ledger-wallet.ts @@ -10,7 +10,7 @@ import { BIP44_COIN_NANO, BIP44_PURPOSE, HARDENED_OFFSET, LEDGER_ADPU_CODES, LED import { bytes, dec, hex } from '#src/lib/convert.js' import { Entropy } from '#src/lib/entropy.js' import { Rpc } from '#src/lib/rpc.js' -import { KeyPair, Wallet } from './wallet' +import { KeyPair, Wallet } from '.' type DeviceStatus = 'DISCONNECTED' | 'BUSY' | 'LOCKED' | 'CONNECTED' diff --git a/src/lib/wallets/wallet.ts b/src/lib/wallets/wallet.ts deleted file mode 100644 index 795fc3b..0000000 --- a/src/lib/wallets/wallet.ts +++ /dev/null @@ -1,299 +0,0 @@ -// 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, hex, utf8 } from '#src/lib/convert.js' -import { Entropy } from '#src/lib/entropy.js' -import { Queue } from '#src/lib/pool.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 - - 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) - } -}