-//! SPDX-FileCopyrightText: 2025 Chris Duncan <chris@zoso.dev>\r
-//! SPDX-License-Identifier: GPL-3.0-or-later\r
-\r
-import { ledgerUSBVendorId } from '@ledgerhq/devices'\r
-import { default as TransportBLE } from '@ledgerhq/hw-transport-web-ble'\r
-import { default as TransportUSB } from '@ledgerhq/hw-transport-webusb'\r
-import { default as TransportHID } from '@ledgerhq/hw-transport-webhid'\r
-import { DeviceStatus, LedgerAccountResponse, LedgerResponse, LedgerSignResponse, LedgerVersionResponse } from '#types'\r
-import { Wallet } from '#wallet'\r
-import { Account } from '../account'\r
-import { Block } from '../block'\r
-import { BIP44_COIN_NANO, BIP44_PURPOSE, HARDENED_OFFSET } from '../constants'\r
-import { bytes, dec, hex } from '../convert'\r
-import { Rpc } from '../rpc'\r
-\r
-/**\r
-* Ledger hardware wallet created by communicating with a Ledger device via ADPU\r
-* calls. This wallet does not feature any seed nor mnemonic phrase as all\r
-* private keys are held in the secure chip of the device. As such, the user\r
-* is responsible for using Ledger technology to back up these pieces of data.\r
-*\r
-* https://github.com/roosmaa/ledger-app-nano/blob/master/doc/nano.md\r
-*/\r
-export class Ledger {\r
- static #listenTimeout: 30000 = 30000\r
- static #openTimeout: 3000 = 3000\r
- static #status: DeviceStatus = 'DISCONNECTED'\r
- static #ADPU_CODES: { [key: string]: number } = Object.freeze({\r
- class: 0xa1,\r
- bip32DerivationLevel: 0x03,\r
- version: 0x01,\r
- account: 0x02,\r
- cacheBlock: 0x03,\r
- signBlock: 0x04,\r
- signNonce: 0x05,\r
- paramUnused: 0x00\r
- })\r
- static #DERIVATION_PATH: Uint8Array = new Uint8Array([\r
- Ledger.#ADPU_CODES.bip32DerivationLevel,\r
- ...dec.toBytes(BIP44_PURPOSE + HARDENED_OFFSET, 4),\r
- ...dec.toBytes(BIP44_COIN_NANO + HARDENED_OFFSET, 4)\r
- ])\r
- static #STATUS_CODES: { [key: number]: string } = Object.freeze({\r
- 0x6700: 'INCORRECT_LENGTH',\r
- 0x670a: 'NO_APPLICATION_SPECIFIED',\r
- 0x6807: 'APPLICATION_NOT_INSTALLED',\r
- 0x6d00: 'APPLICATION_ALREADY_LAUNCHED',\r
- 0x6982: 'SECURITY_STATUS_NOT_SATISFIED',\r
- 0x6985: 'CONDITIONS_OF_USE_NOT_SATISFIED',\r
- 0x6a81: 'INVALID_SIGNATURE',\r
- 0x6a82: 'CACHE_MISS',\r
- 0x6b00: 'INCORRECT_PARAMETER',\r
- 0x6e01: 'TRANSPORT_STATUS_ERROR',\r
- 0x9000: 'OK'\r
- })\r
-\r
- static DynamicTransport: typeof TransportBLE | typeof TransportUSB | typeof TransportHID\r
- static UsbVendorId = ledgerUSBVendorId\r
- static SYMBOL: Symbol = Symbol('Ledger')\r
-\r
- /**\r
- * Check which transport protocols are supported by the browser and return the\r
- * transport type according to the following priorities: USB, Bluetooth, HID.\r
- */\r
- static get isUnsupported (): boolean {\r
- console.log('Checking browser Ledger support...')\r
- if (typeof globalThis.navigator?.usb?.getDevices === 'function') {\r
- this.DynamicTransport = TransportUSB\r
- return false\r
- }\r
- if (typeof globalThis.navigator?.bluetooth?.getDevices === 'function') {\r
- this.DynamicTransport = TransportBLE\r
- return false\r
- }\r
- if (typeof globalThis.navigator?.hid?.getDevices === 'function') {\r
- this.DynamicTransport = TransportHID\r
- return false\r
- }\r
- return true\r
- }\r
-\r
- /**\r
- * Status of the Ledger device connection.\r
- *\r
- * DISCONNECTED | BUSY | LOCKED | CONNECTED\r
- */\r
- static get status (): DeviceStatus { return this.#status }\r
-\r
- /**\r
- * Request an account at a specific BIP-44 index.\r
- *\r
- * @returns Response object containing command status, public key, and address\r
- */\r
- static async account (index: number = 0, show: boolean = false): Promise<LedgerAccountResponse> {\r
- if (typeof index !== 'number' || index < 0 || index >= HARDENED_OFFSET) {\r
- throw new TypeError('Invalid account index')\r
- }\r
- const account = dec.toBytes(index + HARDENED_OFFSET, 4)\r
- const data = new Uint8Array([...Ledger.#DERIVATION_PATH, ...account])\r
-\r
- const transport = await Ledger.DynamicTransport.create(Ledger.#openTimeout, Ledger.#listenTimeout)\r
- const response = await transport\r
- .send(Ledger.#ADPU_CODES.class, Ledger.#ADPU_CODES.account, show ? 1 : 0, Ledger.#ADPU_CODES.paramUnused, data as Buffer)\r
- .catch(err => dec.toBytes(err.statusCode)) as Uint8Array\r
- await transport.close()\r
-\r
- const statusCode = bytes.toDec(response.slice(-2)) as number\r
- const status = Ledger.#STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR'\r
- if (status !== 'OK') {\r
- return { status, publicKey: null, address: null }\r
- }\r
-\r
- try {\r
- const publicKey = bytes.toHex(response.slice(0, 32))\r
- const addressLength = response[32]\r
- const address = response.slice(33, 33 + addressLength).toString()\r
-\r
- return { status, publicKey, address }\r
- } catch (err) {\r
- return { status: 'ERROR_PARSING_ACCOUNT', publicKey: null, address: null }\r
- }\r
- }\r
-\r
- /**\r
- * Check if the Nano app is currently open and set device status accordingly.\r
- *\r
- * @returns Device status as follows:\r
- * - DISCONNECTED: Failed to communicate properly with the app\r
- * - BUSY: Nano app is not currently open\r
- * - LOCKED: Nano app is open but the device locked after a timeout\r
- * - CONNECTED: Nano app is open and listening\r
- */\r
- static async connect (): Promise<DeviceStatus> {\r
- const version = await this.#version()\r
- if (version.status !== 'OK') {\r
- this.#status = 'DISCONNECTED'\r
- } else if (version.name === 'Nano') {\r
- const { status } = await Ledger.account()\r
- if (status === 'OK') {\r
- this.#status = 'CONNECTED'\r
- } else if (status === 'SECURITY_STATUS_NOT_SATISFIED') {\r
- this.#status = 'LOCKED'\r
- } else {\r
- this.#status = 'DISCONNECTED'\r
- }\r
- } else {\r
- this.#status = 'BUSY'\r
- }\r
- return this.#status\r
- }\r
-\r
- /**\r
- * Sign a block with the Ledger device.\r
- *\r
- * @param {number} index - Account number\r
- * @param {Block} block - Block data to sign\r
- * @param {Block} [frontier] - Previous block data to cache in the device\r
- */\r
- static async sign (index: number, block: Block, frontier?: Block): Promise<string> {\r
- try {\r
- if (typeof index !== 'number') {\r
- throw new TypeError('Index must be a number', { cause: index })\r
- }\r
- if (index < 0 || index >= HARDENED_OFFSET) {\r
- throw new RangeError(`Index outside allowed range 0-${HARDENED_OFFSET}`, { cause: index })\r
- }\r
- if (frontier != null) {\r
- const { status } = await Ledger.#cacheBlock(index, frontier)\r
- if (status !== 'OK') {\r
- throw new Error('Failed to cache frontier block in ledger', { cause: status })\r
- }\r
- }\r
- console.log('Waiting for signature confirmation on Ledger device...')\r
- const { status, signature, hash } = await Ledger.#signBlock(index, block)\r
- if (status !== 'OK') {\r
- throw new Error('Signing with ledger failed', { cause: status })\r
- }\r
- if (hash !== block.hash) {\r
- throw new Error('Hash from ledger does not match hash from block', { cause: `${hash} | ${block.hash}` })\r
- }\r
- if (signature == null) {\r
- throw new Error('Ledger silently failed to return signature')\r
- }\r
- return signature\r
- } catch (err) {\r
- console.error(err)\r
- throw new Error('Failed to sign block with Ledger', { cause: err })\r
- }\r
- }\r
-\r
- /**\r
- * Update cache from raw block data. Suitable for offline use.\r
- *\r
- * @param {number} index - Account number\r
- * @param {object} block - JSON-formatted block data\r
- */\r
- static async updateCache (index: number, block: Block): Promise<LedgerResponse>\r
- /**\r
- * Update cache from a block hash by calling out to a node. Suitable for online\r
- * use only.\r
- *\r
- * @param {number} index - Account number\r
- * @param {string} hash - Hexadecimal block hash\r
- * @param {Rpc} rpc - Rpc class object with a node URL\r
- */\r
- static async updateCache (index: number, hash: string, rpc: Rpc): Promise<LedgerResponse>\r
- static async updateCache (index: number, input: any, node?: Rpc): Promise<LedgerResponse> {\r
- if (typeof input === 'string' && node instanceof Rpc) {\r
- const data = {\r
- 'json_block': 'true',\r
- 'hash': input\r
- }\r
- const res = await node.call('block_info', data)\r
- if (!res || !res.ok || res.error) {\r
- throw new Error(`Unable to fetch block info`, res)\r
- }\r
- input = res.contents\r
- }\r
- const { status } = await this.#cacheBlock(index, input)\r
- if (status !== 'OK') {\r
- throw new Error('failed to cache frontier block in ledger', { cause: status })\r
- }\r
- return { status }\r
- }\r
-\r
- /**\r
- * Get the version of the current process. If a specific app is running, get\r
- * the app version. Otherwise, get the Ledger BOLOS version instead.\r
- *\r
- * https://developers.ledger.com/docs/connectivity/ledgerJS/open-close-info-on-apps#get-information\r
- *\r
- * @returns Status, process name, and version\r
- */\r
- static async version (): Promise<LedgerVersionResponse> {\r
- const transport = await Ledger.DynamicTransport.create(Ledger.#openTimeout, Ledger.#listenTimeout)\r
- const response = await transport\r
- .send(0xb0, Ledger.#ADPU_CODES.version, Ledger.#ADPU_CODES.paramUnused, Ledger.#ADPU_CODES.paramUnused)\r
- .catch(err => dec.toBytes(err.statusCode)) as Uint8Array\r
- await transport.close()\r
-\r
- const statusCode = bytes.toDec(response.slice(-2)) as number\r
- const status = Ledger.#STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR'\r
- if (status !== 'OK') {\r
- return { status, name: null, version: null }\r
- }\r
-\r
- const nameLength = response[1]\r
- const name = response.slice(2, 2 + nameLength).toString()\r
- const versionLength = response[2 + nameLength]\r
- const version = response.slice(2 + nameLength + 1, 2 + nameLength + 1 + versionLength).toString()\r
-\r
- return { status, name, version }\r
- }\r
-\r
- /**\r
- * Checks whether a given seed matches the wallet seed. The wallet must be\r
- * unlocked prior to verification.\r
- *\r
- * @param {string} seed - Hexadecimal seed to be matched against the wallet data\r
- * @returns True if input matches wallet seed\r
- */\r
- static async verify (seed: string): Promise<boolean>\r
- /**\r
- * Checks whether a given mnemonic phrase matches the wallet mnemonic. If a\r
- * personal salt was used when generating the mnemonic, it cannot be verified.\r
- * The wallet must be unlocked prior to verification.\r
- *\r
- * @param {string} mnemonic - Phrase to be matched against the wallet data\r
- * @returns True if input matches wallet mnemonic\r
- */\r
- static async verify (mnemonic: string): Promise<boolean>\r
- static async verify (secret: string): Promise<boolean> {\r
- const testWallet = await Wallet.load('BIP-44', '', secret)\r
- await testWallet.unlock('')\r
- const testAccount = await testWallet.account(0)\r
- const testOpenBlock = await new Block(testAccount.address, '0', testAccount.publicKey, testAccount.address)\r
- .receive(testAccount.publicKey, 0)\r
- .sign(testWallet, 0)\r
- const testSendBlock = new Block(testAccount.address, '0', testOpenBlock.hash, testAccount.address)\r
- .send(testAccount.address, 0)\r
- await testWallet.sign(0, testOpenBlock)\r
- try {\r
- await Ledger.sign(0, testSendBlock, testOpenBlock)\r
- return testSendBlock.signature === testOpenBlock.signature\r
- } catch (err) {\r
- throw new Error('Failed to verify wallet', { cause: err })\r
- }\r
- }\r
-\r
- /**\r
- * Close the currently running app and return to the device dashboard.\r
- *\r
- * https://developers.ledger.com/docs/connectivity/ledgerJS/open-close-info-on-apps#quit-application\r
- *\r
- * This command resets the internal USB connection of the device which can\r
- * cause subsequent commands to fail if called too quickly. A one-second delay\r
- * is implemented in this method to mitigate the issue.\r
- *\r
- * https://github.com/LedgerHQ/ledger-live/issues/4964#issuecomment-1878361157\r
- *\r
- * @returns Status of command\r
- */\r
- async #close (): Promise<LedgerResponse> {\r
- const transport = await Ledger.DynamicTransport.create(Ledger.#openTimeout, Ledger.#listenTimeout)\r
- const response = await transport\r
- .send(0xb0, 0xa7, Ledger.#ADPU_CODES.paramUnused, Ledger.#ADPU_CODES.paramUnused)\r
- .then(res => bytes.toDec(res))\r
- .catch(err => err.statusCode) as number\r
- return new Promise(r => setTimeout(r, 1000, { status: Ledger.#STATUS_CODES[response] }))\r
- }\r
-\r
- /**\r
- * Cache frontier block in device memory.\r
- *\r
- * @param {number} index - Account number\r
- * @param {any} block - Block data to cache\r
- * @returns Status of command\r
- */\r
- static async #cacheBlock (index: number = 0, block: Block): Promise<LedgerResponse> {\r
- if (typeof index !== 'number' || index < 0 || index >= HARDENED_OFFSET) {\r
- throw new TypeError('Invalid account index')\r
- }\r
- if (!(block instanceof Block)) {\r
- throw new TypeError('Invalid block format')\r
- }\r
- if (!(block.link instanceof Uint8Array)) {\r
- throw new TypeError('Invalid block link')\r
- }\r
- if (!(block.representative instanceof Account)) {\r
- throw new TypeError('Invalid block link')\r
- }\r
- if (!block.signature) {\r
- throw new ReferenceError('Cannot cache unsigned block')\r
- }\r
-\r
- const purpose = dec.toBytes(BIP44_PURPOSE + HARDENED_OFFSET, 4)\r
- const coin = dec.toBytes(BIP44_COIN_NANO + HARDENED_OFFSET, 4)\r
- const account = dec.toBytes(index + HARDENED_OFFSET, 4)\r
- const previous = block.previous\r
- const link = block.link\r
- const representative = hex.toBytes(block.representative.publicKey, 32)\r
- const balance = hex.toBytes(block.balance.toString(16), 16)\r
- const signature = hex.toBytes(block.signature, 64)\r
- const data = new Uint8Array([Ledger.#ADPU_CODES.bip32DerivationLevel, ...purpose, ...coin, ...account, ...previous, ...link, ...representative, ...balance, ...signature])\r
-\r
- const transport = await Ledger.DynamicTransport.create(Ledger.#openTimeout, Ledger.#listenTimeout)\r
- const response = await transport\r
- .send(Ledger.#ADPU_CODES.class, Ledger.#ADPU_CODES.cacheBlock, Ledger.#ADPU_CODES.paramUnused, Ledger.#ADPU_CODES.paramUnused, data as Buffer)\r
- .then(res => bytes.toDec(res))\r
- .catch(err => err.statusCode) as number\r
- await transport.close()\r
-\r
- return { status: Ledger.#STATUS_CODES[response] }\r
- }\r
-\r
- static #onConnectUsb = async (e: USBConnectionEvent): Promise<void> => {\r
- console.log(e)\r
- if (e.device?.vendorId === ledgerUSBVendorId) {\r
- console.log('Ledger connected')\r
- const { usb } = globalThis.navigator\r
- usb.addEventListener('disconnect', this.#onDisconnectUsb)\r
- usb.removeEventListener('connect', this.#onConnectUsb)\r
- }\r
- }\r
-\r
- static #onDisconnectUsb = async (e: USBConnectionEvent): Promise<void> => {\r
- console.log(e)\r
- if (e.device?.vendorId === ledgerUSBVendorId) {\r
- console.log('Ledger disconnected')\r
- const { usb } = globalThis.navigator\r
- usb.addEventListener('connect', this.#onConnectUsb)\r
- usb.removeEventListener('disconnect', this.#onDisconnectUsb)\r
- this.#status = 'DISCONNECTED'\r
- }\r
- }\r
-\r
- /**\r
- * Open the Nano app by launching a user flow.\r
- *\r
- * https://developers.ledger.com/docs/connectivity/ledgerJS/open-close-info-on-apps#open-application\r
- *\r
- * This command resets the internal USB connection of the device which can\r
- * cause subsequent commands to fail if called too quickly. A one-second delay\r
- * is implemented in this method to mitigate the issue.\r
- *\r
- * https://github.com/LedgerHQ/ledger-live/issues/4964#issuecomment-1878361157\r
- *\r
- * @returns Status of command\r
- */\r
- async #open (): Promise<LedgerResponse> {\r
- const name = new TextEncoder().encode('Nano')\r
- const transport = await Ledger.DynamicTransport.create(Ledger.#openTimeout, Ledger.#listenTimeout)\r
- const response = await transport\r
- .send(0xe0, 0xd8, Ledger.#ADPU_CODES.paramUnused, Ledger.#ADPU_CODES.paramUnused, name as Buffer)\r
- .then(res => bytes.toDec(res))\r
- .catch(err => err.statusCode) as number\r
- return new Promise(r => setTimeout(r, 1000, { status: Ledger.#STATUS_CODES[response] }))\r
- }\r
-\r
- /**\r
- * Sign a block with the Ledger device.\r
- *\r
- * @param {number} index - Account number\r
- * @param {object} block - Block data to sign\r
- * @returns {Promise} Status, signature, and block hash\r
- */\r
- static async #signBlock (index: number, block: Block): Promise<LedgerSignResponse> {\r
- if (typeof index !== 'number' || index < 0 || index >= HARDENED_OFFSET) {\r
- throw new TypeError('Invalid account index')\r
- }\r
- if (!(block.link instanceof Uint8Array)) {\r
- throw new TypeError('Invalid block link')\r
- }\r
- if (!(block.representative instanceof Account)) {\r
- throw new TypeError('Invalid block representative')\r
- }\r
-\r
- const account = dec.toBytes(index + HARDENED_OFFSET, 4)\r
- const previous = block.previous\r
- const link = block.link\r
- const representative = hex.toBytes(block.representative.publicKey, 32)\r
- const balance = hex.toBytes(BigInt(block.balance).toString(16), 16)\r
- const data = new Uint8Array([...Ledger.#DERIVATION_PATH, ...account, ...previous, ...link, ...representative, ...balance])\r
-\r
- const transport = await Ledger.DynamicTransport.create(Ledger.#openTimeout, Ledger.#listenTimeout)\r
- const response = await transport\r
- .send(Ledger.#ADPU_CODES.class, Ledger.#ADPU_CODES.signBlock, Ledger.#ADPU_CODES.paramUnused, Ledger.#ADPU_CODES.paramUnused, data as Buffer)\r
- .catch(err => dec.toBytes(err.statusCode)) as Uint8Array\r
- await transport.close()\r
-\r
- const statusCode = bytes.toDec(response.slice(-2)) as number\r
- const status = Ledger.#STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR'\r
-\r
- if (response.byteLength === 2) {\r
- return { status, signature: null }\r
- }\r
- if (response.byteLength === 98) {\r
- const hash = bytes.toHex(response.slice(0, 32))\r
- const signature = bytes.toHex(response.slice(32, 96))\r
- return { status, signature, hash }\r
- }\r
-\r
- throw new Error('Unexpected byte length from device signature', { cause: response })\r
- }\r
-\r
- /**\r
- * Sign a nonce with the Ledger device.\r
- *\r
- * nonce signing is currently broken: https://github.com/LedgerHQ/app-nano/pull/14\r
- *\r
- * @param {number} index - Account number\r
- * @param {Uint8Array} nonce - 128-bit value to sign\r
- * @returns {Promise} Status and signature\r
- */\r
- async #signNonce (index: number, nonce: Uint8Array<ArrayBuffer>): Promise<LedgerSignResponse> {\r
- if (typeof index !== 'number' || index < 0 || index >= HARDENED_OFFSET) {\r
- throw new TypeError('Invalid account index')\r
- }\r
- if (nonce.byteLength !== 16) {\r
- throw new RangeError('Nonce must be 16-byte string')\r
- }\r
-\r
- const derivationAccount = dec.toBytes(index + HARDENED_OFFSET, 4)\r
- const data = new Uint8Array([...Ledger.#DERIVATION_PATH, ...derivationAccount, ...nonce])\r
-\r
- const transport = await Ledger.DynamicTransport.create(Ledger.#openTimeout, Ledger.#listenTimeout)\r
- const response = await transport\r
- .send(Ledger.#ADPU_CODES.class, Ledger.#ADPU_CODES.signNonce, Ledger.#ADPU_CODES.paramUnused, Ledger.#ADPU_CODES.paramUnused, data as Buffer)\r
- .catch(err => dec.toBytes(err.statusCode)) as Uint8Array\r
- await transport.close()\r
-\r
- const statusCode = bytes.toDec(response.slice(-2)) as number\r
- const status = Ledger.#STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR'\r
-\r
- if (response.byteLength === 2) {\r
- return { status, signature: null }\r
- }\r
- if (response.byteLength === 66) {\r
- const signature = bytes.toHex(response.slice(0, 64))\r
- return { status, signature }\r
- }\r
-\r
- throw new Error('Unexpected byte length from device signature', { cause: response })\r
- }\r
-}\r
+//! SPDX-FileCopyrightText: 2025 Chris Duncan <chris@zoso.dev>
+//! SPDX-License-Identifier: GPL-3.0-or-later
+
+import { ledgerUSBVendorId } from '@ledgerhq/devices'
+import { default as TransportBLE } from '@ledgerhq/hw-transport-web-ble'
+import { default as TransportUSB } from '@ledgerhq/hw-transport-webusb'
+import { default as TransportHID } from '@ledgerhq/hw-transport-webhid'
+import { DeviceStatus, LedgerAccountResponse, LedgerResponse, LedgerSignResponse, LedgerVersionResponse } from '#types'
+import { Wallet } from '#wallet'
+import { Account } from '../account'
+import { Block } from '../block'
+import { BIP44_COIN_NANO, BIP44_PURPOSE, HARDENED_OFFSET } from '../constants'
+import { bytes, dec, hex } from '../convert'
+import { Rpc } from '../rpc'
+
+/**
+* Ledger hardware wallet created by communicating with a Ledger device via ADPU
+* calls. This wallet does not feature any seed nor mnemonic phrase as all
+* private keys are held in the secure chip of the device. As such, the user
+* is responsible for using Ledger technology to back up these pieces of data.
+*
+* https://github.com/roosmaa/ledger-app-nano/blob/master/doc/nano.md
+*/
+export class Ledger {
+ static #listenTimeout: 30000 = 30000
+ static #openTimeout: 3000 = 3000
+ static #status: DeviceStatus = 'DISCONNECTED'
+ static #ADPU_CODES: { [key: string]: number } = Object.freeze({
+ class: 0xa1,
+ bip32DerivationLevel: 0x03,
+ version: 0x01,
+ account: 0x02,
+ cacheBlock: 0x03,
+ signBlock: 0x04,
+ signNonce: 0x05,
+ paramUnused: 0x00
+ })
+ static #DERIVATION_PATH: Uint8Array = new Uint8Array([
+ Ledger.#ADPU_CODES.bip32DerivationLevel,
+ ...dec.toBytes(BIP44_PURPOSE + HARDENED_OFFSET, 4),
+ ...dec.toBytes(BIP44_COIN_NANO + HARDENED_OFFSET, 4)
+ ])
+ static #STATUS_CODES: { [key: number]: string } = Object.freeze({
+ 0x6700: 'INCORRECT_LENGTH',
+ 0x670a: 'NO_APPLICATION_SPECIFIED',
+ 0x6807: 'APPLICATION_NOT_INSTALLED',
+ 0x6d00: 'APPLICATION_ALREADY_LAUNCHED',
+ 0x6982: 'SECURITY_STATUS_NOT_SATISFIED',
+ 0x6985: 'CONDITIONS_OF_USE_NOT_SATISFIED',
+ 0x6a81: 'INVALID_SIGNATURE',
+ 0x6a82: 'CACHE_MISS',
+ 0x6b00: 'INCORRECT_PARAMETER',
+ 0x6e01: 'TRANSPORT_STATUS_ERROR',
+ 0x9000: 'OK'
+ })
+
+ static DynamicTransport: typeof TransportBLE | typeof TransportUSB | typeof TransportHID
+ static UsbVendorId = ledgerUSBVendorId
+ static SYMBOL: Symbol = Symbol('Ledger')
+
+ /**
+ * Check which transport protocols are supported by the browser and return the
+ * transport type according to the following priorities: USB, Bluetooth, HID.
+ */
+ static get isUnsupported (): boolean {
+ console.log('Checking browser Ledger support...')
+ if (typeof globalThis.navigator?.usb?.getDevices === 'function') {
+ this.DynamicTransport = TransportUSB
+ return false
+ }
+ if (typeof globalThis.navigator?.bluetooth?.getDevices === 'function') {
+ this.DynamicTransport = TransportBLE
+ return false
+ }
+ if (typeof globalThis.navigator?.hid?.getDevices === 'function') {
+ this.DynamicTransport = TransportHID
+ return false
+ }
+ return true
+ }
+
+ /**
+ * Status of the Ledger device connection.
+ *
+ * DISCONNECTED | BUSY | LOCKED | CONNECTED
+ */
+ static get status (): DeviceStatus { return this.#status }
+
+ /**
+ * 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')
+ }
+ const account = dec.toBytes(index + HARDENED_OFFSET, 4)
+ const data = new Uint8Array([...Ledger.#DERIVATION_PATH, ...account])
+
+ const transport = await Ledger.DynamicTransport.create(Ledger.#openTimeout, Ledger.#listenTimeout)
+ const response = await transport
+ .send(Ledger.#ADPU_CODES.class, Ledger.#ADPU_CODES.account, show ? 1 : 0, Ledger.#ADPU_CODES.paramUnused, data as Buffer)
+ .catch(err => dec.toBytes(err.statusCode)) as Uint8Array
+ await transport.close()
+
+ const statusCode = bytes.toDec(response.slice(-2)) as number
+ const status = Ledger.#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()
+
+ return { status, publicKey, address }
+ } catch (err) {
+ return { status: 'ERROR_PARSING_ACCOUNT', publicKey: null, address: null }
+ }
+ }
+
+ /**
+ * Check if the Nano app is currently open and set device status accordingly.
+ *
+ * @returns Device status as follows:
+ * - DISCONNECTED: Failed to communicate properly with the app
+ * - BUSY: Nano app is not currently open
+ * - LOCKED: Nano app is open but the device locked after a timeout
+ * - CONNECTED: Nano app is open and listening
+ */
+ static async connect (): Promise<DeviceStatus> {
+ const version = await this.#version()
+ if (version.status !== 'OK') {
+ this.#status = 'DISCONNECTED'
+ } else if (version.name === 'Nano') {
+ const { status } = await Ledger.account()
+ if (status === 'OK') {
+ this.#status = 'CONNECTED'
+ } else if (status === 'SECURITY_STATUS_NOT_SATISFIED') {
+ this.#status = 'LOCKED'
+ } else {
+ this.#status = 'DISCONNECTED'
+ }
+ } else {
+ this.#status = 'BUSY'
+ }
+ return this.#status
+ }
+
+ /**
+ * Sign a block with the Ledger device.
+ *
+ * @param {number} index - Account number
+ * @param {Block} block - Block data to sign
+ * @param {Block} [frontier] - Previous block data to cache in the device
+ */
+ static async sign (index: number, block: Block, frontier?: Block): Promise<string> {
+ try {
+ if (typeof index !== 'number') {
+ throw new TypeError('Index must be a number', { cause: index })
+ }
+ if (index < 0 || index >= HARDENED_OFFSET) {
+ throw new RangeError(`Index outside allowed range 0-${HARDENED_OFFSET}`, { cause: index })
+ }
+ if (frontier != null) {
+ const { status } = await Ledger.#cacheBlock(index, frontier)
+ if (status !== 'OK') {
+ throw new Error('Failed to cache frontier block in ledger', { cause: status })
+ }
+ }
+ console.log('Waiting for signature confirmation on Ledger device...')
+ const { status, signature, hash } = await Ledger.#signBlock(index, block)
+ if (status !== 'OK') {
+ throw new Error('Signing with ledger failed', { cause: status })
+ }
+ if (hash !== block.hash) {
+ throw new Error('Hash from ledger does not match hash from block', { cause: `${hash} | ${block.hash}` })
+ }
+ if (signature == null) {
+ throw new Error('Ledger silently failed to return signature')
+ }
+ return signature
+ } catch (err) {
+ console.error(err)
+ throw new Error('Failed to sign block with Ledger', { cause: err })
+ }
+ }
+
+ /**
+ * Update cache from raw block data. Suitable for offline use.
+ *
+ * @param {number} index - Account number
+ * @param {object} block - JSON-formatted block data
+ */
+ static async updateCache (index: number, block: Block): Promise<LedgerResponse>
+ /**
+ * Update cache from a block hash by calling out to a node. Suitable for online
+ * use only.
+ *
+ * @param {number} index - Account number
+ * @param {string} hash - Hexadecimal block hash
+ * @param {Rpc} rpc - Rpc class object with a node URL
+ */
+ static async updateCache (index: number, hash: string, rpc: Rpc): Promise<LedgerResponse>
+ static async updateCache (index: number, input: any, node?: Rpc): Promise<LedgerResponse> {
+ if (typeof input === 'string' && node instanceof Rpc) {
+ const data = {
+ 'json_block': 'true',
+ 'hash': input
+ }
+ const res = await node.call('block_info', data)
+ if (!res || !res.ok || res.error) {
+ throw new Error(`Unable to fetch block info`, res)
+ }
+ input = res.contents
+ }
+ const { status } = await this.#cacheBlock(index, input)
+ if (status !== 'OK') {
+ throw new Error('failed to cache frontier block in ledger', { cause: status })
+ }
+ return { status }
+ }
+
+ /**
+ * Checks whether a given seed matches the wallet seed. The wallet must be
+ * unlocked prior to verification.
+ *
+ * @param {string} seed - Hexadecimal seed to be matched against the wallet data
+ * @returns True if input matches wallet seed
+ */
+ static async verify (seed: string): Promise<boolean>
+ /**
+ * Checks whether a given mnemonic phrase matches the wallet mnemonic. If a
+ * personal salt was used when generating the mnemonic, it cannot be verified.
+ * The wallet must be unlocked prior to verification.
+ *
+ * @param {string} mnemonic - Phrase to be matched against the wallet data
+ * @returns True if input matches wallet mnemonic
+ */
+ static async verify (mnemonic: string): Promise<boolean>
+ static async verify (secret: string): Promise<boolean> {
+ const testWallet = await Wallet.load('BIP-44', '', secret)
+ await testWallet.unlock('')
+ const testAccount = await testWallet.account(0)
+ const testOpenBlock = await new Block(testAccount.address, '0', testAccount.publicKey, testAccount.address)
+ .receive(testAccount.publicKey, 0)
+ .sign(testWallet, 0)
+ const testSendBlock = new Block(testAccount.address, '0', testOpenBlock.hash, testAccount.address)
+ .send(testAccount.address, 0)
+ await testWallet.sign(0, testOpenBlock)
+ try {
+ await Ledger.sign(0, testSendBlock, testOpenBlock)
+ return testSendBlock.signature === testOpenBlock.signature
+ } catch (err) {
+ throw new Error('Failed to verify wallet', { cause: err })
+ }
+ }
+
+ /**
+ * Cache frontier block in device memory.
+ *
+ * @param {number} index - Account number
+ * @param {any} block - Block data to cache
+ * @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([Ledger.#ADPU_CODES.bip32DerivationLevel, ...purpose, ...coin, ...account, ...previous, ...link, ...representative, ...balance, ...signature])
+
+ const transport = await Ledger.DynamicTransport.create(Ledger.#openTimeout, Ledger.#listenTimeout)
+ const response = await transport
+ .send(Ledger.#ADPU_CODES.class, Ledger.#ADPU_CODES.cacheBlock, Ledger.#ADPU_CODES.paramUnused, Ledger.#ADPU_CODES.paramUnused, data as Buffer)
+ .then(res => bytes.toDec(res))
+ .catch(err => err.statusCode) as number
+ await transport.close()
+
+ return { status: Ledger.#STATUS_CODES[response] }
+ }
+
+ /**
+ * Close the currently running app and return to the device dashboard.
+ *
+ * https://developers.ledger.com/docs/connectivity/ledgerJS/open-close-info-on-apps#quit-application
+ *
+ * This command resets the internal USB connection of the device which can
+ * cause subsequent commands to fail if called too quickly. A one-second delay
+ * is implemented in this method to mitigate the issue.
+ *
+ * https://github.com/LedgerHQ/ledger-live/issues/4964#issuecomment-1878361157
+ *
+ * @returns Status of command
+ */
+ static async #close (): Promise<LedgerResponse> {
+ const transport = await Ledger.DynamicTransport.create(Ledger.#openTimeout, Ledger.#listenTimeout)
+ const response = await transport
+ .send(0xb0, 0xa7, Ledger.#ADPU_CODES.paramUnused, Ledger.#ADPU_CODES.paramUnused)
+ .then(res => bytes.toDec(res))
+ .catch(err => err.statusCode) as number
+ return new Promise(r => setTimeout(r, 1000, { status: Ledger.#STATUS_CODES[response] }))
+ }
+
+ static #onConnectUsb = async (e: USBConnectionEvent): Promise<void> => {
+ console.log(e)
+ if (e.device?.vendorId === ledgerUSBVendorId) {
+ console.log('Ledger connected')
+ const { usb } = globalThis.navigator
+ usb.addEventListener('disconnect', this.#onDisconnectUsb)
+ usb.removeEventListener('connect', this.#onConnectUsb)
+ }
+ }
+
+ static #onDisconnectUsb = async (e: USBConnectionEvent): Promise<void> => {
+ console.log(e)
+ if (e.device?.vendorId === ledgerUSBVendorId) {
+ console.log('Ledger disconnected')
+ const { usb } = globalThis.navigator
+ usb.addEventListener('connect', this.#onConnectUsb)
+ usb.removeEventListener('disconnect', this.#onDisconnectUsb)
+ this.#status = 'DISCONNECTED'
+ }
+ }
+
+ /**
+ * Open the Nano app by launching a user flow.
+ *
+ * https://developers.ledger.com/docs/connectivity/ledgerJS/open-close-info-on-apps#open-application
+ *
+ * This command resets the internal USB connection of the device which can
+ * cause subsequent commands to fail if called too quickly. A one-second delay
+ * is implemented in this method to mitigate the issue.
+ *
+ * https://github.com/LedgerHQ/ledger-live/issues/4964#issuecomment-1878361157
+ *
+ * @returns Status of command
+ */
+ static async #open (): Promise<LedgerResponse> {
+ const name = new TextEncoder().encode('Nano')
+ const transport = await Ledger.DynamicTransport.create(Ledger.#openTimeout, Ledger.#listenTimeout)
+ const response = await transport
+ .send(0xe0, 0xd8, Ledger.#ADPU_CODES.paramUnused, Ledger.#ADPU_CODES.paramUnused, name as Buffer)
+ .then(res => bytes.toDec(res))
+ .catch(err => err.statusCode) as number
+ return new Promise(r => setTimeout(r, 1000, { status: Ledger.#STATUS_CODES[response] }))
+ }
+
+ /**
+ * Sign a block with the Ledger device.
+ *
+ * @param {number} index - Account number
+ * @param {object} block - Block data to sign
+ * @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([...Ledger.#DERIVATION_PATH, ...account, ...previous, ...link, ...representative, ...balance])
+
+ const transport = await Ledger.DynamicTransport.create(Ledger.#openTimeout, Ledger.#listenTimeout)
+ const response = await transport
+ .send(Ledger.#ADPU_CODES.class, Ledger.#ADPU_CODES.signBlock, Ledger.#ADPU_CODES.paramUnused, Ledger.#ADPU_CODES.paramUnused, data as Buffer)
+ .catch(err => dec.toBytes(err.statusCode)) as Uint8Array
+ await transport.close()
+
+ const statusCode = bytes.toDec(response.slice(-2)) as number
+ const status = Ledger.#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 }
+ }
+
+ throw new Error('Unexpected byte length from device signature', { cause: response })
+ }
+
+ /**
+ * Sign a nonce with the Ledger device.
+ *
+ * nonce signing is currently broken: https://github.com/LedgerHQ/app-nano/pull/14
+ *
+ * @param {number} index - Account number
+ * @param {Uint8Array} nonce - 128-bit value to sign
+ * @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([...Ledger.#DERIVATION_PATH, ...derivationAccount, ...nonce])
+
+ const transport = await Ledger.DynamicTransport.create(Ledger.#openTimeout, Ledger.#listenTimeout)
+ const response = await transport
+ .send(Ledger.#ADPU_CODES.class, Ledger.#ADPU_CODES.signNonce, Ledger.#ADPU_CODES.paramUnused, Ledger.#ADPU_CODES.paramUnused, data as Buffer)
+ .catch(err => dec.toBytes(err.statusCode)) as Uint8Array
+ await transport.close()
+
+ const statusCode = bytes.toDec(response.slice(-2)) as number
+ const status = Ledger.#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 }
+ }
+
+ throw new Error('Unexpected byte length from device signature', { cause: response })
+ }
+
+ /**
+ * Get the version of the current process. If a specific app is running, get
+ * the app version. Otherwise, get the Ledger BOLOS version instead.
+ *
+ * https://developers.ledger.com/docs/connectivity/ledgerJS/open-close-info-on-apps#get-information
+ *
+ * @returns Status, process name, and version
+ */
+ static async #version (): Promise<LedgerVersionResponse> {
+ const transport = await Ledger.DynamicTransport.create(Ledger.#openTimeout, Ledger.#listenTimeout)
+ const response = await transport
+ .send(0xb0, Ledger.#ADPU_CODES.version, Ledger.#ADPU_CODES.paramUnused, Ledger.#ADPU_CODES.paramUnused)
+ .catch(err => dec.toBytes(err.statusCode)) as Uint8Array
+ await transport.close()
+
+ const statusCode = bytes.toDec(response.slice(-2)) as number
+ const status = Ledger.#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()
+
+ return { status, name, version }
+ }
+}