return true
}
+ /**
+ * Vendor ID assigned to Ledger for HID and USB interfaces.
+ * https://github.com/LedgerHQ/ledger-live/blob/develop/libs/ledgerjs/packages/devices/src/index.ts#L164
+ */
+ static get ledgerVendorId (): 0x2c97 {
+ return 0x2c97
+ }
+
/**
* Status of the Ledger device connection.
*
return this.isUnsupported ? 'UNSUPPORTED' : this.#status
}
- /**
- * Vendor ID assigned to Ledger for HID and USB interfaces.
- * https://github.com/LedgerHQ/ledger-live/blob/develop/libs/ledgerjs/packages/devices/src/index.ts#L164
- */
- static get UsbVendorId (): 0x2c97 {
- return 0x2c97
- }
-
/**
* Request an account at a specific BIP-44 index.
*
* @returns Response object containing command status, public key, and address
*/
static async account (index: number = 0, show: boolean = false): Promise<LedgerAccountResponse> {
- if (typeof index !== 'number' || index < 0 || index >= HARDENED_OFFSET) {
- throw new TypeError('Invalid account index')
+ if (this.#isBusy) {
+ return await this.account(index, show)
}
- const account = dec.toBytes(index + HARDENED_OFFSET, 4)
- const data = new Uint8Array([...this.#DERIVATION_PATH, ...account])
-
try {
this.#isBusy = true
+ 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)) as Uint8Array
- await transport.close()
+ .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'
} 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 }
} finally {
this.#isBusy = false
}
* - CONNECTED: Nano app is open and listening
*/
static async connect (api?: 'hid' | 'ble' | 'usb'): Promise<LedgerStatus> {
- 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 = TransportUSB
- }
- }
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
+ }
+ }
const version = await this.#version()
if (version.status !== 'OK') {
this.#status = 'DISCONNECTED'
}
}
} catch (err) {
- console.error(err)
+ console.error('Ledger.connect()', err)
this.#status = 'DISCONNECTED'
} finally {
this.#isBusy = false
setTimeout(async () => {
const hidDevices = await globalThis.navigator?.hid?.getDevices?.() ?? []
for (const device of hidDevices) {
- if (device.vendorId === this.UsbVendorId) {
+ if (device.vendorId === this.ledgerVendorId) {
device.forget()
}
}
}
const usbDevices = await globalThis.navigator?.usb?.getDevices?.() ?? []
for (const device of usbDevices) {
- if (device.vendorId === this.UsbVendorId) {
+ if (device.vendorId === this.ledgerVendorId) {
device.forget()
}
}
}
return signature
} catch (err) {
- console.error(err)
+ console.error('Ledger.sign()', err)
throw new Error('Failed to sign block with Ledger', { cause: err })
}
}
* @returns Status of command
*/
static async #cacheBlock (index: number = 0, block: Block): Promise<LedgerResponse> {
- 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])
-
try {
this.#isBusy = true
+ 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) as number
- await transport.close()
+ .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 }
} finally {
this.#isBusy = false
}
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
+ .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
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
+ .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
}
}
+ /**
+ * Checks for connected HID and USB Ledger devices to which access has been
+ * previously granted in response to a `requestDevice()` call. It does not work
+ * for Bluetooth interfaces.
+ *
+ * If no devices have been granted access, or if none with access are currently
+ * connected, the poll will set the 'DISCONNECTED' status accordingly and
+ * return. Otherwise, it will attempt to determine the actual status of the
+ * device.
+ */
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'
+ if (!this.#isBusy) {
+ 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.#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')
* @returns {Promise} Status, signature, and block hash
*/
static async #signBlock (index: number, block: Block): Promise<LedgerSignResponse> {
- 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])
-
try {
this.#isBusy = true
+ 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 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()
+ .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'
} 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 }
} finally {
this.#isBusy = false
}
* @returns {Promise} Status and signature
*/
static async #signNonce (index: number, nonce: Uint8Array<ArrayBuffer>): Promise<LedgerSignResponse> {
- if (typeof index !== 'number' || index < 0 || index >= HARDENED_OFFSET) {
- throw new TypeError('Invalid account index')
- }
- if (nonce.byteLength !== 16) {
- throw new RangeError('Nonce must be 16-byte string')
- }
-
- const derivationAccount = dec.toBytes(index + HARDENED_OFFSET, 4)
- const data = new Uint8Array([...this.#DERIVATION_PATH, ...derivationAccount, ...nonce])
-
try {
this.#isBusy = true
+ if (typeof index !== 'number' || index < 0 || index >= HARDENED_OFFSET) {
+ throw new TypeError('Invalid account index')
+ }
+ if (nonce.byteLength !== 16) {
+ throw new RangeError('Nonce must be 16-byte string')
+ }
+
+ 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()
+ .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 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()
+ .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 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
}
}
static {
- this.#poll().catch(() => { })
+ this.isUnsupported ? void 0 : this.#poll().catch(() => { })
}
}