* 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({
* @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
- }
+ })
}
/**
* - 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'
} catch (err) {
console.error('Ledger.connect()', err)
this.#status = 'DISCONNECTED'
- } finally {
- this.#isBusy = false
}
console.log(this.#status)
return this.#status
* @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 }
+ }
+ })
}
/**
* @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)
.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()
+ })
}
/**
* @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
.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
- }
+ })
}
/**
* 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)
}
}
* @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
- }
+ })
}
/**
* @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')
}
} else {
throw new Error('Unexpected byte length from device signature', { cause: response })
}
- } finally {
- this.#isBusy = false
- }
+ })
}
/**
* @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 {