From 2191a87659b44bd8e5f5d744e00fd756358da13c Mon Sep 17 00:00:00 2001 From: Chris Duncan Date: Sat, 5 Jul 2025 01:41:27 -0700 Subject: [PATCH] Move Ledger class into ledger wallet file and update import in block class. --- src/lib/block.ts | 2 +- src/lib/ledger.ts | 377 ------------------------------- src/lib/wallets/index.ts | 2 +- src/lib/wallets/ledger-wallet.ts | 348 +++++++++++++++++++++++++++- 4 files changed, 347 insertions(+), 382 deletions(-) delete mode 100644 src/lib/ledger.ts diff --git a/src/lib/block.ts b/src/lib/block.ts index 563f78f..2db2fe5 100644 --- a/src/lib/block.ts +++ b/src/lib/block.ts @@ -119,7 +119,7 @@ abstract class Block { async sign (input?: number | string, block?: { [key: string]: string }): Promise { if (typeof input === 'number') { const index = input - const { Ledger } = await import('./ledger') + const { Ledger } = await import('./wallets') const ledger = await Ledger.init() await ledger.open() if (block) { diff --git a/src/lib/ledger.ts b/src/lib/ledger.ts deleted file mode 100644 index 0e9dc04..0000000 --- a/src/lib/ledger.ts +++ /dev/null @@ -1,377 +0,0 @@ -// SPDX-FileCopyrightText: 2025 Chris Duncan -// SPDX-License-Identifier: GPL-3.0-or-later - -// Ledger ADPU commands: https://github.com/roosmaa/ledger-app-nano/blob/master/doc/nano.md - -import Transport from '@ledgerhq/hw-transport' -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 { ChangeBlock, ReceiveBlock, SendBlock } from './block' -import { BIP44_COIN_NANO, BIP44_PURPOSE, HARDENED_OFFSET, LEDGER_ADPU_CODES, LEDGER_STATUS_CODES } from './constants' -import { bytes, dec, hex, utf8 } from './convert' -import { Rpc } from './rpc' - -interface LedgerResponse { - status: string -} - -interface LedgerVersionResponse extends LedgerResponse { - name: string | null, - version: string | null -} - -interface LedgerAccountResponse extends LedgerResponse { - publicKey: string | null, - address: string | null -} - -interface LedgerSignResponse extends LedgerResponse { - signature: string | null, - hash?: string -} - -export class Ledger { - static #isInternal: boolean = false - #status: 'DISCONNECTED' | 'LOCKED' | 'BUSY' | 'CONNECTED' = 'DISCONNECTED' - get status () { return this.#status } - openTimeout = 3000 - listenTimeout = 30000; - transport: Transport | null = null - DynamicTransport: typeof TransportBLE | typeof TransportUSB | typeof TransportHID = TransportHID - - constructor () { - if (!Ledger.#isInternal) { - throw new Error('Ledger cannot be instantiated directly. Use Ledger.init()') - } - Ledger.#isInternal = false - } - - static async init (): Promise { - Ledger.#isInternal = true - const self = new this() - await self.checkBrowserSupport() - await self.listen() - return self - } - - /** - * Check which transport protocols are supported by the browser and set the - * transport type according to the following priorities: Bluetooth, USB, HID. - */ - async checkBrowserSupport (): Promise { - console.log('Checking browser Ledger support...') - const supports = { - ble: await TransportBLE.isSupported(), - usb: await TransportUSB.isSupported(), - hid: await TransportHID.isSupported() - } - console.log(`ble: ${supports.ble}; usb: ${supports.usb}; hid: ${supports.hid}`) - if (supports.ble) { - this.DynamicTransport = TransportBLE - } else if (supports.usb) { - this.DynamicTransport = TransportUSB - } else if (supports.hid) { - this.DynamicTransport = TransportHID - } else { - throw new Error('Unsupported browser') - } - } - - async listen (): Promise { - const { usb } = globalThis.navigator - if (usb) { - usb.addEventListener('connect', console.log.bind(console)) - usb.addEventListener('disconnect', console.log.bind(console)) - } - } - - async connect (): Promise { - const { usb } = globalThis.navigator - if (usb) { - usb.removeEventListener('disconnect', this.onDisconnectUsb.bind(this)) - usb.addEventListener('disconnect', this.onDisconnectUsb.bind(this)) - } - const version = await this.version() - if (version.status === 'OK') { - if (version.name === 'Nano') { - const account = await this.account() - if (account.status === 'OK') { - this.#status = 'CONNECTED' - } else if (account.status === 'SECURITY_STATUS_NOT_SATISFIED') { - this.#status = 'LOCKED' - } else { - this.#status = 'DISCONNECTED' - } - } else if (version.name === 'BOLOS') { - const open = await this.open() - this.#status = (open.status === 'OK') - ? 'CONNECTED' - : 'DISCONNECTED' - } else { - this.#status = 'BUSY' - } - } else { - this.#status = 'DISCONNECTED' - } - return this.status - } - - async onDisconnectUsb (e: USBConnectionEvent): Promise { - if (e.device?.manufacturerName === 'Ledger') { - const { usb } = globalThis.navigator - usb.removeEventListener('disconnect', this.onDisconnectUsb) - this.#status = 'DISCONNECTED' - } - } - - /** - * Open Nano app by launching 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 - */ - async open (): Promise { - const name = new TextEncoder().encode('Nano') - const transport = await this.DynamicTransport.create(this.openTimeout, this.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(resolve => setTimeout(resolve, 1000, { status: LEDGER_STATUS_CODES[response] })) - } - - /** - * Close the currently running app. - * - * 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 - */ - async close (): Promise { - const transport = await this.DynamicTransport.create(this.openTimeout, this.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(resolve => setTimeout(resolve, 1000, { status: LEDGER_STATUS_CODES[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 - */ - async version (): Promise { - const transport = await this.DynamicTransport.create(this.openTimeout, this.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() - - if (response.length === 2) { - const statusCode = bytes.toDec(response) as number - const status = LEDGER_STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR' - return { status, name: null, version: null } - } - - const nameLength = response[1] - const name = response.slice(2, 2 + nameLength).toString() - const versionLength = response[2 + nameLength] - const version = response.slice(2 + nameLength + 1, 2 + nameLength + 1 + versionLength).toString() - const statusCode = bytes.toDec(response.slice(-2)) as number - - const status = LEDGER_STATUS_CODES[statusCode] - return { status, name, version } - } - - /** - * Get an account at a specific BIP-44 index. - * - * @returns Response object containing command status, public key, and address - */ - async account (index: number = 0, show: boolean = false): Promise { - if (typeof index !== 'number' || index < 0 || index >= HARDENED_OFFSET) { - throw new TypeError('Invalid account index') - } - 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 data = new Uint8Array([LEDGER_ADPU_CODES.bip32DerivationLevel, ...purpose, ...coin, ...account]) - - const transport = await this.DynamicTransport.create(this.openTimeout, this.listenTimeout) - const response = await transport.send(LEDGER_ADPU_CODES.class, LEDGER_ADPU_CODES.account, show ? 0x01 : 0x00, LEDGER_ADPU_CODES.paramUnused, data as Buffer) - .catch(err => dec.toBytes(err.statusCode)) as Uint8Array - await transport.close() - - if (response.length === 2) { - const statusCode = bytes.toDec(response) as number - const status = LEDGER_STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR' - 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() - const statusCode = bytes.toDec(response.slice(33 + addressLength)) as number - const status = LEDGER_STATUS_CODES[statusCode] - return { status, publicKey, address } - } catch (err) { - return { status: 'ERROR_PARSING_ACCOUNT', publicKey: null, address: null } - } - } - - /** - * Cache frontier block in device memory. - * - * @param {number} index - Account number - * @param {any} block - Block data to cache - * @returns Status of command - */ - async cacheBlock (index: number = 0, block: SendBlock | ReceiveBlock | ChangeBlock): Promise { - if (typeof index !== 'number' || index < 0 || index >= HARDENED_OFFSET) { - throw new TypeError('Invalid account index') - } - if (!(block instanceof SendBlock) && !(block instanceof ReceiveBlock) && !(block instanceof ChangeBlock)) { - throw new TypeError('Invalid block format') - } - 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 = hex.toBytes(block.previous) - const link = hex.toBytes(block.link) - const representative = hex.toBytes(block.representative.publicKey) - const balance = hex.toBytes(BigInt(block.balance).toString(16), 16) - const signature = hex.toBytes(block.signature) - const data = new Uint8Array([LEDGER_ADPU_CODES.bip32DerivationLevel, ...purpose, ...coin, ...account, ...previous, ...link, ...representative, ...balance, ...signature]) - - const transport = await this.DynamicTransport.create(this.openTimeout, this.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] } - } - - /** - * 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 - */ - async sign (index: number, block: SendBlock | ReceiveBlock | ChangeBlock): Promise - /** - * Sign a nonce with the Ledger device. - * - * @param {number} index - Account number - * @param {string} nonce - 128-bit string to sign - * @returns {Promise} Status and signature - */ - async sign (index: number, nonce: string): Promise - async sign (index: number = 0, input: string | SendBlock | ReceiveBlock | ChangeBlock): Promise { - if (typeof index !== 'number' || index < 0 || index >= HARDENED_OFFSET) { - throw new TypeError('Invalid account index') - } - 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 transport = await this.DynamicTransport.create(this.openTimeout, this.listenTimeout) - if (typeof input === 'string') { - // input is a nonce - const nonce = utf8.toBytes(input) - if (nonce.length !== 16) { - throw new RangeError('Nonce must be 16-byte string') - } - const data = new Uint8Array([LEDGER_ADPU_CODES.bip32DerivationLevel, ...purpose, ...coin, ...account, ...nonce]) - 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() - - if (response.length === 2) { - const statusCode = bytes.toDec(response) as number - const status = LEDGER_STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR' - return { status, signature: null } - } - - const signature = bytes.toHex(response.slice(0, 64)) - const statusCode = bytes.toDec(response.slice(-2)) as number - const status = LEDGER_STATUS_CODES[statusCode] - return { status, signature } - } else { - // input is a block - const previous = hex.toBytes(input.previous) - const link = hex.toBytes(input.link) - const representative = hex.toBytes(input.representative.publicKey) - const balance = hex.toBytes(BigInt(input.balance).toString(16), 16) - const data = new Uint8Array([LEDGER_ADPU_CODES.bip32DerivationLevel, ...purpose, ...coin, ...account, ...previous, ...link, ...representative, ...balance]) - 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() - - if (response.length === 2) { - const statusCode = bytes.toDec(response) as number - const status = LEDGER_STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR' - return { status, signature: null } - } - - const hash = bytes.toHex(response.slice(0, 32)) - const signature = bytes.toHex(response.slice(32, 96)) - const statusCode = bytes.toDec(response.slice(-2)) as number - const status = LEDGER_STATUS_CODES[statusCode] - return { status, signature, hash } - } - } - - /** - * Update cache from raw block data. Suitable for offline use. - * - * @param {number} index - Account number - * @param {object} block - JSON-formatted block data - */ - async updateCache (index: number, block: { [key: string]: string }): Promise - /** - * 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 - */ - async updateCache (index: number, hash: string, rpc: Rpc): Promise - async updateCache (index: number, input: any, node?: Rpc): Promise { - if (typeof input === 'string' && node?.constructor === Rpc) { - const data = { - 'json_block': 'true', - 'hash': input - } - const res = await node.call('block_info', data) - if (!res || res.ok === false) { - throw new Error(`Unable to fetch block info`, res) - } - input = res.contents - } - return this.cacheBlock(index, input) - } -} - diff --git a/src/lib/wallets/index.ts b/src/lib/wallets/index.ts index 574fc70..a6569d0 100644 --- a/src/lib/wallets/index.ts +++ b/src/lib/wallets/index.ts @@ -3,4 +3,4 @@ export { Bip44Wallet } from './bip44-wallet' export { Blake2bWallet } from './blake2b-wallet' -export { LedgerWallet } from './ledger-wallet' +export { Ledger, LedgerWallet } from './ledger-wallet' diff --git a/src/lib/wallets/ledger-wallet.ts b/src/lib/wallets/ledger-wallet.ts index e21298e..4b4d28c 100644 --- a/src/lib/wallets/ledger-wallet.ts +++ b/src/lib/wallets/ledger-wallet.ts @@ -9,7 +9,6 @@ import { ChangeBlock, ReceiveBlock, SendBlock } from '#src/lib/block.js' import { BIP44_COIN_NANO, BIP44_PURPOSE, HARDENED_OFFSET, LEDGER_ADPU_CODES, LEDGER_STATUS_CODES } from '#src/lib/constants.js' import { bytes, dec, hex, utf8 } from '#src/lib/convert.js' import { Entropy } from '#src/lib/entropy.js' -import { Ledger } from '#src/lib/ledger.js' import { Rpc } from '#src/lib/rpc.js' import { KeyPair, Wallet } from './wallet' @@ -65,7 +64,6 @@ export class LedgerWallet extends Wallet { * @returns {LedgerWallet} A wallet containing accounts and a Ledger device communication object */ static async create (): Promise { - const { Ledger } = await import('../ledger') const l = await Ledger.init() const id = await Entropy.create(16) LedgerWallet.#isInternal = true @@ -82,7 +80,6 @@ export class LedgerWallet extends Wallet { if (typeof id !== 'string' || id === '') { throw new TypeError('Wallet ID is required to restore') } - const { Ledger } = await import('../ledger') const l = await Ledger.init() LedgerWallet.#isInternal = true return new this(await Entropy.import(id), l) @@ -139,3 +136,348 @@ export class LedgerWallet extends Wallet { return result === 'OK' } } + +export class Ledger { + static #isInternal: boolean = false + #status: 'DISCONNECTED' | 'LOCKED' | 'BUSY' | 'CONNECTED' = 'DISCONNECTED' + get status () { return this.#status } + openTimeout = 3000 + listenTimeout = 30000; + transport: Transport | null = null + DynamicTransport: typeof TransportBLE | typeof TransportUSB | typeof TransportHID = TransportHID + + constructor () { + if (!Ledger.#isInternal) { + throw new Error('Ledger cannot be instantiated directly. Use Ledger.init()') + } + Ledger.#isInternal = false + Buffer + } + + static async init (): Promise { + Ledger.#isInternal = true + const self = new this() + await self.checkBrowserSupport() + await self.listen() + return self + } + + /** + * Check which transport protocols are supported by the browser and set the + * transport type according to the following priorities: Bluetooth, USB, HID. + */ + async checkBrowserSupport (): Promise { + console.log('Checking browser Ledger support...') + const supports = { + ble: await TransportBLE.isSupported(), + usb: await TransportUSB.isSupported(), + hid: await TransportHID.isSupported() + } + console.log(`ble: ${supports.ble}; usb: ${supports.usb}; hid: ${supports.hid}`) + if (supports.ble) { + this.DynamicTransport = TransportBLE + } else if (supports.usb) { + this.DynamicTransport = TransportUSB + } else if (supports.hid) { + this.DynamicTransport = TransportHID + } else { + throw new Error('Unsupported browser') + } + } + + async listen (): Promise { + const { usb } = globalThis.navigator + if (usb) { + usb.addEventListener('connect', console.log.bind(console)) + usb.addEventListener('disconnect', console.log.bind(console)) + } + } + + async connect (): Promise { + const { usb } = globalThis.navigator + if (usb) { + usb.removeEventListener('disconnect', this.onDisconnectUsb.bind(this)) + usb.addEventListener('disconnect', this.onDisconnectUsb.bind(this)) + } + const version = await this.version() + if (version.status === 'OK') { + if (version.name === 'Nano') { + const account = await this.account() + if (account.status === 'OK') { + this.#status = 'CONNECTED' + } else if (account.status === 'SECURITY_STATUS_NOT_SATISFIED') { + this.#status = 'LOCKED' + } else { + this.#status = 'DISCONNECTED' + } + } else if (version.name === 'BOLOS') { + const open = await this.open() + this.#status = (open.status === 'OK') + ? 'CONNECTED' + : 'DISCONNECTED' + } else { + this.#status = 'BUSY' + } + } else { + this.#status = 'DISCONNECTED' + } + return this.status + } + + async onDisconnectUsb (e: USBConnectionEvent): Promise { + if (e.device?.manufacturerName === 'Ledger') { + const { usb } = globalThis.navigator + usb.removeEventListener('disconnect', this.onDisconnectUsb) + this.#status = 'DISCONNECTED' + } + } + + /** + * Open Nano app by launching 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 + */ + async open (): Promise { + const name = new TextEncoder().encode('Nano') + const transport = await this.DynamicTransport.create(this.openTimeout, this.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(resolve => setTimeout(resolve, 1000, { status: LEDGER_STATUS_CODES[response] })) + } + + /** + * Close the currently running app. + * + * 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 + */ + async close (): Promise { + const transport = await this.DynamicTransport.create(this.openTimeout, this.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(resolve => setTimeout(resolve, 1000, { status: LEDGER_STATUS_CODES[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 + */ + async version (): Promise { + const transport = await this.DynamicTransport.create(this.openTimeout, this.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() + + if (response.length === 2) { + const statusCode = bytes.toDec(response) as number + const status = LEDGER_STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR' + return { status, name: null, version: null } + } + + const nameLength = response[1] + const name = response.slice(2, 2 + nameLength).toString() + const versionLength = response[2 + nameLength] + const version = response.slice(2 + nameLength + 1, 2 + nameLength + 1 + versionLength).toString() + const statusCode = bytes.toDec(response.slice(-2)) as number + + const status = LEDGER_STATUS_CODES[statusCode] + return { status, name, version } + } + + /** + * Get an account at a specific BIP-44 index. + * + * @returns Response object containing command status, public key, and address + */ + async account (index: number = 0, show: boolean = false): Promise { + if (typeof index !== 'number' || index < 0 || index >= HARDENED_OFFSET) { + throw new TypeError('Invalid account index') + } + 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 data = new Uint8Array([LEDGER_ADPU_CODES.bip32DerivationLevel, ...purpose, ...coin, ...account]) + + const transport = await this.DynamicTransport.create(this.openTimeout, this.listenTimeout) + const response = await transport.send(LEDGER_ADPU_CODES.class, LEDGER_ADPU_CODES.account, show ? 0x01 : 0x00, LEDGER_ADPU_CODES.paramUnused, data as Buffer) + .catch(err => dec.toBytes(err.statusCode)) as Uint8Array + await transport.close() + + if (response.length === 2) { + const statusCode = bytes.toDec(response) as number + const status = LEDGER_STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR' + 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() + const statusCode = bytes.toDec(response.slice(33 + addressLength)) as number + const status = LEDGER_STATUS_CODES[statusCode] + return { status, publicKey, address } + } catch (err) { + return { status: 'ERROR_PARSING_ACCOUNT', publicKey: null, address: null } + } + } + + /** + * Cache frontier block in device memory. + * + * @param {number} index - Account number + * @param {any} block - Block data to cache + * @returns Status of command + */ + async cacheBlock (index: number = 0, block: SendBlock | ReceiveBlock | ChangeBlock): Promise { + if (typeof index !== 'number' || index < 0 || index >= HARDENED_OFFSET) { + throw new TypeError('Invalid account index') + } + if (!(block instanceof SendBlock) && !(block instanceof ReceiveBlock) && !(block instanceof ChangeBlock)) { + throw new TypeError('Invalid block format') + } + 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 = hex.toBytes(block.previous) + const link = hex.toBytes(block.link) + const representative = hex.toBytes(block.representative.publicKey) + const balance = hex.toBytes(BigInt(block.balance).toString(16), 16) + const signature = hex.toBytes(block.signature) + const data = new Uint8Array([LEDGER_ADPU_CODES.bip32DerivationLevel, ...purpose, ...coin, ...account, ...previous, ...link, ...representative, ...balance, ...signature]) + + const transport = await this.DynamicTransport.create(this.openTimeout, this.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] } + } + + /** + * 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 + */ + async sign (index: number, block: SendBlock | ReceiveBlock | ChangeBlock): Promise + /** + * Sign a nonce with the Ledger device. + * + * @param {number} index - Account number + * @param {string} nonce - 128-bit string to sign + * @returns {Promise} Status and signature + */ + async sign (index: number, nonce: string): Promise + async sign (index: number = 0, input: string | SendBlock | ReceiveBlock | ChangeBlock): Promise { + if (typeof index !== 'number' || index < 0 || index >= HARDENED_OFFSET) { + throw new TypeError('Invalid account index') + } + 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 transport = await this.DynamicTransport.create(this.openTimeout, this.listenTimeout) + if (typeof input === 'string') { + // input is a nonce + const nonce = utf8.toBytes(input) + if (nonce.length !== 16) { + throw new RangeError('Nonce must be 16-byte string') + } + const data = new Uint8Array([LEDGER_ADPU_CODES.bip32DerivationLevel, ...purpose, ...coin, ...account, ...nonce]) + 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() + + if (response.length === 2) { + const statusCode = bytes.toDec(response) as number + const status = LEDGER_STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR' + return { status, signature: null } + } + + const signature = bytes.toHex(response.slice(0, 64)) + const statusCode = bytes.toDec(response.slice(-2)) as number + const status = LEDGER_STATUS_CODES[statusCode] + return { status, signature } + } else { + // input is a block + const previous = hex.toBytes(input.previous) + const link = hex.toBytes(input.link) + const representative = hex.toBytes(input.representative.publicKey) + const balance = hex.toBytes(BigInt(input.balance).toString(16), 16) + const data = new Uint8Array([LEDGER_ADPU_CODES.bip32DerivationLevel, ...purpose, ...coin, ...account, ...previous, ...link, ...representative, ...balance]) + 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() + + if (response.length === 2) { + const statusCode = bytes.toDec(response) as number + const status = LEDGER_STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR' + return { status, signature: null } + } + + const hash = bytes.toHex(response.slice(0, 32)) + const signature = bytes.toHex(response.slice(32, 96)) + const statusCode = bytes.toDec(response.slice(-2)) as number + const status = LEDGER_STATUS_CODES[statusCode] + return { status, signature, hash } + } + } + + /** + * Update cache from raw block data. Suitable for offline use. + * + * @param {number} index - Account number + * @param {object} block - JSON-formatted block data + */ + async updateCache (index: number, block: { [key: string]: string }): Promise + /** + * 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 + */ + async updateCache (index: number, hash: string, rpc: Rpc): Promise + async updateCache (index: number, input: any, node?: Rpc): Promise { + if (typeof input === 'string' && node?.constructor === Rpc) { + const data = { + 'json_block': 'true', + 'hash': input + } + const res = await node.call('block_info', data) + if (!res || res.ok === false) { + throw new Error(`Unable to fetch block info`, res) + } + input = res.contents + } + return this.cacheBlock(index, input) + } +} -- 2.47.3