From d1cd133cdd18a1d85909e2da2b7473fe1dd8f50c Mon Sep 17 00:00:00 2001 From: Chris Duncan Date: Tue, 16 Sep 2025 14:51:14 -0700 Subject: [PATCH] Dynamically export Ledger class and start building additional support for its features in Wallet and tests. --- src/lib/ledger.ts | 10 ++++- src/lib/wallet/index.ts | 11 ++++- src/main.ts | 6 +++ src/types.d.ts | 90 +++++++++++++++++++++++++++++++++++++++-- test/test.ledger.mjs | 74 ++++++++++++++++++++++++++++----- 5 files changed, 174 insertions(+), 17 deletions(-) diff --git a/src/lib/ledger.ts b/src/lib/ledger.ts index dce4658..b892477 100644 --- a/src/lib/ledger.ts +++ b/src/lib/ledger.ts @@ -24,6 +24,7 @@ import { Wallet } from './wallet' export class Ledger { static #listenTimeout: 30000 = 30000 static #openTimeout: 3000 = 3000 + static #polling: number | NodeJS.Timeout = 0 static #status: LedgerStatus = 'DISCONNECTED' static #transport: typeof TransportHID | typeof TransportBLE | typeof TransportUSB static #ADPU_CODES: { [key: string]: number } = Object.freeze({ @@ -60,9 +61,10 @@ export class Ledger { * transport type according to the following priorities: HID, Bluetooth, USB. */ static get isUnsupported (): boolean { - if (this.#transport === undefined) { - console.log('Checking browser Ledger support...') + if (this.#transport !== undefined) { + return false } + console.log('Checking browser Ledger support...') if (typeof globalThis.navigator?.hid?.getDevices === 'function') { this.#transport ??= TransportHID return false @@ -381,6 +383,7 @@ export class Ledger { console.log(e) if (e.device?.vendorId === ledgerUSBVendorId) { console.log('Ledger connected via HID') + this.#polling = setInterval(this.connect, 1000) const { hid } = globalThis.navigator hid.addEventListener('disconnect', this.#onDisconnectHid) hid.removeEventListener('connect', this.#onConnectHid) @@ -391,6 +394,7 @@ export class Ledger { console.log(e) if (e.device?.vendorId === ledgerUSBVendorId) { console.log('Ledger disconnected via HID') + clearInterval(this.#polling) const { hid } = globalThis.navigator hid.addEventListener('connect', this.#onConnectHid) hid.removeEventListener('disconnect', this.#onDisconnectHid) @@ -402,6 +406,7 @@ export class Ledger { console.log(e) if (e.device?.vendorId === ledgerUSBVendorId) { console.log('Ledger connected via USB') + this.#polling = setInterval(this.connect, 1000) const { usb } = globalThis.navigator usb.addEventListener('disconnect', this.#onDisconnectUsb) usb.removeEventListener('connect', this.#onConnectUsb) @@ -412,6 +417,7 @@ export class Ledger { console.log(e) if (e.device?.vendorId === ledgerUSBVendorId) { console.log('Ledger disconnected via USB') + clearInterval(this.#polling) const { usb } = globalThis.navigator usb.addEventListener('connect', this.#onConnectUsb) usb.removeEventListener('disconnect', this.#onDisconnectUsb) diff --git a/src/lib/wallet/index.ts b/src/lib/wallet/index.ts index e567420..c73f2c1 100644 --- a/src/lib/wallet/index.ts +++ b/src/lib/wallet/index.ts @@ -31,6 +31,7 @@ import { _verify } from './verify' */ export class Wallet { static get DB_NAME (): 'Wallet' { return 'Wallet' } + static #ledger: typeof import('../ledger').Ledger /** * Retrieves all wallets with encrypted secrets and unencrypted metadata from @@ -60,6 +61,7 @@ export class Wallet { */ static async create (type: 'BIP-44' | 'BLAKE2b', password: string, mnemonicSalt?: string): Promise static async create (type: WalletType, password?: string, mnemonicSalt?: string): Promise { + Wallet.#ledger ??= (await import('../ledger')).Ledger Wallet.#isInternal = true const self = new this(type) { ({ mnemonic: self.#mnemonic, seed: self.#seed } = await _create(self, self.#vault, password, mnemonicSalt)) } @@ -88,9 +90,13 @@ export class Wallet { */ static async load (type: 'BIP-44' | 'BLAKE2b', password: string, mnemonicPhrase: string, mnemonicSalt?: string): Promise static async load (type: WalletType, password: string, secret: string, mnemonicSalt?: string): Promise { + Wallet.#ledger ??= (await import('../ledger')).Ledger Wallet.#isInternal = true const self = new this(type) await _load(self, self.#vault, password, secret, mnemonicSalt) + if (type === 'Ledger' && Wallet.#ledger === undefined) { + Wallet.#ledger = (await import('../ledger')).Ledger + } return self } @@ -109,6 +115,7 @@ export class Wallet { */ static async restore (): Promise static async restore (id?: string): Promise { + Wallet.#ledger ??= (await import('../ledger')).Ledger const backups = await _restore(id) const wallets = backups.map(backup => { Wallet.#isInternal = true @@ -141,7 +148,9 @@ export class Wallet { * @returns True if the wallet is locked, else false */ get isLocked (): boolean { - return this.#vault.isLocked + return this.type === 'Ledger' + ? Wallet.#ledger.status !== 'CONNECTED' + : this.#vault.isLocked } /** diff --git a/src/main.ts b/src/main.ts index 432b4b9..317289d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,10 +9,16 @@ import { Rpc } from './lib/rpc' import { Tools } from './lib/tools' import { Wallet } from './lib/wallet' +let Ledger: typeof import('./lib/ledger').Ledger +try { + Ledger = (await import('./lib/ledger')).Ledger +} catch { } + export { Account, Blake2b, Block, + Ledger, Rolodex, Rpc, Tools, diff --git a/src/types.d.ts b/src/types.d.ts index f4839d5..4ed9995 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -1,9 +1,7 @@ //! SPDX-FileCopyrightText: 2025 Chris Duncan //! SPDX-License-Identifier: GPL-3.0-or-later -import { default as TransportBLE } from '@ledgerhq/hw-transport-web-ble' -import { default as TransportUSB } from '@ledgerhq/hw-transport-webusb' -import { default as TransportHID } from '@ledgerhq/hw-transport-webhid' +import { ledgerUSBVendorId } from '@ledgerhq/devices' /** * Represents a single Nano address and the associated public key. To include the @@ -471,6 +469,92 @@ interface LedgerSignResponse extends LedgerResponse { hash?: string } +/** +* Ledger hardware wallet created by communicating with a Ledger device via ADPU +* calls. This wallet does not feature any seed nor mnemonic phrase as all +* private keys are held in the secure chip of the device. As such, the user +* is responsible for using Ledger technology to back up these pieces of data. +* +* https://github.com/roosmaa/ledger-app-nano/blob/master/doc/nano.md +*/ +export declare class Ledger { + #private + static get UsbVendorId (): typeof ledgerUSBVendorId + /** + * Check which transport protocols are supported by the browser and return the + * transport type according to the following priorities: HID, Bluetooth, USB. + */ + static get isUnsupported (): boolean + /** + * Status of the Ledger device connection. + * + * DISCONNECTED | BUSY | LOCKED | CONNECTED + */ + static get status (): LedgerStatus + /** + * Request an account at a specific BIP-44 index. + * + * @returns Response object containing command status, public key, and address + */ + static account (index?: number, show?: boolean): Promise + /** + * Check if the Nano app is currently open and set device status accordingly. + * + * @param {string} [api] Transport interface to use + * @returns Device status as follows: + * - DISCONNECTED: Failed to communicate properly with the app + * - BUSY: Nano app is not currently open + * - LOCKED: Nano app is open but the device locked after a timeout + * - CONNECTED: Nano app is open and listening + */ + static connect (api?: 'hid' | 'ble' | 'usb'): Promise + /** + * Clears Ledger connections from all device interfaces. + */ + static disconnect (): void + /** + * Sign a block with the Ledger device. + * + * @param {number} index - Account number + * @param {Block} block - Block data to sign + * @param {Block} [frontier] - Previous block data to cache in the device + */ + static sign (index: number, block: Block, frontier?: Block): Promise + /** + * Update cache from raw block data. Suitable for offline use. + * + * @param {number} index - Account number + * @param {object} block - JSON-formatted block data + */ + static updateCache (index: number, block: Block): Promise + /** + * Update cache from a block hash by calling out to a node. Suitable for online + * use only. + * + * @param {number} index - Account number + * @param {string} hash - Hexadecimal block hash + * @param {Rpc} rpc - Rpc class object with a node URL + */ + static updateCache (index: number, hash: string, rpc: Rpc): Promise + /** + * Checks whether a given seed matches the wallet seed. The wallet must be + * unlocked prior to verification. + * + * @param {string} seed - Hexadecimal seed to be matched against the wallet data + * @returns True if input matches wallet seed + */ + static verify (seed: string): Promise + /** + * Checks whether a given mnemonic phrase matches the wallet mnemonic. If a + * personal salt was used when generating the mnemonic, it cannot be verified. + * The wallet must be unlocked prior to verification. + * + * @param {string} mnemonic - Phrase to be matched against the wallet data + * @returns True if input matches wallet mnemonic + */ + static verify (mnemonic: string): Promise +} + /** * Represents a basic address book of Nano accounts. Multiple addresses can be * saved under one nickname. diff --git a/test/test.ledger.mjs b/test/test.ledger.mjs index 6837f9a..46b1276 100644 --- a/test/test.ledger.mjs +++ b/test/test.ledger.mjs @@ -15,6 +15,10 @@ let Account */ let Block /** +* @type {typeof import('../dist/types.d.ts').Ledger} +*/ +let Ledger +/** * @type {typeof import('../dist/types.d.ts').Rpc} */ let Rpc @@ -24,9 +28,9 @@ let Rpc let Wallet if (isNode) { - ({ Account, Block, Rpc, Wallet } = await import('../dist/nodejs.min.js')) + ({ Account, Block, Ledger, Rpc, Wallet } = await import('../dist/nodejs.min.js')) } else { - ({ Account, Block, Rpc, Wallet } = await import('../dist/browser.min.js')) + ({ Account, Block, Ledger, Rpc, Wallet } = await import('../dist/browser.min.js')) } const rpc = new Rpc(env.NODE_URL ?? '', env.API_KEY_NAME) @@ -41,6 +45,14 @@ const rpc = new Rpc(env.NODE_URL ?? '', env.API_KEY_NAME) * from their own Ledger hardware wallets. */ await Promise.all([ + suite('Ledger unsupported', { skip: !(isNode || navigator?.usb == null) }, async () => { + + await test('status UNSUPPORTED', async () => { + assert.equal(Ledger.status, 'UNSUPPORTED') + await assert.rejects(Wallet.create('Ledger')) + }) + }), + suite('Ledger hardware wallet', { skip: false || isNode || navigator?.usb == null }, async () => { const { LEDGER_MNEMONIC, LEDGER_SEED, LEDGER_PUBLIC_0, LEDGER_ADDRESS_0 } = CUSTOM_TEST_VECTORS @@ -53,67 +65,107 @@ await Promise.all([ } await test('request permissions', async () => { - console.log('expect DISCONNECTED...') await click( - 'Reset permissions, unlock device, quit Nano app, then click to continue', + 'Reset permissions, then click to continue', async () => new Promise(r => setTimeout(r, 5000)) ) await assert.rejects(wallet.unlock()) assert.equal(wallet.isLocked, true) + assert.equal(Ledger.status, 'DISCONNECTED') await assert.rejects(async () => { - console.log('expect BUSY...') await click( - 'Reset permissions, unlock device, quit Nano app, then click to continue', + 'Unlock device, quit Nano app, then click to continue', async () => wallet.unlock() ) }) assert.equal(wallet.isLocked, true) + assert.equal(Ledger.status, 'BUSY') + + await new Promise(async (resolve) => { + console.log('Waiting 6 seconds...') + setTimeout(async () => { + // should still be locked and busy + assert.equal(wallet.isLocked, true) + assert.equal(Ledger.status, 'BUSY') + resolve(null) + }, 6000) + }) await assert.rejects(async () => { - console.log('expect LOCKED...') await click( 'Open Nano app on device, allow device to auto-lock, then click to continue', async () => wallet.unlock() ) }) assert.equal(wallet.isLocked, true) + assert.equal(Ledger.status, 'LOCKED') + + await new Promise(async (resolve) => { + console.log('Waiting 6 seconds...') + setTimeout(async () => { + // should still be locked + assert.equal(wallet.isLocked, true) + assert.equal(Ledger.status, 'LOCKED') + resolve(null) + }, 6000) + }) await assert.resolves(async () => { - console.log('expect CONNECTED...') await click( 'Unlock device, verify Nano app is open, then click to continue', async () => wallet.unlock() ) }) assert.equal(wallet.isLocked, false) + assert.equal(Ledger.status, 'CONNECTED') + + await new Promise(async (resolve) => { + console.log('Waiting 6 seconds...') + setTimeout(async () => { + // should still be unlocked + assert.equal(wallet.isLocked, false) + assert.equal(Ledger.status, 'CONNECTED') + resolve(null) + }, 6000) + }) + + await new Promise(async (resolve) => { + console.log('Waiting 60 seconds...') + setTimeout(async () => { + // should now be locked + assert.equal(wallet.isLocked, true) + assert.equal(Ledger.status, 'LOCKED') + resolve(null) + }, 60000) + }) await assert.resolves(async () => { - console.log('expect BUSY...') await click( 'Verify current interface is HID, switch to Bluetooth device, then click to continue', async () => wallet.config({ connection: 'ble' }) ) }) assert.equal(wallet.isLocked, false) + assert.equal(Ledger.status, 'BUSY') await assert.resolves(async () => { - console.log('expect CONNECTED...') await click( 'Verify current interface is BLE, switch back to USB device, then click to continue', async () => wallet.config({ connection: 'usb' }) ) }) assert.equal(wallet.isLocked, false) + assert.equal(Ledger.status, 'CONNECTED') await assert.resolves(async () => { - console.log('expect CONNECTED...') await click( 'Verify current interface is USB, then click to continue', async () => wallet.config({ connection: 'hid' }) ) }) assert.equal(wallet.isLocked, false) + assert.equal(Ledger.status, 'CONNECTED') }) await test('verify mnemonic', async () => { -- 2.47.3