From: Chris Duncan Date: Sat, 20 Sep 2025 06:36:29 +0000 (-0700) Subject: Scrap complicated promise weaving with simple serial queue. X-Git-Tag: v0.10.5~12^2~34 X-Git-Url: https://git.codecow.com/?a=commitdiff_plain;h=84aa459cc96f238a99575a4051e3c843263b48ef;p=libnemo.git Scrap complicated promise weaving with simple serial queue. --- diff --git a/src/lib/ledger.ts b/src/lib/ledger.ts index 5e9034b..6f6a3ec 100644 --- a/src/lib/ledger.ts +++ b/src/lib/ledger.ts @@ -41,10 +41,10 @@ interface LedgerSignResponse extends LedgerResponse { * https://github.com/roosmaa/ledger-app-nano/blob/master/doc/nano.md */ export class Ledger { - static #isBusy: boolean = false - static #isPolling?: Promise + static #isIdle: boolean = true static #listenTimeout: 30000 = 30000 static #openTimeout: 3000 = 3000 + static #queue: { task: Function, resolve: Function, reject: Function }[] = [] static #status: LedgerStatus = 'DISCONNECTED' static #transport: typeof TransportHID | typeof TransportBLE | typeof TransportUSB static #ADPU_CODES: { [key: string]: number } = Object.freeze({ @@ -127,45 +127,40 @@ export class Ledger { * @returns Response object containing command status, public key, and address */ static async account (index: number = 0, show: boolean = false): Promise { - if (this.#isBusy) { - return await this.account(index, show) - } - try { - this.#isBusy = true - await this.#isPolling - if (typeof index !== 'number' || index < 0 || index >= HARDENED_OFFSET) { - throw new TypeError('Invalid account index') - } - const account = dec.toBytes(index + HARDENED_OFFSET, 4) - const data = new Uint8Array([...this.#DERIVATION_PATH, ...account]) + return this.#enqueue(async () => { + try { + if (typeof index !== 'number' || index < 0 || index >= HARDENED_OFFSET) { + throw new TypeError('Invalid account index') + } + 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)) - .finally(async () => await transport.close()) as Uint8Array + 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)) + .finally(async () => await transport.close()) as Uint8Array - 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 } - } + 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() + 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 } + return { status, publicKey, address } + } catch (err) { + return { status: 'ERROR_PARSING_ACCOUNT', publicKey: null, address: null } + } + } catch (err: any) { + console.error('Ledger.account()', err) + return { status: err.message, publicKey: null, address: null } } - } catch (err: any) { - console.error('Ledger.account()', err) - return { status: err.message, publicKey: null, address: null } - } finally { - this.#isBusy = false - } + }) } /** @@ -180,21 +175,20 @@ export class Ledger { * - CONNECTED: Nano app is open and listening */ static async connect (api?: 'hid' | 'ble' | 'usb'): Promise { - try { - this.#isBusy = true - if (api !== undefined || this.#status !== 'UNSUPPORTED') { - if (api === 'hid' && this.#transport !== TransportHID) { - this.#transport = TransportHID - } - if (api === 'ble' && this.#transport !== TransportBLE) { - this.#transport = TransportBLE - } - if (api === 'usb' && this.#transport !== TransportUSB) { - this.#transport = typeof navigator.hid?.getDevices === 'function' - ? TransportHID - : TransportUSB - } + if (api !== undefined || this.#status !== 'UNSUPPORTED') { + if (api === 'hid' && this.#transport !== TransportHID) { + this.#transport = TransportHID + } + if (api === 'ble' && this.#transport !== TransportBLE) { + this.#transport = TransportBLE } + if (api === 'usb' && this.#transport !== TransportUSB) { + this.#transport = typeof navigator.hid?.getDevices === 'function' + ? TransportHID + : TransportUSB + } + } + try { const version = await this.#version() if (version.status !== 'OK') { this.#status = 'DISCONNECTED' @@ -215,8 +209,6 @@ export class Ledger { } catch (err) { console.error('Ledger.connect()', err) this.#status = 'DISCONNECTED' - } finally { - this.#isBusy = false } console.log(this.#status) return this.#status @@ -355,49 +347,47 @@ export class Ledger { * @returns Status of command */ static async #cacheBlock (index: number = 0, block: Block): Promise { - try { - this.#isBusy = true - await this.#isPolling - if (typeof index !== 'number' || index < 0 || index >= HARDENED_OFFSET) { - throw new TypeError('Invalid account index') - } - if (!(block instanceof Block)) { - throw new TypeError('Invalid block format') - } - if (!(block.link instanceof Uint8Array)) { - throw new TypeError('Invalid block link') - } - if (!(block.representative instanceof Account)) { - throw new TypeError('Invalid block link') - } - if (!block.signature) { - throw new ReferenceError('Cannot cache unsigned block') - } - - const purpose = dec.toBytes(BIP44_PURPOSE + HARDENED_OFFSET, 4) - const coin = dec.toBytes(BIP44_COIN_NANO + HARDENED_OFFSET, 4) - const account = dec.toBytes(index + HARDENED_OFFSET, 4) - const previous = block.previous - const link = block.link - const representative = hex.toBytes(block.representative.publicKey, 32) - const balance = hex.toBytes(block.balance.toString(16), 16) - 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) - .finally(async () => await transport.close()) as number + return this.#enqueue(async () => { + try { + if (typeof index !== 'number' || index < 0 || index >= HARDENED_OFFSET) { + throw new TypeError('Invalid account index') + } + if (!(block instanceof Block)) { + throw new TypeError('Invalid block format') + } + if (!(block.link instanceof Uint8Array)) { + throw new TypeError('Invalid block link') + } + if (!(block.representative instanceof Account)) { + throw new TypeError('Invalid block link') + } + if (!block.signature) { + throw new ReferenceError('Cannot cache unsigned block') + } - return { status: this.#STATUS_CODES[response] } - } catch (err: any) { - console.error('Ledger.#cacheBlock()', err) - return { status: err.message } - } finally { - this.#isBusy = false - } + const purpose = dec.toBytes(BIP44_PURPOSE + HARDENED_OFFSET, 4) + const coin = dec.toBytes(BIP44_COIN_NANO + HARDENED_OFFSET, 4) + const account = dec.toBytes(index + HARDENED_OFFSET, 4) + const previous = block.previous + const link = block.link + const representative = hex.toBytes(block.representative.publicKey, 32) + const balance = hex.toBytes(block.balance.toString(16), 16) + 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) + .finally(async () => await transport.close()) as number + + return { status: this.#STATUS_CODES[response] } + } catch (err: any) { + console.error('Ledger.#cacheBlock()', err) + return { status: err.message } + } + }) } /** @@ -414,9 +404,7 @@ export class Ledger { * @returns Status of command */ static async #close (): Promise { - try { - this.#isBusy = true - await this.#isPolling + return this.#enqueue(async () => { 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) @@ -424,9 +412,25 @@ export class Ledger { .catch((err: any) => err.statusCode) .finally(async () => await transport.close()) as number return new Promise(r => setTimeout(r, 1000, { status: this.#STATUS_CODES[response] })) - } finally { - this.#isBusy = false + }) + } + + /** + * Serially executes asynchronous functions. + */ + static async #enqueue (task: () => Promise): Promise { + const process = () => { + const next = this.#queue.shift() + if (next == null) return this.#isIdle = true + const { task, resolve, reject } = next + this.#isIdle = !task + task?.().then(resolve).catch(reject).finally(process) } + if (typeof task !== 'function') throw new TypeError('task is not a function') + return new Promise((resolve, reject) => { + this.#queue.push({ task, resolve, reject }) + if (this.#isIdle) process() + }) } /** @@ -443,9 +447,7 @@ export class Ledger { * @returns Status of command */ static async #open (): Promise { - try { - this.#isBusy = true - await this.#isPolling + return this.#enqueue(async () => { const name = new TextEncoder().encode('Nano') const transport = await this.#transport.create(this.#openTimeout, this.#listenTimeout) const response = await transport @@ -454,9 +456,7 @@ export class Ledger { .catch((err: any) => err.statusCode) .finally(async () => await transport.close()) as number return new Promise(r => setTimeout(r, 1000, { status: this.#STATUS_CODES[response] })) - } finally { - this.#isBusy = false - } + }) } /** @@ -470,30 +470,23 @@ export class Ledger { * device. */ static async #poll (): Promise { - let resolve: (() => void) | undefined - const poll = new Promise(r => resolve = r) - this.#isPolling = poll try { const isHidPaired = (await navigator.hid?.getDevices()) .some(device => device.vendorId === this.ledgerVendorId) const isUsbPaired = (await navigator.usb?.getDevices()) .some(device => device.vendorId === this.ledgerVendorId) - if (!this.#isBusy) { - if (this.#transport === TransportHID && isHidPaired) { - await this.connect() - } else if (this.#transport === TransportUSB && isUsbPaired) { - await this.connect() - } else { - console.log('No Ledger devices paired on current interface') - this.#status = 'DISCONNECTED' - } + if (this.#transport === TransportHID && isHidPaired) { + await this.connect() + } else if (this.#transport === TransportUSB && isUsbPaired) { + await this.connect() + } else { + console.log('No Ledger devices paired on current interface') + this.#status = 'DISCONNECTED' } } catch { console.warn('Error polling Ledger device') this.#status = 'DISCONNECTED' } finally { - resolve?.() - this.#isPolling = undefined setTimeout(() => this.#poll(), 200) } } @@ -506,51 +499,49 @@ export class Ledger { * @returns {Promise} Status, signature, and block hash */ static async #signBlock (index: number, block: Block): Promise { - try { - this.#isBusy = true - await this.#isPolling - if (typeof index !== 'number' || index < 0 || index >= HARDENED_OFFSET) { - throw new TypeError('Invalid account index') - } - if (!(block.link instanceof Uint8Array)) { - throw new TypeError('Invalid block link') - } - if (!(block.representative instanceof Account)) { - throw new TypeError('Invalid block representative') - } + return this.#enqueue(async () => { + try { + if (typeof index !== 'number' || index < 0 || index >= HARDENED_OFFSET) { + throw new TypeError('Invalid account index') + } + if (!(block.link instanceof Uint8Array)) { + throw new TypeError('Invalid block link') + } + if (!(block.representative instanceof Account)) { + throw new TypeError('Invalid block representative') + } - const account = dec.toBytes(index + HARDENED_OFFSET, 4) - const previous = block.previous - const link = block.link - const representative = hex.toBytes(block.representative.publicKey, 32) - const balance = hex.toBytes(BigInt(block.balance).toString(16), 16) - const data = new Uint8Array([...this.#DERIVATION_PATH, ...account, ...previous, ...link, ...representative, ...balance]) + const account = dec.toBytes(index + HARDENED_OFFSET, 4) + const previous = block.previous + const link = block.link + const representative = hex.toBytes(block.representative.publicKey, 32) + 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)) - .finally(async () => await transport.close()) as Uint8Array + 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)) + .finally(async () => await transport.close()) as Uint8Array - const statusCode = bytes.toDec(response.slice(-2)) as number - const status = this.#STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR' + 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 }) + 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 }) + } + } catch (err: any) { + console.error('Ledger.#signBlock()', err) + return { status: err.message, signature: null } } - } catch (err: any) { - console.error('Ledger.#signBlock()', err) - return { status: err.message, signature: null } - } finally { - this.#isBusy = false - } + }) } /** @@ -563,9 +554,7 @@ export class Ledger { * @returns {Promise} Status and signature */ static async #signNonce (index: number, nonce: Uint8Array): Promise { - try { - this.#isBusy = true - await this.#isPolling + return this.#enqueue(async () => { if (typeof index !== 'number' || index < 0 || index >= HARDENED_OFFSET) { throw new TypeError('Invalid account index') } @@ -594,9 +583,7 @@ export class Ledger { } else { throw new Error('Unexpected byte length from device signature', { cause: response }) } - } finally { - this.#isBusy = false - } + }) } /** @@ -608,33 +595,31 @@ export class Ledger { * @returns Status, process name, and version */ static async #version (): Promise { - try { - this.#isBusy = true - await this.#isPolling - 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)) - .finally(async () => await transport.close()) as Uint8Array - - 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 } - } + return this.#enqueue(async () => { + try { + 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)) + .finally(async () => await transport.close()) as Uint8Array + + 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 } - } catch (err: any) { - console.error('Ledger.#version()', err) - return { status: err.message, name: null, version: null } - } finally { - this.#isBusy = false - } + return { status, name, version } + } catch (err: any) { + console.error('Ledger.#version()', err) + return { status: err.message, name: null, version: null } + } + }) } static {