// SPDX-FileCopyrightText: 2025 Chris Duncan <chris@zoso.dev>\r
// SPDX-License-Identifier: GPL-3.0-or-later\r
\r
-import Transport from '@ledgerhq/hw-transport'\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 { Account } from '#src/lib/account.js'\r
import { ChangeBlock, ReceiveBlock, SendBlock } from '#src/lib/block.js'\r
import { BIP44_COIN_NANO, BIP44_PURPOSE, HARDENED_OFFSET, LEDGER_ADPU_CODES, LEDGER_STATUS_CODES } from '#src/lib/constants.js'\r
-import { bytes, dec, hex, utf8 } from '#src/lib/convert.js'\r
+import { bytes, dec, hex } from '#src/lib/convert.js'\r
import { Entropy } from '#src/lib/entropy.js'\r
import { Rpc } from '#src/lib/rpc.js'\r
import { KeyPair, Wallet } from './wallet'\r
\r
-type DeviceStatus = 'DISCONNECTED' | 'PAIRED' | 'LOCKED' | 'BUSY' | 'CONNECTED'\r
+type DeviceStatus = 'DISCONNECTED' | 'BUSY' | 'LOCKED' | 'CONNECTED'\r
\r
interface LedgerResponse {\r
status: string\r
*/\r
export class LedgerWallet extends Wallet {\r
static #isInternal: boolean = false\r
+ static #derivationPath: 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
\r
#status: DeviceStatus = 'DISCONNECTED'\r
\r
super(id)\r
}\r
\r
+ /**\r
+ * Since the Ledger device can only return one account per request, this\r
+ * overrides the default behavior of calling `accounts()` to instead\r
+ * directly derive and return a single account at the specified index.\r
+ *\r
+ * @returns Account\r
+ */\r
+ async account (index: number = 0, show: boolean = false): Promise<Account> {\r
+ const { status, publicKey } = await this.#account(index, show)\r
+ if (publicKey == null) {\r
+ throw new Error('Failed to get account from device', { cause: status })\r
+ }\r
+ const account = await Account.fromPublicKey(publicKey)\r
+ return account\r
+ }\r
+\r
+ /**\r
+ * Since the Ledger device can only return one account per request, this\r
+ * overrides the default behavior of returning multiple accounts to instead\r
+ * return a single account at the specified index.\r
+ *\r
+ * @returns Account\r
+ */\r
+ async accounts (): Promise<never> {\r
+ throw new Error(`Ledger device only supports 'account()' calls`)\r
+ }\r
+\r
+ /**\r
+ * Check which transport protocols are supported by the browser and set the\r
+ * transport type according to the following priorities: Bluetooth, USB, HID.\r
+ */\r
+ async checkBrowserSupport (): Promise<typeof TransportBLE | typeof TransportUSB | typeof TransportHID> {\r
+ console.log('Checking browser Ledger support...')\r
+ try {\r
+ if (await TransportBLE.isSupported()) {\r
+ return TransportBLE\r
+ }\r
+ if (await TransportUSB.isSupported()) {\r
+ return TransportUSB\r
+ }\r
+ if (await TransportHID.isSupported()) {\r
+ return TransportHID\r
+ }\r
+ } catch { }\r
+ throw new Error('Unsupported browser')\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
+ 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 this.#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
* Creates a new Ledger hardware wallet communication layer by dynamically\r
* importing the ledger.js service.\r
*/\r
async destroy (): Promise<void> {\r
await super.destroy()\r
- const { status } = await this.close()\r
+ const { status } = await this.#close()\r
if (status !== 'OK') {\r
- throw new Error('Failed to close wallet', { cause: status })\r
+ throw new Error('Failed to lock Ledger wallet', { cause: status })\r
+ }\r
+ }\r
+\r
+ async init (): Promise<void> {\r
+ try {\r
+ this.DynamicTransport = await this.checkBrowserSupport()\r
+ // await this.connect()\r
+ } catch (err) {\r
+ throw new Error('Failed to initialize Ledger wallet', { cause: err })\r
+ }\r
+ }\r
+\r
+ /**\r
+ * Attempts to close the current process on the Ledger device.\r
+ *\r
+ * Overrides the default wallet `lock()` method since as a hardware wallet it\r
+ * does not need to be encrypted by software.\r
+ *\r
+ * @returns True if successfully locked\r
+ */\r
+ async lock (): Promise<boolean> {\r
+ const { status } = await this.#close()\r
+ return status === 'OK'\r
+ }\r
+\r
+ 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
+ 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
+ * 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
+ async sign (index: number, block: SendBlock | ReceiveBlock | ChangeBlock): Promise<LedgerSignResponse>\r
+ /**\r
+ * Sign a nonce with the Ledger device.\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 sign (index: number, nonce: Uint8Array<ArrayBuffer>): Promise<LedgerSignResponse>\r
+ async sign (index: number = 0, input: Uint8Array<ArrayBuffer> | SendBlock | ReceiveBlock | ChangeBlock): Promise<LedgerSignResponse> {\r
+ if (typeof index !== 'number' || index < 0 || index >= HARDENED_OFFSET) {\r
+ throw new TypeError('Invalid account index')\r
+ }\r
+ if (input instanceof Uint8Array) {\r
+ // input is a nonce\r
+ return await this.#signNonce(index, input)\r
+ } else {\r
+ // input is a block\r
+ return await this.#signBlock(index, input)\r
}\r
}\r
\r
return wallet\r
}\r
\r
- /**\r
- * Attempts to close the current process on the Ledger device.\r
- *\r
- * Overrides the default wallet `lock()` method since as a hardware wallet it\r
- * does not need to be encrypted by software.\r
- *\r
- * @returns True if successfully locked\r
- */\r
- async lock (): Promise<boolean> {\r
- const { status } = await this.close()\r
- return status === 'OK'\r
- }\r
-\r
/**\r
* Attempts to connect to the Ledger device.\r
*\r
return await this.connect() === 'CONNECTED'\r
}\r
\r
- async init (): Promise<void> {\r
- await this.checkBrowserSupport()\r
- const { usb } = globalThis.navigator\r
- if (usb) {\r
- usb.addEventListener('connect', console.log.bind(console))\r
- usb.addEventListener('disconnect', console.log.bind(console))\r
- }\r
- }\r
-\r
/**\r
- * Check which transport protocols are supported by the browser and set the\r
- * transport type according to the following priorities: Bluetooth, USB, HID.\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
- async checkBrowserSupport (): Promise<typeof TransportBLE | typeof TransportUSB | typeof TransportHID> {\r
- console.log('Checking browser Ledger support...')\r
- try {\r
- if (await TransportBLE.isSupported()) {\r
- return TransportBLE\r
- }\r
- if (await TransportUSB.isSupported()) {\r
- return TransportUSB\r
+ async updateCache (index: number, block: { [key: string]: string }): 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
+ async updateCache (index: number, hash: string, rpc: Rpc): Promise<LedgerResponse>\r
+ 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
- if (await TransportHID.isSupported()) {\r
- return TransportHID\r
+ const res = await node.call('block_info', data)\r
+ if (!res || res.ok === false) {\r
+ throw new Error(`Unable to fetch block info`, res)\r
}\r
- } catch { }\r
- throw new Error('Unsupported browser')\r
- }\r
-\r
- async connect (): Promise<LedgerResponse> {\r
- const { usb } = globalThis.navigator\r
- if (usb) {\r
- usb.removeEventListener('disconnect', this.onDisconnectUsb.bind(this))\r
- usb.addEventListener('disconnect', this.onDisconnectUsb.bind(this))\r
+ input = res.contents\r
}\r
- const version = await this.version()\r
- if (version.status === 'OK') {\r
- if (version.name === 'Nano') {\r
- const { status } = await this.#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 if (version.name === 'BOLOS') {\r
- const open = await this.open()\r
- this.#status = (open.status === 'OK')\r
- ? 'CONNECTED'\r
- : 'DISCONNECTED'\r
- } else {\r
- this.#status = 'BUSY'\r
- }\r
- } else {\r
- this.#status = 'DISCONNECTED'\r
+ const { status } = await this.#cacheBlock(index, input)\r
+ if (status !== 'OK') {\r
+ throw new Error(status)\r
}\r
- return { status: this.status }\r
+ return { status }\r
}\r
\r
- async onDisconnectUsb (e: USBConnectionEvent): Promise<void> {\r
- if (e.device?.manufacturerName === 'Ledger') {\r
- const { usb } = globalThis.navigator\r
- usb.removeEventListener('disconnect', this.onDisconnectUsb)\r
- this.#status = 'DISCONNECTED'\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
+ async version (): Promise<LedgerVersionResponse> {\r
+ return await this.#version()\r
}\r
\r
/**\r
*\r
* @returns Status of command\r
*/\r
- async open (): Promise<LedgerResponse> {\r
+ async #open (): Promise<LedgerResponse> {\r
const name = new TextEncoder().encode('Nano')\r
const transport = await this.DynamicTransport.create(this.openTimeout, this.listenTimeout)\r
- const response = await transport.send(0xe0, 0xd8, LEDGER_ADPU_CODES.paramUnused, LEDGER_ADPU_CODES.paramUnused, name as Buffer)\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(resolve => setTimeout(resolve, 1000, { status: LEDGER_STATUS_CODES[response] }))\r
+ return new Promise(r => setTimeout(r, 1000, { status: LEDGER_STATUS_CODES[response] }))\r
}\r
\r
/**\r
*\r
* @returns Status of command\r
*/\r
- async close (): Promise<LedgerResponse> {\r
+ async #close (): Promise<LedgerResponse> {\r
const transport = await this.DynamicTransport.create(this.openTimeout, this.listenTimeout)\r
- const response = await transport.send(0xb0, 0xa7, LEDGER_ADPU_CODES.paramUnused, LEDGER_ADPU_CODES.paramUnused)\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(resolve => setTimeout(resolve, 1000, { status: LEDGER_STATUS_CODES[response] }))\r
+ return new Promise(r => setTimeout(r, 1000, { status: LEDGER_STATUS_CODES[response] }))\r
}\r
\r
/**\r
*\r
* @returns Status, process name, and version\r
*/\r
- async version (): Promise<LedgerVersionResponse> {\r
+ async #version (): Promise<LedgerVersionResponse> {\r
const transport = await this.DynamicTransport.create(this.openTimeout, this.listenTimeout)\r
const response = await transport\r
.send(0xb0, LEDGER_ADPU_CODES.version, LEDGER_ADPU_CODES.paramUnused, LEDGER_ADPU_CODES.paramUnused)\r
return { status, name, version }\r
}\r
\r
- /**\r
- * Since the Ledger device can only return one account per request, this\r
- * overrides the default behavior of calling `accounts()` to instead\r
- * directly derive and return a single account at the specified index.\r
- *\r
- * @returns Account\r
- */\r
- async account (index: number = 0, show: boolean = false): Promise<Account> {\r
- const { status, publicKey } = await this.#account(index, show)\r
- if (publicKey == null) {\r
- throw new Error('Failed to get account from device', { cause: status })\r
- }\r
- const account = await Account.fromPublicKey(publicKey)\r
- return account\r
- }\r
-\r
- /**\r
- * Since the Ledger device can only return one account per request, this\r
- * overrides the default behavior of returning multiple accounts to instead\r
- * return a single account at the specified index.\r
- *\r
- * @returns Account\r
- */\r
- async accounts (): Promise<never> {\r
- throw new Error(`Ledger device only supports 'account()' calls`)\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
- async sign (index: number, block: SendBlock | ReceiveBlock | ChangeBlock): Promise<LedgerSignResponse>\r
- /**\r
- * Sign a nonce with the Ledger device.\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 sign (index: number, nonce: Uint8Array<ArrayBuffer>): Promise<LedgerSignResponse>\r
- async sign (index: number = 0, input: Uint8Array<ArrayBuffer> | SendBlock | ReceiveBlock | ChangeBlock): Promise<LedgerSignResponse> {\r
- if (typeof index !== 'number' || index < 0 || index >= HARDENED_OFFSET) {\r
- throw new TypeError('Invalid account index')\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
-\r
- let instruction: number\r
- let data: Uint8Array\r
-\r
- if (input instanceof Uint8Array) {\r
- // nonce signing is currently broken: https://github.com/LedgerHQ/app-nano/pull/14\r
- // input is a nonce\r
- instruction = LEDGER_ADPU_CODES.signNonce\r
- if (input.byteLength !== 16) {\r
- throw new RangeError('Nonce must be 16-byte string')\r
- }\r
- data = new Uint8Array([LEDGER_ADPU_CODES.bip32DerivationLevel, ...purpose, ...coin, ...account, ...input])\r
- } else {\r
- // input is a block\r
- instruction = LEDGER_ADPU_CODES.signBlock\r
- const previous = hex.toBytes(input.previous, 32)\r
- const link = hex.toBytes(input.link, 32)\r
- const representative = hex.toBytes(input.representative.publicKey, 32)\r
- const balance = hex.toBytes(BigInt(input.balance).toString(16), 16)\r
- data = new Uint8Array([LEDGER_ADPU_CODES.bip32DerivationLevel, ...purpose, ...coin, ...account, ...previous, ...link, ...representative, ...balance])\r
- }\r
- const transport = await this.DynamicTransport.create(this.openTimeout, this.listenTimeout)\r
- const response = await transport.send(LEDGER_ADPU_CODES.class, instruction, 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
- 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
- * 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
- async updateCache (index: number, block: { [key: string]: string }): 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
- async updateCache (index: number, hash: string, rpc: Rpc): Promise<LedgerResponse>\r
- 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 === false) {\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(status)\r
- }\r
- return { status }\r
- }\r
-\r
/**\r
* Request an account at a specific BIP-44 index.\r
*\r
return { 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
+ async #signBlock (index: number, block: SendBlock | ReceiveBlock | ChangeBlock): Promise<LedgerSignResponse> {\r
+ if (typeof index !== 'number' || index < 0 || index >= HARDENED_OFFSET) {\r
+ throw new TypeError('Invalid account index')\r
+ }\r
+\r
+ const account = dec.toBytes(index + HARDENED_OFFSET, 4)\r
+ const previous = hex.toBytes(block.previous, 32)\r
+ const link = hex.toBytes(block.link, 32)\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([...LedgerWallet.#derivationPath, ...account, ...previous, ...link, ...representative, ...balance])\r
+\r
+ const transport = await this.DynamicTransport.create(this.openTimeout, this.listenTimeout)\r
+ const response = await transport.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([...LedgerWallet.#derivationPath, ...derivationAccount, ...nonce])\r
+\r
+ const transport = await this.DynamicTransport.create(this.openTimeout, this.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
/**\r
* Gets the public key for an account from the Ledger device.\r
*\r
*/
await suite('Ledger hardware wallet', { skip: false || isNode }, async () => {
- let wallet, account, openBlock, sendBlock, receiveBlock
+ let wallet = await LedgerWallet.create(), account, openBlock, sendBlock, receiveBlock
- await test('connect to the device', async () => {
- wallet = await LedgerWallet.create()
- const { status } = await click(
- 'Unlock, then click to connect',
+ await test('request permissions', async () => {
+ let status = wallet.status
+ status = await click(
+ 'Reset permissions, unlock device, quit Nano app, then click to continue',
+ async () => wallet.connect()
+ )
+ assert.equals(status, 'BUSY')
+ assert.equals(status, wallet.status)
+
+ status = await click(
+ 'Open Nano app on device, then click to continue',
async () => wallet.connect()
)
assert.equals(status, 'CONNECTED')
+ assert.equals(status, wallet.status)
})
await test('get version', async () => {
- const { status, name, version } = await click(
- 'Click to get version',
- async () => wallet.version()
- )
+ const { status, name, version } = await wallet.version()
assert.equals(status, 'OK')
assert.equals(name, 'Nano')
})
await test('get first account', async () => {
- account = await click(
- 'Click to get account',
- async () => wallet.account()
- )
+ account = await wallet.account()
assert.exists(account)
assert.ok(account instanceof Account)
assert.nullish(openBlock.signature)
assert.equals(openBlock.account.publicKey, account.publicKey)
- const { status, hash, signature } = await click(
- 'Click to sign opening ReceiveBlock from wallet',
- async () => wallet.sign(0, openBlock)
- )
+ const { status, hash, signature } = await wallet.sign(0, openBlock)
assert.equals(status, 'OK')
assert.ok(/[A-Fa-f0-9]{64}/.test(hash))
assert.ok(/[A-Fa-f0-9]{128}/.test(signature))
- await click(
- 'Click to sign open ReceiveBlock from block and compare signatures',
- async () => openBlock.sign(0)
- )
+ await openBlock.sign(0)
assert.ok(/[A-Fa-f0-9]{128}/.test(openBlock.signature))
assert.equals(signature, openBlock.signature)
})
await test('cache open block', async () => {
- const { status } = await click(
- 'Click to cache open block',
- async () => wallet.updateCache(0, openBlock)
- )
+ const { status } = await wallet.updateCache(0, openBlock)
assert.equals(status, 'OK')
})
assert.nullish(sendBlock.signature)
assert.equals(sendBlock.account.publicKey, account.publicKey)
- const { status, hash, signature } = await click(
- 'Click to sign SendBlock from wallet',
- async () => wallet.sign(0, sendBlock)
- )
+ const { status, hash, signature } = await wallet.sign(0, sendBlock)
assert.equals(status, 'OK')
assert.ok(/[A-Fa-f0-9]{64}/.test(hash))
assert.nullish(receiveBlock.signature)
assert.equals(receiveBlock.account.publicKey, account.publicKey)
- await click(
- 'Click to sign SendBlock from block',
- async () => receiveBlock.sign(0, sendBlock)
- )
+ await receiveBlock.sign(0, sendBlock)
assert.ok(/[A-Fa-f0-9]{128}/.test(receiveBlock.signature))
})
await test('destroy wallet', async () => {
- const { status } = await click(
- 'Click to close',
- async () => wallet.close()
- )
+ const { status } = await wallet.close()
+
assert.equals(status, 'OK')
await wallet.destroy()
})