]> git.codecow.com Git - libnemo.git/commitdiff
Scrap complicated promise weaving with simple serial queue.
authorChris Duncan <chris@zoso.dev>
Sat, 20 Sep 2025 06:36:29 +0000 (23:36 -0700)
committerChris Duncan <chris@zoso.dev>
Sat, 20 Sep 2025 06:36:29 +0000 (23:36 -0700)
src/lib/ledger.ts

index 5e9034b640800407583753d1b8dcd986275c2d0d..6f6a3eca49dedc217458a12a08c219c3d65f1f94 100644 (file)
@@ -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<void>
+       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<LedgerAccountResponse> {
-               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<LedgerStatus> {
-               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<LedgerResponse> {
-               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<LedgerResponse> {
-               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<T> (task: () => Promise<T>): Promise<T> {
+               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<T>((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<LedgerResponse> {
-               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<void> {
-               let resolve: (() => void) | undefined
-               const poll = new Promise<void>(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<LedgerSignResponse> {
-               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<ArrayBuffer>): Promise<LedgerSignResponse> {
-               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<LedgerVersionResponse> {
-               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 {