* 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({
* 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
}
this.#transport ??= TransportUSB
return false
}
+ this.#status = 'UNSUPPORTED'
return true
}
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
}
}
* - CONNECTED: Nano app is open and listening
*/
static async connect (api?: 'hid' | 'ble' | 'usb'): Promise<LedgerStatus> {
- if (api !== undefined) {
+ if (api !== undefined || this.#status !== 'UNSUPPORTED') {
if (api === 'hid' && this.#transport !== TransportHID) {
this.#transport = TransportHID
}
}
}
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') {
}
} 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) {
}
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) {
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
+ }
}
/**
* @returns Status of command
*/
static async #close (): Promise<LedgerResponse> {
- 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
+ }
}
/**
* @returns Status of command
*/
static async #open (): Promise<LedgerResponse> {
- 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<void> {
+ 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)
+ }
}
/**
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 })
}
/**
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 })
}
/**
* @returns Status, process name, and version
*/
static async #version (): Promise<LedgerVersionResponse> {
- 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(() => { })
}
}