]> git.codecow.com Git - libnemo.git/commitdiff
Fix bad commit for Ledger version and method organizing.
authorChris Duncan <chris@zoso.dev>
Wed, 20 Aug 2025 20:30:11 +0000 (13:30 -0700)
committerChris Duncan <chris@zoso.dev>
Wed, 20 Aug 2025 20:30:11 +0000 (13:30 -0700)
src/lib/wallet/ledger.ts

index 5f766e959f7510adaa94b98706de697d53498937..21b94594fe5db459cb9efbe6778de1728f4a9dd2 100644 (file)
-//! 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 }
+       }
+}