]> git.codecow.com Git - libnemo.git/commitdiff
Start merging Ledger functionality into main Wallet class and call out to static...
authorChris Duncan <chris@zoso.dev>
Tue, 19 Aug 2025 21:38:34 +0000 (14:38 -0700)
committerChris Duncan <chris@zoso.dev>
Tue, 19 Aug 2025 21:38:34 +0000 (14:38 -0700)
src/lib/block.ts
src/lib/wallet/accounts.ts
src/lib/wallet/create.ts
src/lib/wallet/destroy.ts
src/lib/wallet/index.ts
src/lib/wallet/ledger.ts
src/lib/wallet/load.ts
src/lib/wallet/lock.ts
src/lib/wallet/unlock.ts
src/lib/wallet/verify.ts
src/main.ts

index 7381ef1acd34b7f9aedcfce59730c99dcfadb105..ea3a5122c5ae7868f6fa79e98adcfba3ed321435 100644 (file)
@@ -427,18 +427,18 @@ export class Block {
                                        const wallet = input
                                        await wallet.sign(param, this)
                                } else if (typeof input === 'number') {
+                                       const { Ledger } = await import('./wallet/ledger')
                                        const index = input
-                                       const { Ledger } = await import('#wallet')
-                                       const ledger = await Ledger.create()
-                                       await ledger.connect()
+                                       const wallet = await Wallet.create('Ledger')
+                                       await wallet.unlock()
                                        if (param && param instanceof Block) {
                                                try {
-                                                       await ledger.updateCache(index, param)
+                                                       await Ledger.updateCache(index, param)
                                                } catch (err) {
                                                        console.warn('Error updating Ledger cache of previous block, attempting signature anyway', err)
                                                }
                                        }
-                                       await ledger.sign(index, this)
+                                       await Ledger.sign(index, this)
                                } else {
                                        throw new TypeError('Invalid key for block signature', { cause: input })
                                }
index 3aa3f9629012e8e08ad4e1fd0c9aa824fc0ec182..a9a716b2387b138dd0901c021b87b7921e632053 100644 (file)
@@ -1,12 +1,13 @@
 //! SPDX-FileCopyrightText: 2025 Chris Duncan <chris@zoso.dev>
 //! SPDX-License-Identifier: GPL-3.0-or-later
 
-import { KeyPair } from '#types'
+import { KeyPair, WalletType } from '#types'
 import { Vault } from '#vault'
 import { Account, AccountList } from '../account'
+import { Ledger } from './ledger'
 
