From d3b368397284ecc24f2aa7cb8b5019a54b81ccbd Mon Sep 17 00:00:00 2001 From: Chris Duncan Date: Thu, 18 Sep 2025 14:37:25 -0700 Subject: [PATCH] Refactor Ledger polling. Working OK now, just need to verify fix for polling while busy and polling Bluetooth which doesn't really work well. --- index.html | 1 - src/lib/ledger.ts | 261 +++++++++++++++++++++++++++---------------- src/lib/tools.ts | 17 --- test/test.ledger.mjs | 4 +- 4 files changed, 166 insertions(+), 117 deletions(-) diff --git a/index.html b/index.html index 52ad248..cf9b2db 100644 --- a/index.html +++ b/index.html @@ -75,7 +75,6 @@ SPDX-License-Identifier: GPL-3.0-or-later } })() - diff --git a/src/lib/ledger.ts b/src/lib/ledger.ts index 75d3a3d..123a429 100644 --- a/src/lib/ledger.ts +++ b/src/lib/ledger.ts @@ -41,9 +41,9 @@ interface LedgerSignResponse extends LedgerResponse { * https://github.com/roosmaa/ledger-app-nano/blob/master/doc/nano.md */ export class Ledger { + static #isBusy: boolean = false static #listenTimeout: 30000 = 30000 static #openTimeout: 3000 = 3000 - static #polling: number | NodeJS.Timeout static #status: LedgerStatus = 'DISCONNECTED' static #transport: typeof TransportHID | typeof TransportBLE | typeof TransportUSB static #ADPU_CODES: { [key: string]: number } = Object.freeze({ @@ -80,6 +80,9 @@ export class Ledger { * transport type according to the following priorities: HID, Bluetooth, USB. */ static get isUnsupported (): boolean { + if (this.#status === 'UNSUPPORTED') { + return true + } if (this.#transport !== undefined) { return false } @@ -96,6 +99,7 @@ export class Ledger { this.#transport ??= TransportUSB return false } + this.#status = 'UNSUPPORTED' return true } @@ -128,26 +132,31 @@ export class Ledger { const account = dec.toBytes(index + HARDENED_OFFSET, 4) const data = new Uint8Array([...this.#DERIVATION_PATH, ...account]) - const transport = await this.#transport.create(this.#openTimeout, this.#listenTimeout) - const response = await transport - .send(this.#ADPU_CODES.class, this.#ADPU_CODES.account, show ? 1 : 0, this.#ADPU_CODES.paramUnused, data as Buffer) - .catch((err: any) => dec.toBytes(err.statusCode)) as Uint8Array - await transport.close() - - const statusCode = bytes.toDec(response.slice(-2)) as number - const status = this.#STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR' - if (status !== 'OK') { - return { status, publicKey: null, address: null } - } - try { - const publicKey = bytes.toHex(response.slice(0, 32)) - const addressLength = response[32] - const address = response.slice(33, 33 + addressLength).toString() + this.#isBusy = true + const transport = await this.#transport.create(this.#openTimeout, this.#listenTimeout) + const response = await transport + .send(this.#ADPU_CODES.class, this.#ADPU_CODES.account, show ? 1 : 0, this.#ADPU_CODES.paramUnused, data as Buffer) + .catch((err: any) => dec.toBytes(err.statusCode)) as Uint8Array + await transport.close() + + const statusCode = bytes.toDec(response.slice(-2)) as number + const status = this.#STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR' + if (status !== 'OK') { + return { status, publicKey: null, address: null } + } - return { status, publicKey, address } - } catch (err) { - return { status: 'ERROR_PARSING_ACCOUNT', publicKey: null, address: null } + try { + const publicKey = bytes.toHex(response.slice(0, 32)) + const addressLength = response[32] + const address = response.slice(33, 33 + addressLength).toString() + + return { status, publicKey, address } + } catch (err) { + return { status: 'ERROR_PARSING_ACCOUNT', publicKey: null, address: null } + } + } finally { + this.#isBusy = false } } @@ -163,7 +172,7 @@ export class Ledger { * - CONNECTED: Nano app is open and listening */ static async connect (api?: 'hid' | 'ble' | 'usb'): Promise { - if (api !== undefined) { + if (api !== undefined || this.#status !== 'UNSUPPORTED') { if (api === 'hid' && this.#transport !== TransportHID) { this.#transport = TransportHID } @@ -175,11 +184,11 @@ export class Ledger { } } try { + this.#isBusy = true const version = await this.#version() if (version.status !== 'OK') { this.#status = 'DISCONNECTED' } else { - this.#polling ??= setInterval(() => this.connect(), 1000) if (version.name === 'Nano') { const { status } = await this.account() if (status === 'OK') { @@ -195,17 +204,18 @@ export class Ledger { } } catch (err) { console.error(err) - clearInterval(this.#polling) this.#status = 'DISCONNECTED' + } finally { + this.#isBusy = false } - return this.status + console.log(this.#status) + return this.#status } /** * Clears Ledger connections from all device interfaces. */ static disconnect (): void { - clearInterval(this.#polling) setTimeout(async () => { const hidDevices = await globalThis.navigator?.hid?.getDevices?.() ?? [] for (const device of hidDevices) { @@ -215,7 +225,7 @@ export class Ledger { } const bleDevices = await globalThis.navigator?.bluetooth?.getDevices?.() ?? [] for (const device of bleDevices) { - TransportBLE.disconnect(device.id) + TransportBLE.disconnect(device.id).catch(() => { }) } const usbDevices = await globalThis.navigator?.usb?.getDevices?.() ?? [] for (const device of usbDevices) { @@ -371,14 +381,19 @@ export class Ledger { const signature = hex.toBytes(block.signature, 64) const data = new Uint8Array([this.#ADPU_CODES.bip32DerivationLevel, ...purpose, ...coin, ...account, ...previous, ...link, ...representative, ...balance, ...signature]) - const transport = await this.#transport.create(this.#openTimeout, this.#listenTimeout) - const response = await transport - .send(this.#ADPU_CODES.class, this.#ADPU_CODES.cacheBlock, this.#ADPU_CODES.paramUnused, this.#ADPU_CODES.paramUnused, data as Buffer) - .then((res: Buffer) => bytes.toDec(res)) - .catch((err: any) => err.statusCode) as number - await transport.close() - - return { status: this.#STATUS_CODES[response] } + try { + this.#isBusy = true + const transport = await this.#transport.create(this.#openTimeout, this.#listenTimeout) + const response = await transport + .send(this.#ADPU_CODES.class, this.#ADPU_CODES.cacheBlock, this.#ADPU_CODES.paramUnused, this.#ADPU_CODES.paramUnused, data as Buffer) + .then((res: Buffer) => bytes.toDec(res)) + .catch((err: any) => err.statusCode) as number + await transport.close() + + return { status: this.#STATUS_CODES[response] } + } finally { + this.#isBusy = false + } } /** @@ -395,12 +410,17 @@ export class Ledger { * @returns Status of command */ static async #close (): Promise { - const transport = await this.#transport.create(this.#openTimeout, this.#listenTimeout) - const response = await transport - .send(0xb0, 0xa7, this.#ADPU_CODES.paramUnused, this.#ADPU_CODES.paramUnused) - .then((res: Buffer) => bytes.toDec(res)) - .catch((err: any) => err.statusCode) as number - return new Promise(r => setTimeout(r, 1000, { status: this.#STATUS_CODES[response] })) + try { + this.#isBusy = true + const transport = await this.#transport.create(this.#openTimeout, this.#listenTimeout) + const response = await transport + .send(0xb0, 0xa7, this.#ADPU_CODES.paramUnused, this.#ADPU_CODES.paramUnused) + .then((res: Buffer) => bytes.toDec(res)) + .catch((err: any) => err.statusCode) as number + return new Promise(r => setTimeout(r, 1000, { status: this.#STATUS_CODES[response] })) + } finally { + this.#isBusy = false + } } /** @@ -417,13 +437,38 @@ export class Ledger { * @returns Status of command */ static async #open (): Promise { - const name = new TextEncoder().encode('Nano') - const transport = await this.#transport.create(this.#openTimeout, this.#listenTimeout) - const response = await transport - .send(0xe0, 0xd8, this.#ADPU_CODES.paramUnused, this.#ADPU_CODES.paramUnused, name as Buffer) - .then((res: Buffer) => bytes.toDec(res)) - .catch((err: any) => err.statusCode) as number - return new Promise(r => setTimeout(r, 1000, { status: this.#STATUS_CODES[response] })) + try { + this.#isBusy = true + const name = new TextEncoder().encode('Nano') + const transport = await this.#transport.create(this.#openTimeout, this.#listenTimeout) + const response = await transport + .send(0xe0, 0xd8, this.#ADPU_CODES.paramUnused, this.#ADPU_CODES.paramUnused, name as Buffer) + .then((res: Buffer) => bytes.toDec(res)) + .catch((err: any) => err.statusCode) as number + return new Promise(r => setTimeout(r, 1000, { status: this.#STATUS_CODES[response] })) + } finally { + this.#isBusy = false + } + } + + static async #poll (): Promise { + try { + const hidDevices = await navigator.hid?.getDevices() ?? [] + const usbDevices = await navigator.usb?.getDevices() ?? [] + const isPaired = [...hidDevices, ...usbDevices] + .some(device => device.vendorId === this.UsbVendorId) + if (!this.#isBusy && isPaired && (this.#transport === TransportHID || this.#transport === TransportUSB)) { + await this.connect().catch(() => { }) + } else { + console.log('No Ledger USB devices paired') + this.#status = 'DISCONNECTED' + } + } catch { + console.warn('Error polling Ledger device') + this.#status = 'DISCONNECTED' + } finally { + setTimeout(() => this.#poll(), 1000) + } } /** @@ -451,25 +496,30 @@ export class Ledger { const balance = hex.toBytes(BigInt(block.balance).toString(16), 16) const data = new Uint8Array([...this.#DERIVATION_PATH, ...account, ...previous, ...link, ...representative, ...balance]) - const transport = await this.#transport.create(this.#openTimeout, this.#listenTimeout) - const response = await transport - .send(this.#ADPU_CODES.class, this.#ADPU_CODES.signBlock, this.#ADPU_CODES.paramUnused, this.#ADPU_CODES.paramUnused, data as Buffer) - .catch((err: any) => dec.toBytes(err.statusCode)) as Uint8Array - await transport.close() - - const statusCode = bytes.toDec(response.slice(-2)) as number - const status = this.#STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR' - - if (response.byteLength === 2) { - return { status, signature: null } - } - if (response.byteLength === 98) { - const hash = bytes.toHex(response.slice(0, 32)) - const signature = bytes.toHex(response.slice(32, 96)) - return { status, signature, hash } + try { + this.#isBusy = true + const transport = await this.#transport.create(this.#openTimeout, this.#listenTimeout) + const response = await transport + .send(this.#ADPU_CODES.class, this.#ADPU_CODES.signBlock, this.#ADPU_CODES.paramUnused, this.#ADPU_CODES.paramUnused, data as Buffer) + .catch((err: any) => dec.toBytes(err.statusCode)) as Uint8Array + await transport.close() + + const statusCode = bytes.toDec(response.slice(-2)) as number + const status = this.#STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR' + + if (response.byteLength === 2) { + return { status, signature: null } + } + if (response.byteLength === 98) { + const hash = bytes.toHex(response.slice(0, 32)) + const signature = bytes.toHex(response.slice(32, 96)) + return { status, signature, hash } + } else { + throw new Error('Unexpected byte length from device signature', { cause: response }) + } + } finally { + this.#isBusy = false } - - throw new Error('Unexpected byte length from device signature', { cause: response }) } /** @@ -492,24 +542,29 @@ export class Ledger { const derivationAccount = dec.toBytes(index + HARDENED_OFFSET, 4) const data = new Uint8Array([...this.#DERIVATION_PATH, ...derivationAccount, ...nonce]) - const transport = await this.#transport.create(this.#openTimeout, this.#listenTimeout) - const response = await transport - .send(this.#ADPU_CODES.class, this.#ADPU_CODES.signNonce, this.#ADPU_CODES.paramUnused, this.#ADPU_CODES.paramUnused, data as Buffer) - .catch((err: any) => dec.toBytes(err.statusCode)) as Uint8Array - await transport.close() - - const statusCode = bytes.toDec(response.slice(-2)) as number - const status = this.#STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR' - - if (response.byteLength === 2) { - return { status, signature: null } - } - if (response.byteLength === 66) { - const signature = bytes.toHex(response.slice(0, 64)) - return { status, signature } + try { + this.#isBusy = true + const transport = await this.#transport.create(this.#openTimeout, this.#listenTimeout) + const response = await transport + .send(this.#ADPU_CODES.class, this.#ADPU_CODES.signNonce, this.#ADPU_CODES.paramUnused, this.#ADPU_CODES.paramUnused, data as Buffer) + .catch((err: any) => dec.toBytes(err.statusCode)) as Uint8Array + await transport.close() + + const statusCode = bytes.toDec(response.slice(-2)) as number + const status = this.#STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR' + + if (response.byteLength === 2) { + return { status, signature: null } + } + if (response.byteLength === 66) { + const signature = bytes.toHex(response.slice(0, 64)) + return { status, signature } + } else { + throw new Error('Unexpected byte length from device signature', { cause: response }) + } + } finally { + this.#isBusy = false } - - throw new Error('Unexpected byte length from device signature', { cause: response }) } /** @@ -521,23 +576,35 @@ export class Ledger { * @returns Status, process name, and version */ static async #version (): Promise { - const transport = await this.#transport.create(this.#openTimeout, this.#listenTimeout) - const response = await transport - .send(0xb0, this.#ADPU_CODES.version, this.#ADPU_CODES.paramUnused, this.#ADPU_CODES.paramUnused) - .catch((err: any) => dec.toBytes(err.statusCode)) as Uint8Array - await transport.close() - - const statusCode = bytes.toDec(response.slice(-2)) as number - const status = this.#STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR' - if (status !== 'OK') { - return { status, name: null, version: null } - } + try { + this.#isBusy = true + const transport = await this.#transport.create(this.#openTimeout, this.#listenTimeout) + const response = await transport + .send(0xb0, this.#ADPU_CODES.version, this.#ADPU_CODES.paramUnused, this.#ADPU_CODES.paramUnused) + .catch((err: any) => { + console.error(err) + dec.toBytes(err.statusCode) + }) as Uint8Array + await transport.close() + + const statusCode = bytes.toDec(response.slice(-2)) as number + const status = this.#STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR' + if (status !== 'OK') { + return { status, name: null, version: null } + } - const nameLength = response[1] - const name = response.slice(2, 2 + nameLength).toString() - const versionLength = response[2 + nameLength] - const version = response.slice(2 + nameLength + 1, 2 + nameLength + 1 + versionLength).toString() + const nameLength = response[1] + const name = response.slice(2, 2 + nameLength).toString() + const versionLength = response[2 + nameLength] + const version = response.slice(2 + nameLength + 1, 2 + nameLength + 1 + versionLength).toString() + + return { status, name, version } + } finally { + this.#isBusy = false + } + } - return { status, name, version } + static { + this.#poll().catch(() => { }) } } diff --git a/src/lib/tools.ts b/src/lib/tools.ts index 7f33bf8..99b36e1 100644 --- a/src/lib/tools.ts +++ b/src/lib/tools.ts @@ -16,14 +16,6 @@ type SweepResult = { } export class Tools { - static #ledger: typeof import('./ledger').Ledger - - static { - (async () => { - this.#ledger = (await import('./ledger')).Ledger - })() - } - /** * Converts a decimal amount of nano from one unit divider to another. * @@ -137,15 +129,6 @@ export class Tools { : hash.digest() } - /** - * Provides low-level access to Ledger hardware wallets. - * - * @returns `Ledger` class with collection of static methods for managing the device - */ - static get Ledger (): typeof import('./ledger').Ledger { - return this.#ledger - } - /** * Signs arbitrary strings with a private key using the Ed25519 signature scheme. * The strings are first hashed to a 32-byte value using BLAKE2b. diff --git a/test/test.ledger.mjs b/test/test.ledger.mjs index 1ca0d67..c32f290 100644 --- a/test/test.ledger.mjs +++ b/test/test.ledger.mjs @@ -131,13 +131,13 @@ await Promise.all([ }) await new Promise(async (resolve) => { - console.log('Waiting 60 seconds...') + console.log('Waiting 90 seconds...') setTimeout(async () => { // should now be locked assert.equal(wallet.isLocked, true) assert.equal(Ledger.status, 'LOCKED') resolve(null) - }, 60000) + }, 900000) }) await assert.resolves(async () => { -- 2.47.3