]> git.codecow.com Git - libnemo.git/commitdiff
Start extracting Ledger connect method to its own file.
authorChris Duncan <chris@zoso.dev>
Fri, 15 May 2026 22:11:18 +0000 (15:11 -0700)
committerChris Duncan <chris@zoso.dev>
Fri, 15 May 2026 22:11:18 +0000 (15:11 -0700)
src/lib/ledger/connect.ts [new file with mode: 0644]
src/lib/ledger/index.ts

diff --git a/src/lib/ledger/connect.ts b/src/lib/ledger/connect.ts
new file mode 100644 (file)
index 0000000..4faae98
--- /dev/null
@@ -0,0 +1,83 @@
+//! SPDX-FileCopyrightText: 2025 Chris Duncan <chris@codecow.com>
+//! SPDX-License-Identifier: GPL-3.0-or-later
+
+import { APDU_CODES, LedgerResponse, LedgerStatus } from '.'
+import { bytes, dec } from '../convert'
+import { queue } from './queue'
+
+interface LedgerVersionResponse extends LedgerResponse {
+       name: string | null,
+       version: string | null
+}
+
+/**
+ * Check if the Nano app is currently open and set device status accordingly.
+ *
+ * @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
+ */
+export async function connect (): Promise<LedgerStatus> {
+       try {
+               const v = await version()
+               if (v.status === 'LOCKED_DEVICE') {
+                       this.#setStatus('LOCKED')
+               } else if (v.status !== 'OK') {
+                       this.#setStatus('DISCONNECTED')
+               } else if (v.name === 'Nano') {
+                       const { status } = await this.account()
+                       if (status === 'OK') {
+                               this.#setStatus('CONNECTED')
+                       } else if (status === 'SECURITY_STATUS_NOT_SATISFIED') {
+                               this.#setStatus('LOCKED')
+                       } else {
+                               this.#setStatus('DISCONNECTED')
+                       }
+               } else {
+                       this.#setStatus('BUSY')
+               }
+       } catch (err) {
+               console.error('Ledger.#connect()', err)
+               this.#setStatus('DISCONNECTED')
+       }
+       return this.#status
+}
+
+/**
+ * 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 function version (): Promise<LedgerVersionResponse> {
+       return queue(async () => {
+               try {
+                       const transport = await this.#transport.create(this.#openTimeout, this.#listenTimeout)
+                       const response = await transport
+                               .send(0xb0, APDU_CODES.version, APDU_CODES.paramUnused, APDU_CODES.paramUnused)
+                               .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 = this.#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 }
+               } catch (err: any) {
+                       console.error('Ledger.#version()', err)
+                       return { status: err.message, name: null, version: null }
+               }
+       })
+}
index 25ad785bb5722cfafe1841e4ffc8f3b67076596d..09e3cd9773dc736f82c02fbe56b2a6fbe8dc967e 100644 (file)
@@ -13,17 +13,12 @@ import { Rpc } from '../rpc'
 import { Wallet } from '../wallet'
 import { queue } from './queue'
 
-type LedgerStatus = 'UNSUPPORTED' | 'DISCONNECTED' | 'BUSY' | 'LOCKED' | 'CONNECTED'
+export type LedgerStatus = 'UNSUPPORTED' | 'DISCONNECTED' | 'BUSY' | 'LOCKED' | 'CONNECTED'
 
-interface LedgerResponse {
+export interface LedgerResponse {
        status: string
 }
 
-interface LedgerVersionResponse extends LedgerResponse {
-       name: string | null,
-       version: string | null
-}
-
 interface LedgerAccountResponse extends LedgerResponse {
        publicKey: string | null,
        address: string | null
@@ -34,6 +29,17 @@ interface LedgerSignResponse extends LedgerResponse {
        hash?: string
 }
 
+export const APDU_CODES: Record<string, number> = Object.freeze({
+       class: 0xa1,
+       bip32DerivationLevel: 0x03,
+       version: 0x01,
+       account: 0x02,
+       cacheBlock: 0x03,
+       signBlock: 0x04,
+       signNonce: 0x05,
+       paramUnused: 0x00
+})
+
 /**
 * 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
@@ -48,18 +54,8 @@ export class Ledger {
        static #openTimeout: 3000 = 3000
        static #status: LedgerStatus = 'DISCONNECTED'
        static #transport: typeof TransportHID | typeof TransportBLE | typeof TransportUSB
-       static #ADPU_CODES: Record<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([
-               this.#ADPU_CODES.bip32DerivationLevel,
+               APDU_CODES.bip32DerivationLevel,
                ...dec.toBytes(BIP44_PURPOSE + HARDENED_OFFSET, 4),
                ...dec.toBytes(BIP44_COIN_NANO + HARDENED_OFFSET, 4)
        ])
@@ -138,7 +134,7 @@ export class Ledger {
 
                                const transport = await this.#transport.create(this.#openTimeout, this.#listenTimeout)
                                const response = await transport
-                                       .send(this.#ADPU_CODES.class, this.#ADPU_CODES.account, show ? 1 : 0, this.#ADPU_CODES.paramUnused, data as Buffer)
+                                       .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
 
@@ -190,7 +186,7 @@ export class Ledger {
                        }
                }
                try {
-                       return this.#connect()
+                       return connect()
                } catch (err) {
                        throw new Error('Ledger.connect()', { cause: err })
                } finally {
@@ -394,11 +390,11 @@ export class Ledger {
                                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([this.#ADPU_CODES.bip32DerivationLevel, ...purpose, ...coin, ...account, ...previous, ...link, ...representative, ...balance, ...signature])
+                               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 response = await transport
-                                       .send(this.#ADPU_CODES.class, this.#ADPU_CODES.cacheBlock, this.#ADPU_CODES.paramUnused, this.#ADPU_CODES.paramUnused, data as Buffer)
+                                       .send(APDU_CODES.class, APDU_CODES.cacheBlock, APDU_CODES.paramUnused, APDU_CODES.paramUnused, data as Buffer)
                                        .then((res: Buffer) => bytes.toDec(res))
                                        .catch((err: any) => err.statusCode)
                                        .finally(async () => await transport.close()) as number
@@ -428,7 +424,7 @@ export class Ledger {
                return queue(async () => {
                        const transport = await this.#transport.create(this.#openTimeout, this.#listenTimeout)
                        const response = await transport
-                               .send(0xb0, 0xa7, this.#ADPU_CODES.paramUnused, this.#ADPU_CODES.paramUnused)
+                               .send(0xb0, 0xa7, APDU_CODES.paramUnused, APDU_CODES.paramUnused)
                                .then((res: Buffer) => bytes.toDec(res))
                                .catch((err: any) => err.statusCode)
                                .finally(async () => await transport.close()) as number
@@ -436,42 +432,6 @@ export class Ledger {
                })
        }
 
-       /**
-       * Check if the Nano app is currently open and set device status accordingly.
-       *
-       * @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 (): Promise<LedgerStatus> {
-               try {
-                       const version = await this.#version()
-                       if (version.status === 'LOCKED_DEVICE') {
-                               this.#setStatus('LOCKED')
-                       } else if (version.status !== 'OK') {
-                               this.#setStatus('DISCONNECTED')
-                       } else if (version.name === 'Nano') {
-                               const { status } = await this.account()
-                               if (status === 'OK') {
-                                       this.#setStatus('CONNECTED')
-                               } else if (status === 'SECURITY_STATUS_NOT_SATISFIED') {
-                                       this.#setStatus('LOCKED')
-                               } else {
-                                       this.#setStatus('DISCONNECTED')
-                               }
-                       } else {
-                               this.#setStatus('BUSY')
-                       }
-               } catch (err) {
-                       console.error('Ledger.#connect()', err)
-                       this.#setStatus('DISCONNECTED')
-               }
-               return this.#status
-       }
-
        /**
        * Open the Nano app by launching a user flow.
        *
@@ -490,7 +450,7 @@ export class Ledger {
                        const name = new TextEncoder().encode('Nano')
                        const transport = await this.#transport.create(this.#openTimeout, this.#listenTimeout)
                        const response = await transport
-                               .send(0xe0, 0xd8, this.#ADPU_CODES.paramUnused, this.#ADPU_CODES.paramUnused, name as Buffer)
+                               .send(0xe0, 0xd8, APDU_CODES.paramUnused, APDU_CODES.paramUnused, name as Buffer)
                                .then((res: Buffer) => bytes.toDec(res))
                                .catch((err: any) => err.statusCode)
                                .finally(async () => await transport.close()) as number
@@ -515,9 +475,9 @@ export class Ledger {
                        const isUsbPaired = (await navigator.usb?.getDevices?.() ?? [])
                                .some(device => device.vendorId === this.ledgerVendorId)
                        if (this.#transport === TransportHID && isHidPaired) {
-                               await this.#connect()
+                               await connect()
                        } else if (this.#transport === TransportUSB && isUsbPaired) {
-                               await this.#connect()
+                               await connect()
                        } else {
                                this.#setStatus('DISCONNECTED')
                        }
@@ -572,7 +532,7 @@ export class Ledger {
 
                                const transport = await this.#transport.create(this.#openTimeout, this.#listenTimeout)
                                const response = await transport
-                                       .send(this.#ADPU_CODES.class, this.#ADPU_CODES.signBlock, this.#ADPU_CODES.paramUnused, this.#ADPU_CODES.paramUnused, data as Buffer)
+                                       .send(APDU_CODES.class, APDU_CODES.signBlock, APDU_CODES.paramUnused, APDU_CODES.paramUnused, data as Buffer)
                                        .catch((err: any) => dec.toBytes(err.statusCode))
                                        .finally(async () => await transport.close()) as Uint8Array
 
@@ -617,7 +577,7 @@ export class Ledger {
 
                        const transport = await this.#transport.create(this.#openTimeout, this.#listenTimeout)
                        const response = await transport
-                               .send(this.#ADPU_CODES.class, this.#ADPU_CODES.signNonce, this.#ADPU_CODES.paramUnused, this.#ADPU_CODES.paramUnused, data as Buffer)
+                               .send(APDU_CODES.class, APDU_CODES.signNonce, APDU_CODES.paramUnused, APDU_CODES.paramUnused, data as Buffer)
                                .catch((err: any) => dec.toBytes(err.statusCode))
                                .finally(async () => await transport.close()) as Uint8Array
 
@@ -635,40 +595,4 @@ export class Ledger {
                        }
                })
        }
-
-       /**
-       * 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> {
-               return queue(async () => {
-                       try {
-                               const transport = await this.#transport.create(this.#openTimeout, this.#listenTimeout)
-                               const response = await transport
-                                       .send(0xb0, this.#ADPU_CODES.version, this.#ADPU_CODES.paramUnused, this.#ADPU_CODES.paramUnused)
-                                       .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 = this.#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 }
-                       } catch (err: any) {
-                               console.error('Ledger.#version()', err)
-                               return { status: err.message, name: null, version: null }
-                       }
-               })
-       }
 }