-export async function _accounts (accounts: AccountList, vault: Vault, from: number, to: number): Promise<AccountList>
-export async function _accounts (accounts: AccountList, vault: Vault, from: unknown, to: unknown): Promise<AccountList> {
+export async function _accounts (type: WalletType, accounts: AccountList, vault: Vault, from: number, to: number): Promise<AccountList>
+export async function _accounts (type: WalletType, accounts: AccountList, vault: Vault, from: unknown, to: unknown): Promise<AccountList> {
        if (typeof from !== 'number' || typeof to !== 'number') {
                throw new TypeError('Invalid account range', { cause: `${from}-${to}` })
        }
@@ -21,22 +22,33 @@ export async function _accounts (accounts: AccountList, vault: Vault, from: unkn
                }
        }
        if (indexes.length > 0) {
-               const promises = []
-               for (const index of indexes) {
-                       promises.push(vault.request<ArrayBuffer>({
-                               action: 'derive',
-                               index
-                       }))
-               }
-               const publicKeys: KeyPair[] = await Promise.all(promises)
-               if (publicKeys.length > 0) {
-                       const publicAccounts = Account.load(publicKeys)
-                       for (const a of publicAccounts) {
-                               if (a.index == null) {
-                                       throw new RangeError('Index missing for Account')
+               const publicAccounts = []
+               if (type === 'Ledger') {
+                       for (const index of indexes) {
+                               const { status, publicKey } = await Ledger.account(index)
+                               if (status !== 'OK' || publicKey == null) {
+                                       throw new Error(`Error getting Ledger account: ${status}`)
                                }
-                               output[a.index] = accounts[a.index] = a
+                               publicAccounts.push(Account.load({ index, publicKey }))
+                       }
+               } else {
+                       const promises = []
+                       for (const index of indexes) {
+                               promises.push(vault.request<ArrayBuffer>({
+                                       action: 'derive',
+                                       index
+                               }))
+                       }
+                       const publicKeys: KeyPair[] = await Promise.all(promises)
+                       if (publicKeys.length > 0) {
+                               publicAccounts.push(...Account.load(publicKeys))
+                       }
+               }
+               for (const a of publicAccounts) {
+                       if (a.index == null) {
+                               throw new RangeError('Index missing for Account')
                        }
+                       output[a.index] = accounts[a.index] = a
                }
        }
        return output
index 8b83af8185f4cacb5cf8da764841a89b3cdc927b..63dc9e17ff3a39cb3c6ba05a0ebf04e28f9c590b 100644 (file)
@@ -6,34 +6,48 @@ import { Vault } from '#vault'
 import { Wallet } from '#wallet'
 import { utf8 } from '../convert'
 import { Database } from '../database'
+import { Ledger } from './ledger'
 import { _load } from './load'
 
-export async function _create (wallet: Wallet, vault: Vault, password: string, mnemonicSalt?: string): Promise<NamedData<ArrayBuffer>>
+export async function _create (wallet: Wallet, vault: Vault, password?: string, mnemonicSalt?: string): Promise<NamedData<ArrayBuffer>>
 export async function _create (wallet: Wallet, vault: Vault, password: unknown, mnemonicSalt?: unknown): Promise<NamedData<ArrayBuffer>> {
        try {
-               if (typeof password !== 'string') {
-                       throw new TypeError('Invalid password', { cause: typeof password })
-               }
-               if (mnemonicSalt !== undefined && typeof mnemonicSalt !== 'string') {
-                       throw new TypeError('Mnemonic salt must be a string')
-               }
-               const { iv, salt, encrypted, seed, mnemonic } = await vault.request<ArrayBuffer>({
-                       action: 'create',
-                       type: wallet.type,
-                       password: utf8.toBuffer(password),
-                       mnemonicSalt: mnemonicSalt ?? ''
-               })
-               password = undefined
-               mnemonicSalt = undefined
-               const record = {
+               const result: NamedData<ArrayBuffer> = {}
+               const record: NamedData = {
                        id: wallet.id,
-                       type: wallet.type,
-                       iv,
-                       salt,
-                       encrypted
+                       type: wallet.type
+               }
+               if (wallet.type === 'Ledger') {
+                       try {
+                               if (Ledger.isUnsupported) {
+                                       throw new Error('Browser is unsupported')
+                               }
+                       } catch (err) {
+                               throw new Error('Failed to initialize Ledger wallet', { cause: err })
+                       }
+               } else {
+                       if (typeof password !== 'string') {
+                               throw new TypeError('Invalid password', { cause: typeof password })
+                       }
+                       if (mnemonicSalt !== undefined && typeof mnemonicSalt !== 'string') {
+                               throw new TypeError('Mnemonic salt must be a string')
+                       }
+                       const response = await vault.request<ArrayBuffer>({
+                               action: 'create',
+                               type: wallet.type,
+                               password: utf8.toBuffer(password),
+                               mnemonicSalt: mnemonicSalt ?? ''
+                       })
+                       password = undefined
+                       mnemonicSalt = undefined
+                       record.iv = response.iv
+                       record.salt = response.salt
+                       record.encrypted = response.encrypted
+                       result.mnemonic = response.mnemonic
+                       result.seed = response.seed
                }
                await Database.add({ [wallet.id]: record }, Wallet.DB_NAME)
-               return { mnemonic, seed }
+               return result
        } catch (err) {
                await wallet.destroy()
                throw new Error('Error creating new Wallet', { cause: err })
index 133294a16cad63927a855edd6e27a023b66b05e4..57387d94e1061d4a603e18bc377b9428bf6c8ea8 100644 (file)
@@ -8,6 +8,9 @@ import { Database } from '../database'
 export async function _destroy (wallet: Wallet, vault: Vault) {
        try {
                vault.terminate()
+               if (wallet.type === 'Ledger') {
+                       wallet.lock()
+               }
                const isDeleted = await Database.delete(wallet.id, Wallet.DB_NAME)
                if (!isDeleted) {
                        throw new Error('Failed to delete wallet from database')
index 3a32e9f72fe8060b73d3686d97c1f139bd699b83..6af7806e51cde27bd635948fe78e754db7ac9ebc 100644 (file)
@@ -22,8 +22,6 @@ import { _unlock } from './unlock'
 import { _unopened } from './unopened'\r
 import { _verify } from './verify'\r
 \r
-export { Ledger } from './ledger'\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. Currently,\r
@@ -41,6 +39,13 @@ export class Wallet {
                return _backup()\r
        }\r
 \r
+       /**\r
+       * Creates a new Ledger wallet manager.\r
+       *\r
+       * @param {string} type - Encrypts the wallet to lock and unlock it\r
+       * @returns {Wallet} A newly instantiated Wallet\r
+       */\r
+       static async create (type: 'Ledger'): Promise<Wallet>\r
        /**\r
        * Creates a new HD wallet by using an entropy value generated using a\r
        * cryptographically strong pseudorandom number generator.\r
@@ -49,7 +54,8 @@ export class Wallet {
        * @param {string} [salt=''] - Used when generating the final seed\r
        * @returns {Wallet} A newly instantiated Wallet\r
        */\r
-       static async create (type: 'BIP-44' | 'BLAKE2b', password: string, mnemonicSalt?: string): Promise<Wallet> {\r
+       static async create (type: 'BIP-44' | 'BLAKE2b', password?: string, mnemonicSalt?: string): Promise<Wallet>\r
+       static async create (type: WalletType, password?: string, mnemonicSalt?: string): Promise<Wallet> {\r
                Wallet.#isInternal = true\r
                const self = new this(type)\r
                { ({ mnemonic: self.#mnemonic, seed: self.#seed } = await _create(self, self.#vault, password, mnemonicSalt)) }\r
@@ -109,7 +115,7 @@ export class Wallet {
 \r
        constructor (type: WalletType, id?: string)\r
        constructor (type: unknown, id?: string) {\r
-               if (!Wallet.#isInternal && type !== 'Ledger') {\r
+               if (!Wallet.#isInternal) {\r
                        throw new Error(`Wallet cannot be instantiated directly. Use 'await Wallet.create()' instead.`)\r
                }\r
                if (type !== 'BIP-44' && type !== 'BLAKE2b' && type !== 'Ledger') {\r
@@ -227,7 +233,7 @@ export class Wallet {
        * @returns {AccountList} Promise for a list of Accounts at the specified indexes\r
        */\r
        async accounts (from: number = 0, to: number = from): Promise<AccountList> {\r
-               return await _accounts(this.#accounts, this.#vault, from, to)\r
+               return await _accounts(this.type, this.#accounts, this.#vault, from, to)\r
        }\r
 \r
        /**\r
@@ -239,10 +245,15 @@ export class Wallet {
        }\r
 \r
        /**\r
-       * Clears the seed and mnemonic from the vault.\r
+       * For BIP-44 and BLAKE2b wallets, clears the seed and mnemonic from the vault.\r
+       *\r
+       * For Ledger hardware wallets, revokes permission granted by the user to\r
+       * access the Ledger device. The 'quit app' ADPU command is uncooperative, so\r
+       * this is currently the only way to ensure the connection is severed.\r
+       * `setTimeout` is used to expire any lingering transient user activation.\r
        */\r
        lock (): void {\r
-               _lock(this.#vault)\r
+               _lock(this, this.#vault)\r
        }\r
 \r
        /**\r
@@ -270,12 +281,17 @@ export class Wallet {
                await _sign(this.#vault, index, block)\r
        }\r
 \r
+       /**\r
+       * Attempts to connect to the Ledger device.\r
+       */\r
+       async unlock (): Promise<void>\r
        /**\r
        * Unlocks the wallet using the same password as used prior to lock it.\r
        *\r
        * @param {string} password Used previously to lock the wallet\r
        */\r
-       async unlock (password: string): Promise<void> {\r
+       async unlock (password: string): Promise<void>\r
+       async unlock (password?: string): Promise<void> {\r
                await _unlock(this, this.#vault, password)\r
        }\r
 \r
@@ -310,7 +326,7 @@ export class Wallet {
        */\r
        async verify (mnemonic: string): Promise<boolean>\r
        async verify (secret: string): Promise<boolean> {\r
-               return await _verify(this.#vault, secret)\r
+               return await _verify(this.type, this.#vault, secret)\r
        }\r
 \r
        static #isInternal: boolean = false\r
index 54a5a038baf34d9216dc92299a0322e08dbcfd38..2899d3857692091d2811d168a411ea12d811e022 100644 (file)
@@ -20,8 +20,9 @@ import { Rpc } from '../rpc'
 * private keys are held in the secure chip of the device. As such, the user\r
 * is responsible for using Ledger technology to back up these pieces of data.\r
 */\r
-export class Ledger extends Wallet {\r
+export class Ledger {\r
        static #isInternal: boolean = false\r
+       static #status: DeviceStatus = 'DISCONNECTED'\r
 \r
        static #ADPU_CODES: { [key: string]: number } = Object.freeze({\r
                class: 0xa1,\r
@@ -53,10 +54,12 @@ export class Ledger extends Wallet {
        })\r
 \r
        static DynamicTransport: typeof TransportBLE | typeof TransportUSB | typeof TransportHID\r
+       static UsbVendorId = ledgerUSBVendorId\r
        static SYMBOL: Symbol = Symbol('Ledger')\r
 \r
        static get #listenTimeout (): 30000 { return 30000 }\r
        static get #openTimeout (): 3000 { return 3000 }\r
+       static get status (): DeviceStatus { return this.#status }\r
 \r
        /**\r
        * Check which transport protocols are supported by the browser and return the\r
@@ -80,116 +83,38 @@ export class Ledger extends Wallet {
        }\r
 \r
        /**\r
-       * Creates a new Ledger hardware wallet communication layer by dynamically\r
-       * importing the ledger.js service.\r
+       * Request an account at a specific BIP-44 index.\r
        *\r
-       * @returns {Ledger} A wallet containing accounts and a Ledger device communication object\r
+       * @returns Response object containing command status, public key, and address\r
        */\r
-       static async create (): Promise<Ledger> {\r
-               try {\r
-                       if (this.isUnsupported) throw new Error('Browser is unsupported')\r
-                       this.#isInternal = true\r
-                       const self = new this()\r
-                       await Database.add({ [self.id]: { id: self.id, type: 'Ledger' } }, Wallet.DB_NAME)\r
-                       return self\r
-               } catch (err) {\r
-                       throw new Error('Failed to initialize Ledger wallet', { cause: err })\r
+       static async account (index: number = 0, show: boolean = false): Promise<LedgerAccountResponse> {\r
+               if (typeof index !== 'number' || index < 0 || index >= HARDENED_OFFSET) {\r
+                       throw new TypeError('Invalid account index')\r
                }\r
-       }\r
+               const account = dec.toBytes(index + HARDENED_OFFSET, 4)\r
+               const data = new Uint8Array([...Ledger.#DERIVATION_PATH, ...account])\r
 \r
-       /**\r
-       * Overrides `import()` from the base Wallet class since Ledger secrets cannot\r
-       * be extracted from the device.\r
-       */\r
-       static import (): Promise<Ledger> {\r
-               return Ledger.create()\r
-       }\r
+               const transport = await Ledger.DynamicTransport.create(Ledger.#openTimeout, Ledger.#listenTimeout)\r
+               const response = await transport\r
+                       .send(Ledger.#ADPU_CODES.class, Ledger.#ADPU_CODES.account, show ? 1 : 0, Ledger.#ADPU_CODES.paramUnused, data as Buffer)\r
+                       .catch(err => dec.toBytes(err.statusCode)) as Uint8Array\r
+               await transport.close()\r
 \r
-       private constructor () {\r
-               if (!Ledger.#isInternal) {\r
-                       throw new Error(`Ledger cannot be instantiated directly. Use 'await Ledger.create()' instead.`)\r
+               const statusCode = bytes.toDec(response.slice(-2)) as number\r
+               const status = Ledger.#STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR'\r
+               if (status !== 'OK') {\r
+                       return { status, publicKey: null, address: null }\r
                }\r
-               Ledger.#isInternal = false\r
-               super('Ledger')\r
-               this.#accounts = new AccountList()\r
-       }\r
-\r
-       get status (): DeviceStatus { return this.#status }\r
 \r
-       /**\r
-       * Gets the index and public key for an account from the Ledger device.\r
-       *\r
-       * @param {number} index - Wallet index of the account\r
-       * @returns Promise for the Account at the index specified\r
-       */\r
-       async account (index: number): Promise<Account> {\r
-               const { status, publicKey } = await Ledger.#account(index)\r
-               if (status !== 'OK' || publicKey == null) {\r
-                       throw new Error(`Error getting Ledger account: ${status}`)\r
-               }\r
-               return Account.load({ index, publicKey })\r
-       }\r
+               try {\r
+                       const publicKey = bytes.toHex(response.slice(0, 32))\r
+                       const addressLength = response[32]\r
+                       const address = response.slice(33, 33 + addressLength).toString()\r
 \r
-       /**\r
-       * Retrieves accounts from a Ledger wallet using its internal secure software.\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
-       * const accounts = await wallet.accounts(0, 1))\r
-       * // outputs the first and second account of the wallet\r
-       * console.log(accounts)\r
-       * // {\r
-       * //    0: {\r
-       * //            address: <...>,\r
-       * //            publicKey: <...>,\r
-       * //            index: 0,\r
-       * //            <etc...>\r
-       * //    },\r
-       * //    1: {\r
-       * //            address: <...>,\r
-       * //            publicKey: <...>,\r
-       * //            index: 1,\r
-       * //            <etc...>\r
-       * //    }\r
-       * // }\r
-       * // individual accounts can be referenced using array index notation\r
-       * console.log(accounts[1])\r
-       * // { address: <...>, publicKey: <...>, index: 1, <etc...> }\r
-       * ```\r
-       *\r
-       * If `from` is greater than `to`, their values will be swapped.\r
-       * @param {number} from - Start index of accounts. Default: 0\r
-       * @param {number} to - End index of accounts. Default: `from`\r
-       * @returns {AccountList} Promise for a list of Accounts at the specified indexes\r
-       */\r
-       async accounts (from: number = 0, to: number = from): Promise<AccountList> {\r
-               if (from > to) [from, to] = [to, from]\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 publicAccounts = []\r
-                       for (const index of indexes) {\r
-                               publicAccounts.push(await this.account(index))\r
-                       }\r
-                       for (const a of publicAccounts) {\r
-                               if (a.index == null) {\r
-                                       throw new RangeError('Index missing for Account')\r
-                               }\r
-                               output[a.index] = this.#accounts[a.index] = a\r
-                       }\r
+                       return { status, publicKey, address }\r
+               } catch (err) {\r
+                       return { status: 'ERROR_PARSING_ACCOUNT', publicKey: null, address: null }\r
                }\r
-               return output\r
        }\r
 \r
        /**\r
@@ -201,12 +126,12 @@ export class Ledger extends Wallet {
        * - LOCKED: Nano app is open but the device locked after a timeout\r
        * - CONNECTED: Nano app is open and listening\r
        */\r
-       async connect (): Promise<DeviceStatus> {\r
-               const version = await this.#version()\r
+       static async connect (): Promise<DeviceStatus> {\r
+               const version = await this.version()\r
                if (version.status !== 'OK') {\r
                        this.#status = 'DISCONNECTED'\r
                } else if (version.name === 'Nano') {\r
-                       const { status } = await Ledger.#account()\r
+                       const { status } = await Ledger.account()\r
                        if (status === 'OK') {\r
                                this.#status = 'CONNECTED'\r
                        } else if (status === 'SECURITY_STATUS_NOT_SATISFIED') {\r
@@ -220,36 +145,6 @@ export class Ledger extends Wallet {
                return this.#status\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
-               await super.destroy()\r
-               this.lock()\r
-       }\r
-\r
-       /**\r
-       * Revokes permission granted by the user to access the Ledger device.\r
-       *\r
-       * The 'quit app' ADPU command has not passed testing, so this is the only way\r
-       * to ensure the connection is severed at this time. `setTimeout` is used to\r
-       * expire any lingering transient user activation.\r
-       *\r
-       * Overrides the default wallet `lock()` method since as a hardware wallet it\r
-       * does not need to be encrypted by software.\r
-       */\r
-       lock (): void {\r
-               setTimeout(async () => {\r
-                       const devices = await globalThis.navigator.usb.getDevices()\r
-                       for (const device of devices) {\r
-                               if (device.vendorId === ledgerUSBVendorId) {\r
-                                       device.forget()\r
-                               }\r
-                       }\r
-               })\r
-       }\r
-\r
        /**\r
        * Sign a block with the Ledger device.\r
        *\r
@@ -257,8 +152,8 @@ export class Ledger extends Wallet {
        * @param {Block} block - Block data to sign\r
        * @param {Block} [frontier] - Previous block data to cache in the device\r
        */\r
-       async sign (index: number, block: Block, frontier?: Block): Promise<void>\r
-       async sign (index: number, block: Block, frontier?: Block): Promise<void> {\r
+       static async sign (index: number, block: Block, frontier?: Block): Promise<void>\r
+       static async sign (index: number, block: Block, frontier?: Block): Promise<void> {\r
                try {\r
                        if (typeof index !== 'number') {\r
                                throw new TypeError('Index must be a number', { cause: index })\r
@@ -267,13 +162,13 @@ export class Ledger extends Wallet {
                                throw new RangeError(`Index outside allowed range 0-${HARDENED_OFFSET}`, { cause: index })\r
                        }\r
                        if (frontier != null) {\r
-                               const { status } = await this.#cacheBlock(index, frontier)\r
+                               const { status } = await Ledger.#cacheBlock(index, frontier)\r
                                if (status !== 'OK') {\r
                                        throw new Error('Failed to cache frontier block in ledger', { cause: status })\r
                                }\r
                        }\r
                        console.log('Waiting for signature confirmation on Ledger device...')\r
-                       const { status, signature, hash } = await this.#signBlock(index, block)\r
+                       const { status, signature, hash } = await Ledger.#signBlock(index, block)\r
                        if (status !== 'OK') {\r
                                throw new Error('Signing with ledger failed', { cause: status })\r
                        }\r
@@ -290,28 +185,13 @@ export class Ledger extends Wallet {
                }\r
        }\r
 \r
-       /**\r
-       * Attempts to connect to the Ledger device.\r
-       *\r
-       * Overrides the default wallet `unlock()` method since as a hardware wallet it\r
-       * does not need to be encrypted by software.\r
-       *\r
-       * @returns True if successfully unlocked\r
-       */\r
-       async unlock (): Promise<void> {\r
-               const status = await this.connect()\r
-               if (await status !== 'CONNECTED') {\r
-                       throw new Error('Failed to unlock wallet', { cause: status })\r
-               }\r
-       }\r
-\r
        /**\r
        * Update cache from raw block data. Suitable for offline use.\r
        *\r
        * @param {number} index - Account number\r
        * @param {object} block - JSON-formatted block data\r
        */\r
-       async updateCache (index: number, block: Block): Promise<LedgerResponse>\r
+       static async updateCache (index: number, block: Block): Promise<LedgerResponse>\r
        /**\r
        * Update cache from a block hash by calling out to a node. Suitable for online\r
        * use only.\r
@@ -320,8 +200,8 @@ export class Ledger extends Wallet {
        * @param {string} hash - Hexadecimal block hash\r
        * @param {Rpc} rpc - Rpc class object with a node URL\r
        */\r
-       async updateCache (index: number, hash: string, rpc: Rpc): Promise<LedgerResponse>\r
-       async updateCache (index: number, input: any, node?: Rpc): Promise<LedgerResponse> {\r
+       static async updateCache (index: number, hash: string, rpc: Rpc): Promise<LedgerResponse>\r
+       static async updateCache (index: number, input: any, node?: Rpc): Promise<LedgerResponse> {\r
                if (typeof input === 'string' && node instanceof Rpc) {\r
                        const data = {\r
                                'json_block': 'true',\r
@@ -340,6 +220,35 @@ export class Ledger extends Wallet {
                return { status }\r
        }\r
 \r
+       /**\r
+       * Get the version of the current process. If a specific app is running, get\r
+       * the app version. Otherwise, get the Ledger BOLOS version instead.\r
+       *\r
+       * https://developers.ledger.com/docs/connectivity/ledgerJS/open-close-info-on-apps#get-information\r
+       *\r
+       * @returns Status, process name, and version\r
+       */\r
+       static async version (): Promise<LedgerVersionResponse> {\r
+               const transport = await Ledger.DynamicTransport.create(Ledger.#openTimeout, Ledger.#listenTimeout)\r
+               const response = await transport\r
+                       .send(0xb0, Ledger.#ADPU_CODES.version, Ledger.#ADPU_CODES.paramUnused, Ledger.#ADPU_CODES.paramUnused)\r
+                       .catch(err => dec.toBytes(err.statusCode)) as Uint8Array\r
+               await transport.close()\r
+\r
+               const statusCode = bytes.toDec(response.slice(-2)) as number\r
+               const status = Ledger.#STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR'\r
+               if (status !== 'OK') {\r
+                       return { status, name: null, version: null }\r
+               }\r
+\r
+               const nameLength = response[1]\r
+               const name = response.slice(2, 2 + nameLength).toString()\r
+               const versionLength = response[2 + nameLength]\r
+               const version = response.slice(2 + nameLength + 1, 2 + nameLength + 1 + versionLength).toString()\r
+\r
+               return { status, name, version }\r
+       }\r
+\r
        /**\r
        * Checks whether a given seed matches the wallet seed. The wallet must be\r
        * unlocked prior to verification.\r
@@ -347,7 +256,7 @@ export class Ledger extends Wallet {
        * @param {string} seed - Hexadecimal seed to be matched against the wallet data\r
        * @returns True if input matches wallet seed\r
        */\r
-       async verify (seed: string): Promise<boolean>\r
+       static async verify (seed: string): Promise<boolean>\r
        /**\r
        * Checks whether a given mnemonic phrase matches the wallet mnemonic. If a\r
        * personal salt was used when generating the mnemonic, it cannot be verified.\r
@@ -356,8 +265,8 @@ export class Ledger extends Wallet {
        * @param {string} mnemonic - Phrase to be matched against the wallet data\r
        * @returns True if input matches wallet mnemonic\r
        */\r
-       async verify (mnemonic: string): Promise<boolean>\r
-       async verify (secret: string): Promise<boolean> {\r
+       static async verify (mnemonic: string): Promise<boolean>\r
+       static async verify (secret: string): Promise<boolean> {\r
                const testWallet = await Wallet.load('BIP-44', '', secret)\r
                await testWallet.unlock('')\r
                const testAccount = await testWallet.account(0)\r
@@ -368,28 +277,13 @@ export class Ledger extends Wallet {
                        .send(testAccount.address, 0)\r
                await testWallet.sign(0, testOpenBlock)\r
                try {\r
-                       await this.sign(0, testSendBlock, testOpenBlock)\r
+                       await Ledger.sign(0, testSendBlock, testOpenBlock)\r
                        return testSendBlock.signature === testOpenBlock.signature\r
                } catch (err) {\r
                        throw new Error('Failed to verify wallet', { cause: err })\r
                }\r
        }\r
 \r
-       /**\r
-       * Get the version of the current process. If a specific app is running, get\r
-       * the app version. Otherwise, get the Ledger BOLOS version instead.\r
-       *\r
-       * https://developers.ledger.com/docs/connectivity/ledgerJS/open-close-info-on-apps#get-information\r
-       *\r
-       * @returns Status, process name, and version\r
-       */\r
-       async version (): Promise<LedgerVersionResponse> {\r
-               return await this.#version()\r
-       }\r
-\r
-       #accounts: AccountList\r
-       #status: DeviceStatus = 'DISCONNECTED'\r
-\r
        /**\r
        * Close the currently running app and return to the device dashboard.\r
        *\r
@@ -412,41 +306,6 @@ export class Ledger extends Wallet {
                return new Promise(r => setTimeout(r, 1000, { status: Ledger.#STATUS_CODES[response] }))\r
        }\r
 \r
-       /**\r
-       * Request an account at a specific BIP-44 index.\r
-       *\r
-       * @returns Response object containing command status, public key, and address\r
-       */\r
-       static async #account (index: number = 0, show: boolean = false): Promise<LedgerAccountResponse> {\r
-               if (typeof index !== 'number' || index < 0 || index >= HARDENED_OFFSET) {\r
-                       throw new TypeError('Invalid account index')\r
-               }\r
-               const account = dec.toBytes(index + HARDENED_OFFSET, 4)\r
-               const data = new Uint8Array([...Ledger.#DERIVATION_PATH, ...account])\r
-\r
-               const transport = await Ledger.DynamicTransport.create(Ledger.#openTimeout, Ledger.#listenTimeout)\r
-               const response = await transport\r
-                       .send(Ledger.#ADPU_CODES.class, Ledger.#ADPU_CODES.account, show ? 1 : 0, Ledger.#ADPU_CODES.paramUnused, data as Buffer)\r
-                       .catch(err => dec.toBytes(err.statusCode)) as Uint8Array\r
-               await transport.close()\r
-\r
-               const statusCode = bytes.toDec(response.slice(-2)) as number\r
-               const status = Ledger.#STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR'\r
-               if (status !== 'OK') {\r
-                       return { status, publicKey: null, address: null }\r
-               }\r
-\r
-               try {\r
-                       const publicKey = bytes.toHex(response.slice(0, 32))\r
-                       const addressLength = response[32]\r
-                       const address = response.slice(33, 33 + addressLength).toString()\r
-\r
-                       return { status, publicKey, address }\r
-               } catch (err) {\r
-                       return { status: 'ERROR_PARSING_ACCOUNT', publicKey: null, address: null }\r
-               }\r
-       }\r
-\r
        /**\r
        * Cache frontier block in device memory.\r
        *\r
@@ -454,7 +313,7 @@ export class Ledger extends Wallet {
        * @param {any} block - Block data to cache\r
        * @returns Status of command\r
        */\r
-       async #cacheBlock (index: number = 0, block: Block): Promise<LedgerResponse> {\r
+       static async #cacheBlock (index: number = 0, block: Block): Promise<LedgerResponse> {\r
                if (typeof index !== 'number' || index < 0 || index >= HARDENED_OFFSET) {\r
                        throw new TypeError('Invalid account index')\r
                }\r
@@ -491,7 +350,7 @@ export class Ledger extends Wallet {
                return { status: Ledger.#STATUS_CODES[response] }\r
        }\r
 \r
-       #onConnectUsb = async (e: USBConnectionEvent): Promise<void> => {\r
+       static #onConnectUsb = async (e: USBConnectionEvent): Promise<void> => {\r
                console.log(e)\r
                if (e.device?.vendorId === ledgerUSBVendorId) {\r
                        console.log('Ledger connected')\r
@@ -501,7 +360,7 @@ export class Ledger extends Wallet {
                }\r
        }\r
 \r
-       #onDisconnectUsb = async (e: USBConnectionEvent): Promise<void> => {\r
+       static #onDisconnectUsb = async (e: USBConnectionEvent): Promise<void> => {\r
                console.log(e)\r
                if (e.device?.vendorId === ledgerUSBVendorId) {\r
                        console.log('Ledger disconnected')\r
@@ -542,7 +401,7 @@ export class Ledger extends Wallet {
        * @param {object} block - Block data to sign\r
        * @returns {Promise} Status, signature, and block hash\r
        */\r
-       async #signBlock (index: number, block: Block): Promise<LedgerSignResponse> {\r
+       static async #signBlock (index: number, block: Block): Promise<LedgerSignResponse> {\r
                if (typeof index !== 'number' || index < 0 || index >= HARDENED_OFFSET) {\r
                        throw new TypeError('Invalid account index')\r
                }\r
@@ -620,33 +479,4 @@ export class Ledger extends Wallet {
 \r
                throw new Error('Unexpected byte length from device signature', { cause: response })\r
        }\r
-\r
-       /**\r
-       * Get the version of the current process. If a specific app is running, get\r
-       * the app version. Otherwise, get the Ledger BOLOS version instead.\r
-       *\r
-       * https://developers.ledger.com/docs/connectivity/ledgerJS/open-close-info-on-apps#get-information\r
-       *\r
-       * @returns Status, process name, and version\r
-       */\r
-       async #version (): Promise<LedgerVersionResponse> {\r
-               const transport = await Ledger.DynamicTransport.create(Ledger.#openTimeout, Ledger.#listenTimeout)\r
-               const response = await transport\r
-                       .send(0xb0, Ledger.#ADPU_CODES.version, Ledger.#ADPU_CODES.paramUnused, Ledger.#ADPU_CODES.paramUnused)\r
-                       .catch(err => dec.toBytes(err.statusCode)) as Uint8Array\r
-               await transport.close()\r
-\r
-               const statusCode = bytes.toDec(response.slice(-2)) as number\r
-               const status = Ledger.#STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR'\r
-               if (status !== 'OK') {\r
-                       return { status, name: null, version: null }\r
-               }\r
-\r
-               const nameLength = response[1]\r
-               const name = response.slice(2, 2 + nameLength).toString()\r
-               const versionLength = response[2 + nameLength]\r
-               const version = response.slice(2 + nameLength + 1, 2 + nameLength + 1 + versionLength).toString()\r
-\r
-               return { status, name, version }\r
-       }\r
 }\r
index d19558b814e9b7e5e79c28a851502c54aa8ee2cc..22b1233614801097c516f244fdb8803762c46cb7 100644 (file)
@@ -7,43 +7,52 @@ import { Vault } from '#vault'
 import { Wallet } from '#wallet'
 import { hex, utf8 } from '../convert'
 import { Database } from '../database'
+import { Ledger } from './ledger'
 
 export async function _load (wallet: Wallet, vault: Vault, password: string, secret: string, mnemonicSalt?: string): Promise<void>
 export async function _load (wallet: Wallet, vault: Vault, password: unknown, secret: unknown, mnemonicSalt?: unknown): Promise<void> {
        try {
-               if (typeof password !== 'string') {
-                       throw new TypeError('Password must be a string')
-               }
-               if (typeof secret !== 'string') {
-                       throw new TypeError('Wallet secret must be a string')
-               }
-               if (mnemonicSalt !== undefined && typeof mnemonicSalt !== 'string') {
-                       throw new TypeError('Mnemonic salt must be a string')
-               }
-               const data: NamedData = {
-                       action: 'load',
-                       type: wallet.type,
-                       password: utf8.toBuffer(password)
+               const record: NamedData = {
+                       id: wallet.id,
+                       type: wallet.type
                }
-               password = undefined
-               if (/^(?:[A-F0-9]{64}){1,2}$/i.test(secret)) {
-                       data.seed = hex.toBuffer(secret)
-               } else if (await Bip39.validate(secret)) {
-                       data.mnemonicPhrase = secret.toLowerCase()
-                       if (mnemonicSalt != null) data.mnemonicSalt = mnemonicSalt
+               if (wallet.type === 'Ledger') {
+                       if (Ledger.isUnsupported) {
+                               throw new Error('Failed to initialize Ledger wallet', { cause: 'Browser is unsupported' })
+                       }
                } else {
-                       throw new TypeError('Invalid wallet data')
-               }
-               secret = undefined
-               mnemonicSalt = undefined
-               const result = vault.request<ArrayBuffer>(data)
-               const { iv, salt, encrypted } = await result
-               const record = {
-                       id: wallet.id,
-                       type: wallet.type,
-                       iv,
-                       salt,
-                       encrypted
+                       if (wallet.type !== 'BIP-44' && wallet.type !== 'BLAKE2b') {
+                               throw new TypeError('Invalid wallet type', { cause: wallet.type })
+                       }
+                       if (typeof password !== 'string') {
+                               throw new TypeError('Password must be a string')
+                       }
+                       if (typeof secret !== 'string') {
+                               throw new TypeError('Wallet secret must be a string')
+                       }
+                       if (mnemonicSalt !== undefined && typeof mnemonicSalt !== 'string') {
+                               throw new TypeError('Mnemonic salt must be a string')
+                       }
+                       const data: NamedData = {
+                               action: 'load',
+                               type: wallet.type,
+                               password: utf8.toBuffer(password)
+                       }
+                       password = undefined
+                       if (/^(?:[A-F0-9]{64}){1,2}$/i.test(secret)) {
+                               data.seed = hex.toBuffer(secret)
+                       } else if (await Bip39.validate(secret)) {
+                               data.mnemonicPhrase = secret.toLowerCase()
+                               if (mnemonicSalt != null) data.mnemonicSalt = mnemonicSalt
+                       } else {
+                               throw new TypeError('Invalid wallet data')
+                       }
+                       secret = undefined
+                       mnemonicSalt = undefined
+                       const response = await vault.request<ArrayBuffer>(data)
+                       record.iv = response.iv
+                       record.salt = response.salt
+                       record.encrypted = response.encrypted
                }
                await Database.add({ [wallet.id]: record }, Wallet.DB_NAME)
        } catch (err) {
index 75cccbb7728066424863316666e4d75de4956219..863654b747c8724b499748e59a142ab82711535f 100644 (file)
@@ -2,15 +2,27 @@
 //! SPDX-License-Identifier: GPL-3.0-or-later
 
 import { Vault } from '#vault'
+import { Wallet } from '#wallet'
+import { Ledger } from './ledger'
 
-export async function _lock (vault: Vault): Promise<void>
-export async function _lock (vault: Vault): Promise<void> {
+export async function _lock (wallet: Wallet, vault: Vault): Promise<void> {
        try {
-               const { isLocked } = await vault.request<boolean>({
-                       action: 'lock'
-               })
-               if (!isLocked) {
-                       throw new Error('Lock request to Vault failed')
+               if (wallet.type === 'Ledger') {
+                       setTimeout(async () => {
+                               const devices = await globalThis.navigator.usb.getDevices()
+                               for (const device of devices) {
+                                       if (device.vendorId === Ledger.UsbVendorId) {
+                                               device.forget()
+                                       }
+                               }
+                       })
+               } else {
+                       const { isLocked } = await vault.request<boolean>({
+                               action: 'lock'
+                       })
+                       if (!isLocked) {
+                               throw new Error('Lock request to Vault failed')
+                       }
                }
        } catch (err) {
                throw new Error('Failed to lock wallet', { cause: err })
index 90209aeb12f28dc590cc1e1d4758b6f1db3128c6..135a1c196b0c4e74ccb141740a9cd8b3480e8715 100644 (file)
@@ -5,24 +5,32 @@ import { Vault } from '#vault'
 import { Wallet } from '#wallet'
 import { utf8 } from '../convert'
 import { _get } from './get'
+import { Ledger } from './ledger'
 
-export async function _unlock (wallet: Wallet, vault: Vault, password: string): Promise<void>
+export async function _unlock (wallet: Wallet, vault: Vault, password?: string): Promise<void>
 export async function _unlock (wallet: Wallet, vault: Vault, password: unknown): Promise<void> {
        try {
-               if (typeof password !== 'string') {
-                       throw new TypeError('Password must be a string')
-               }
-               const { iv, salt, encrypted } = await _get(wallet.id)
-               const { isUnlocked } = await vault.request<boolean>({
-                       action: 'unlock',
-                       type: wallet.type,
-                       password: utf8.toBuffer(password),
-                       iv,
-                       keySalt: salt,
-                       encrypted
-               })
-               if (!isUnlocked) {
-                       throw new Error('Unlock request to Vault failed')
+               if (wallet.type === 'Ledger') {
+                       const status = await Ledger.connect()
+                       if (await status !== 'CONNECTED') {
+                               throw new Error('Failed to unlock wallet', { cause: status })
+                       }
+               } else {
+                       if (typeof password !== 'string') {
+                               throw new TypeError('Password must be a string')
+                       }
+                       const { iv, salt, encrypted } = await _get(wallet.id)
+                       const { isUnlocked } = await vault.request<boolean>({
+                               action: 'unlock',
+                               type: wallet.type,
+                               password: utf8.toBuffer(password),
+                               iv,
+                               keySalt: salt,
+                               encrypted
+                       })
+                       if (!isUnlocked) {
+                               throw new Error('Unlock request to Vault failed')
+                       }
                }
        } catch (err) {
                throw new Error('Failed to unlock wallet', { cause: err })
index 9fcf2679739f2a3a46a6951970e70260a2061a81..1d530d1261910f8a9dd1e997f742d8f2880b1466 100644 (file)
@@ -1,29 +1,34 @@
 //! SPDX-FileCopyrightText: 2025 Chris Duncan <chris@zoso.dev>
 //! SPDX-License-Identifier: GPL-3.0-or-later
 
-import { NamedData } from '#types'
+import { NamedData, WalletType } from '#types'
 import { Vault } from '#vault'
 import { hex } from '../convert'
+import { Ledger } from './ledger'
 
-export async function _verify (vault: Vault, secret: string): Promise<boolean>
-export async function _verify (vault: Vault, secret: unknown): Promise<boolean> {
+export async function _verify (type: WalletType, vault: Vault, secret: string): Promise<boolean>
+export async function _verify (type: WalletType, vault: Vault, secret: unknown): Promise<boolean> {
        try {
                if (typeof secret !== 'string') {
                        throw new TypeError('Wallet secret must be a string', { cause: typeof secret })
                }
-               const data: NamedData = {
-                       action: 'verify'
-               }
-               if (/^(?:[A-F0-9]{64}){1,2}$/i.test(secret)) {
-                       data.seed = hex.toBuffer(secret)
-               } else if (/^([a-z]{3,8} ){11,23}[a-z]{3,8}$/i.test(secret)) {
-                       data.mnemonicPhrase = secret.toLowerCase()
+               if (type === 'Ledger') {
+                       return await Ledger.verify(secret)
                } else {
-                       throw new TypeError('Invalid format')
+                       const data: NamedData = {
+                               action: 'verify'
+                       }
+                       if (/^(?:[A-F0-9]{64}){1,2}$/i.test(secret)) {
+                               data.seed = hex.toBuffer(secret)
+                       } else if (/^([a-z]{3,8} ){11,23}[a-z]{3,8}$/i.test(secret)) {
+                               data.mnemonicPhrase = secret.toLowerCase()
+                       } else {
+                               throw new TypeError('Invalid format')
+                       }
+                       const result = await vault.request<boolean>(data)
+                       const { isVerified } = result
+                       return isVerified
                }
-               const result = await vault.request<boolean>(data)
-               const { isVerified } = result
-               return isVerified
        } catch (err) {
                throw new Error('Failed to verify wallet', { cause: err })
        }
index e3746f2cea51dba453a4d89e3543182525a74e36..dd9a3d07f6874a2dcfe5373e5bf3d67ab35049cd 100644 (file)
@@ -2,7 +2,7 @@
 //! SPDX-License-Identifier: GPL-3.0-or-later
 
 import { Blake2b } from '#crypto'
-import { Ledger, Wallet } from '#wallet'
+import { Wallet } from '#wallet'
 import { Account } from './lib/account'
 import { Block } from './lib/block'
 import { Rolodex } from './lib/rolodex'
@@ -13,7 +13,6 @@ export {
        Account,
        Blake2b,
        Block,
-       Ledger,
        Rolodex,
        Rpc,
        Tools,