--- /dev/null
+//! SPDX-FileCopyrightText: 2025 Chris Duncan <chris@codecow.com>
+//! SPDX-License-Identifier: GPL-3.0-or-later
+
+import { APDU_CODES, DERIVATION_PATH, LedgerAccountResponse, LedgerTransport, STATUS_CODES, listenTimeout, openTimeout } from '.'
+import { HARDENED_OFFSET } from '../constants'
+import { bytes, dec } from '../convert'
+
+export async function _account (transport: LedgerTransport, index: number = 0, show: boolean = false): Promise<LedgerAccountResponse> {
+ try {
+ 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([...DERIVATION_PATH, ...account])
+
+ const t = await transport.create(openTimeout, listenTimeout)
+ const response = await t
+ .send(APDU_CODES.class, APDU_CODES.account, show ? 1 : 0, APDU_CODES.paramUnused, data as Buffer)
+ .catch((err: any) => dec.toBytes(err.statusCode))
+ .finally(async () => await t.close()) as Uint8Array
+
+ const statusCode = bytes.toDec(response.slice(-2)) as number
+ const status = 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 }
+ }
+ } catch (err: any) {
+ console.error('Ledger.account()', err)
+ return { status: err.message, publicKey: null, address: null }
+ }
+}
import { bytes, dec, hex, utf8 } from '../convert'
import { Rpc } from '../rpc'
import { Wallet } from '../wallet'
+import { _account } from './account'
+import { _connect } from './connect'
import { queue } from './queue'
export type LedgerStatus = 'UNSUPPORTED' | 'DISCONNECTED' | 'BUSY' | 'LOCKED' | 'CONNECTED'
status: string
}
-interface LedgerAccountResponse extends LedgerResponse {
+export interface LedgerAccountResponse extends LedgerResponse {
publicKey: string | null,
address: string | null
}
signNonce: 0x05,
paramUnused: 0x00
})
+export const DERIVATION_PATH: Uint8Array = new Uint8Array([
+ APDU_CODES.bip32DerivationLevel,
+ ...dec.toBytes(BIP44_PURPOSE + HARDENED_OFFSET, 4),
+ ...dec.toBytes(BIP44_COIN_NANO + HARDENED_OFFSET, 4)
+])
export const STATUS_CODES: Readonly<Record<number, string>> = Object.freeze({
...Object.fromEntries(Object.entries(StatusCodes).map(([k, v]) => [+v, k])),
0x6807: 'APPLICATION_NOT_INSTALLED',
static #isPolling: boolean = false
static #status: LedgerStatus = 'DISCONNECTED'
static #transport: typeof TransportHID | typeof TransportBLE | typeof TransportUSB
- static #DERIVATION_PATH: Uint8Array = new Uint8Array([
- APDU_CODES.bip32DerivationLevel,
- ...dec.toBytes(BIP44_PURPOSE + HARDENED_OFFSET, 4),
- ...dec.toBytes(BIP44_COIN_NANO + HARDENED_OFFSET, 4)
- ])
// Compose event emission for status changes
static #eventTarget = new EventTarget()
}
/**
- * Request an account at a specific BIP-44 index.
- *
- * @returns Response object containing command status, public key, and address
- */
+ * Request an account at a specific BIP-44 index.
+ *
+ * @param [index=0] BIP-44 account level
+ * @param [show=false] Display the account's Nano address on the Ledger device
+ * @returns Response object containing command status, public key, and address
+ */
static async account (index: number = 0, show: boolean = false): Promise<LedgerAccountResponse> {
- return queue(async () => {
- try {
- if (typeof index !== 'number' || index < 0 || index >= HARDENED_OFFSET) {
- throw new TypeError('Invalid account index')
- }
- const account = dec.toBytes(index + HARDENED_OFFSET, 4)
- const data = new Uint8Array([...this.#DERIVATION_PATH, ...account])
-
- const transport = await this.#transport.create(this.#openTimeout, this.#listenTimeout)
- const response = await transport
- .send(APDU_CODES.class, APDU_CODES.account, show ? 1 : 0, APDU_CODES.paramUnused, data as Buffer)
- .catch((err: any) => dec.toBytes(err.statusCode))
- .finally(async () => await transport.close()) as Uint8Array
-
- const statusCode = bytes.toDec(response.slice(-2)) as number
- const status = 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 }
- }
- } catch (err: any) {
- console.error('Ledger.account()', err)
- return { status: err.message, publicKey: null, address: null }
- }
- })
+ return queue(async () => _account(this.#transport, index, show))
}
/**
- * Check if the Nano app is currently open and set device status accordingly.
- *
- * @param {string} [api] Transport interface to use
- * @returns Device status as follows:
- * - UNSUPPORTED: Platform does not support any Ledger transport protocols
- * - 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
- */
+ * Check if the Nano app is currently open and set device status accordingly.
+ *
+ * @param {string} [api] Transport interface to use
+ * @returns Device status as follows:
+ * - UNSUPPORTED: Platform does not support any Ledger transport protocols
+ * - 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 (api?: 'hid' | 'ble' | 'usb'): Promise<LedgerStatus> {
if (api !== undefined || this.#status !== 'UNSUPPORTED') {
if (api === 'hid' && this.#transport !== TransportHID) {
: TransportUSB
}
}
- try {
- return connect()
- } catch (err) {
- throw new Error('Ledger.connect()', { cause: err })
- } finally {
- if (!this.isUnsupported && !this.#isPolling) {
- this.#isPolling = true
- this.#poll().catch(() => { })
- }
+ const status = await _connect(this.#transport)
+ this.#setStatus(status)
+ if (!this.isUnsupported && !this.#isPolling) {
+ this.#isPolling = true
+ this.#poll().then(() => void 0, () => void 0)
}
+ return this.#status
}
/**
const signature = hex.toBytes(block.signature, 64)
const data = new Uint8Array([APDU_CODES.bip32DerivationLevel, ...purpose, ...coin, ...account, ...previous, ...link, ...representative, ...balance, ...signature])
- const transport = await this.#transport.create(this.#openTimeout, this.#listenTimeout)
+ const transport = await this.#transport.create(openTimeout, listenTimeout)
const response = await transport
.send(APDU_CODES.class, APDU_CODES.cacheBlock, APDU_CODES.paramUnused, APDU_CODES.paramUnused, data as Buffer)
.then((res: Buffer) => bytes.toDec(res))
*/
static async #close (): Promise<LedgerResponse> {
return queue(async () => {
- const transport = await this.#transport.create(this.#openTimeout, this.#listenTimeout)
+ const transport = await this.#transport.create(openTimeout, listenTimeout)
const response = await transport
.send(0xb0, 0xa7, APDU_CODES.paramUnused, APDU_CODES.paramUnused)
.then((res: Buffer) => bytes.toDec(res))
static async #open (): Promise<LedgerResponse> {
return queue(async () => {
const name = new TextEncoder().encode('Nano')
- const transport = await this.#transport.create(this.#openTimeout, this.#listenTimeout)
+ const transport = await this.#transport.create(openTimeout, listenTimeout)
const response = await transport
.send(0xe0, 0xd8, APDU_CODES.paramUnused, APDU_CODES.paramUnused, name as Buffer)
.then((res: Buffer) => bytes.toDec(res))
const isUsbPaired = (await navigator.usb?.getDevices?.() ?? [])
.some(device => device.vendorId === this.ledgerVendorId)
if (this.#transport === TransportHID && isHidPaired) {
- await connect()
+ await _connect(this.#transport)
} else if (this.#transport === TransportUSB && isUsbPaired) {
- await connect()
+ await _connect(this.#transport)
} else {
this.#setStatus('DISCONNECTED')
}
const link = block.link
const representative = hex.toBytes(block.representative.publicKey, 32)
const balance = hex.toBytes(BigInt(block.balance).toString(16), 16)
- const data = new Uint8Array([...this.#DERIVATION_PATH, ...account, ...previous, ...link, ...representative, ...balance])
+ const data = new Uint8Array([...DERIVATION_PATH, ...account, ...previous, ...link, ...representative, ...balance])
- const transport = await this.#transport.create(this.#openTimeout, this.#listenTimeout)
+ const transport = await this.#transport.create(openTimeout, listenTimeout)
const response = await transport
.send(APDU_CODES.class, APDU_CODES.signBlock, APDU_CODES.paramUnused, APDU_CODES.paramUnused, data as Buffer)
.catch((err: any) => dec.toBytes(err.statusCode))
}
const derivationAccount = dec.toBytes(index + HARDENED_OFFSET, 4)
- const data = new Uint8Array([...this.#DERIVATION_PATH, ...derivationAccount, ...nonce])
+ const data = new Uint8Array([...DERIVATION_PATH, ...derivationAccount, ...nonce])
- const transport = await this.#transport.create(this.#openTimeout, this.#listenTimeout)
+ const transport = await this.#transport.create(openTimeout, listenTimeout)
const response = await transport
.send(APDU_CODES.class, APDU_CODES.signNonce, APDU_CODES.paramUnused, APDU_CODES.paramUnused, data as Buffer)
.catch((err: any) => dec.toBytes(err.statusCode))