//! SPDX-FileCopyrightText: 2025 Chris Duncan <chris@zoso.dev>
//! SPDX-License-Identifier: GPL-3.0-or-later
+import { WalletType } from '#types'
import { Vault } from '../vault'
-import { Wallet } from '../wallet'
-export async function _config (wallet: Wallet, vault: Vault, settings: { timeout: number }): Promise<void>
-export async function _config (wallet: Wallet, vault: Vault, settings: unknown): Promise<void> {
+export async function _config (type: WalletType, vault: Vault, settings: { connection: 'hid' | 'ble' | 'usb' } | { timeout: number }): Promise<void>
+export async function _config (type: WalletType, vault: Vault, settings: unknown): Promise<void> {
try {
if (settings == null || typeof settings !== 'object') {
throw new TypeError('Invalid configuration settings')
}
- const { timeout } = settings as { [key: string]: unknown }
- if (typeof timeout !== 'number') {
- throw new TypeError('Timeout must be number', { cause: timeout })
- }
- if (wallet.type === 'BIP-44' || wallet.type === 'BLAKE2b') {
- await vault.request({
- action: 'config',
- timeout
- })
+ const { connection, timeout } = settings as { [key: string]: unknown }
+ if (type === 'Ledger') {
+ const { Ledger } = await import('./ledger')
+ if (connection !== undefined && connection !== 'hid' && connection !== 'ble' && connection !== 'usb') {
+ throw new Error('Ledger connection must be hid, ble, or usb', { cause: connection })
+ }
+ await Ledger.connect(connection)
+ } else {
+ if (typeof timeout !== 'number') {
+ throw new TypeError('Timeout must be number', { cause: timeout })
+ }
+ if (type === 'BIP-44' || type === 'BLAKE2b') {
+ await vault.request({
+ action: 'config',
+ timeout
+ })
+ }
}
} catch (err) {
throw new Error('Failed to lock wallet', { cause: err })
}\r
\r
/**\r
- * Creates a new Ledger wallet manager.\r
+ * Creates a new hardware wallet manager.\r
*\r
- * @param {string} type - Encrypts the wallet to lock and unlock it\r
+ * @param {string} type - Wallet manufacturer\r
* @returns {Wallet} A newly instantiated Wallet\r
*/\r
static async create (type: 'Ledger'): Promise<Wallet>\r
* Creates a new HD wallet by using an entropy value generated using a\r
* cryptographically strong pseudorandom number generator.\r
*\r
+ * @param {string} type - Algorithm used to generate wallet and child accounts\r
* @param {string} password - Encrypts the wallet to lock and unlock it\r
* @param {string} [salt=''] - Used when generating the final seed\r
* @returns {Wallet} A newly instantiated Wallet\r
return await _accounts(this.type, this.#accounts, this.#vault, from, to)\r
}\r
\r
+ /**\r
+ * Configures Ledger connection settings.\r
+ * @param {string} connection - Transport interface to use\r
+ */\r
+ async config (settings: { connection: 'hid' | 'ble' | 'usb' }): Promise<void>\r
/**\r
* Configures vault worker settings.\r
* @param {number} timeout - Measured in seconds of inactivity before wallet automatically locks\r
*/\r
- async config (settings: { timeout: number }): Promise<void> {\r
- return await _config(this, this.#vault, settings)\r
+ async config (settings: { timeout: number }): Promise<void>\r
+ async config (settings: { connection: 'hid' | 'ble' | 'usb' } | { timeout: number }): Promise<void> {\r
+ return await _config(this.type, this.#vault, settings)\r
}\r
\r
/**\r
import { default as TransportBLE } from '@ledgerhq/hw-transport-web-ble'
import { default as TransportHID } from '@ledgerhq/hw-transport-webhid'
import { default as TransportUSB } from '@ledgerhq/hw-transport-webusb'
-import { DeviceStatus, LedgerAccountResponse, LedgerResponse, LedgerSignResponse, LedgerVersionResponse } from '#types'
+import { LedgerStatus, LedgerAccountResponse, LedgerResponse, LedgerSignResponse, LedgerVersionResponse } from '#types'
import { Account } from '../account'
import { Block } from '../block'
import { BIP44_COIN_NANO, BIP44_PURPOSE, HARDENED_OFFSET } from '../constants'
export class Ledger {
static #listenTimeout: 30000 = 30000
static #openTimeout: 3000 = 3000
- static #status: DeviceStatus = 'DISCONNECTED'
+ static #status: LedgerStatus = 'DISCONNECTED'
static #transport: typeof TransportHID | typeof TransportBLE | typeof TransportUSB
static #ADPU_CODES: { [key: string]: number } = Object.freeze({
class: 0xa1,
* transport type according to the following priorities: HID, Bluetooth, USB.
*/
static get isUnsupported (): boolean {
- console.log('Checking browser Ledger support...')
+ if (this.#transport === undefined) {
+ console.log('Checking browser Ledger support...')
+ }
if (typeof globalThis.navigator?.hid?.getDevices === 'function') {
this.#transport ??= TransportHID
return false
*
* DISCONNECTED | BUSY | LOCKED | CONNECTED
*/
- static get status (): DeviceStatus { return this.#status }
+ static get status (): LedgerStatus { return this.#status }
/**
* Request an account at a specific BIP-44 index.
/**
* 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:
* - 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 this.account()
- if (status === 'OK') {
- this.#status = 'CONNECTED'
- } else if (status === 'SECURITY_STATUS_NOT_SATISFIED') {
- this.#status = 'LOCKED'
- } else {
+ static async connect (api?: 'hid' | 'ble' | 'usb'): Promise<LedgerStatus> {
+ if (Ledger.isUnsupported) {
+ throw new Error('Browser is unsupported')
+ }
+ if (api !== undefined) {
+ if (api === 'hid' && Ledger.#transport !== TransportHID) {
+ Ledger.#transport = TransportHID
+ }
+ if (api === 'ble' && Ledger.#transport !== TransportBLE) {
+ Ledger.#transport = TransportBLE
+ }
+ if (api === 'usb' && Ledger.#transport !== TransportUSB) {
+ Ledger.#transport = TransportUSB
+ }
+ }
+ try {
+ const version = await this.#version()
+ if (version.status !== 'OK') {
this.#status = 'DISCONNECTED'
+ } else if (version.name === 'Nano') {
+ const { status } = await this.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'
}
- } else {
- this.#status = 'BUSY'
+ } catch (err) {
+ console.error(err)
+ this.#status = 'DISCONNECTED'
}
+ console.log(this.#status)
return this.#status
}
publicKey?: string | Uint8Array<ArrayBuffer>
}
+export type LedgerStatus = 'DISCONNECTED' | 'BUSY' | 'LOCKED' | 'CONNECTED'
+
+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
+}
+
/**
* Represents a basic address book of Nano accounts. Multiple addresses can be
* saved under one nickname.
*/
static backup (): Promise<NamedData[]>
/**
- * Creates a new Ledger wallet manager.
+ * Creates a new hardware wallet manager.
*
- * @param {string} type - Encrypts the wallet to lock and unlock it
+ * @param {string} type - Wallet manufacturer
* @returns {Wallet} A newly instantiated Wallet
*/
static create (type: 'Ledger'): Promise<Wallet>
* Creates a new HD wallet by using an entropy value generated using a
* cryptographically strong pseudorandom number generator.
*
+ * @param {string} type - Algorithm used to generate wallet and child accounts
* @param {string} password - Encrypts the wallet to lock and unlock it
* @param {string} [salt=''] - Used when generating the final seed
* @returns {Wallet} A newly instantiated Wallet
*/
accounts (from?: number, to?: number): Promise<Map<number, Account>>
/**
+ * Configures Ledger connection settings.
+ * @param {string} connection - Transport interface to use
+ */
+ config (settings: {
+ connection: 'hid' | 'ble' | 'usb'
+ }): Promise<void>
+ /**
* Configures vault worker settings.
* @param {number} timeout - Measured in seconds of inactivity before wallet automatically locks
*/
*/
verify (mnemonic: string): Promise<boolean>
}
-
-type DeviceStatus = 'DISCONNECTED' | 'BUSY' | 'LOCKED' | 'CONNECTED'
-
-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
-}
-
-/**
-* 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 declare class Ledger {
- #private
- static DynamicTransport: typeof TransportBLE | typeof TransportUSB | typeof TransportHID
- static UsbVendorId: number
- static SYMBOL: Symbol
- /**
- * 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
- /**
- * Status of the Ledger device connection.
- *
- * DISCONNECTED | BUSY | LOCKED | CONNECTED
- */
- static get status (): DeviceStatus
- /**
- * Request an account at a specific BIP-44 index.
- *
- * @returns Response object containing command status, public key, and address
- */
- static account (index?: number, show?: boolean): Promise<LedgerAccountResponse>
- /**
- * 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 connect (): Promise<DeviceStatus>
- /**
- * 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 sign (index: number, block: Block, frontier?: Block): Promise<string>
- /**
- * Update cache from raw block data. Suitable for offline use.
- *
- * @param {number} index - Account number
- * @param {object} block - JSON-formatted block data
- */
- static 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 updateCache (index: number, hash: string, rpc: Rpc): Promise<LedgerResponse>
- /**
- * 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 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 verify (mnemonic: string): Promise<boolean>
-}
}
await test('request permissions', async () => {
- await assert.rejects(wallet.unlock(), 'expect DISCONNECTED')
+ console.log('expect DISCONNECTED...')
+ await click(
+ 'Reset permissions, unlock device, quit Nano app, then click to continue',
+ async () => new Promise(r => setTimeout(r, 5000))
+ )
+ await assert.rejects(wallet.unlock())
assert.equal(wallet.isLocked, true)
await assert.rejects(async () => {
+ console.log('expect BUSY...')
await click(
'Reset permissions, unlock device, quit Nano app, then click to continue',
async () => wallet.unlock()
)
- }, 'expect BUSY')
+ })
assert.equal(wallet.isLocked, true)
await assert.rejects(async () => {
+ console.log('expect LOCKED...')
await click(
'Open Nano app on device, allow device to auto-lock, then click to continue',
async () => wallet.unlock()
)
- }, 'expect LOCKED')
+ })
assert.equal(wallet.isLocked, true)
await assert.resolves(async () => {
+ console.log('expect CONNECTED...')
await click(
'Unlock device, verify Nano app is open, then click to continue',
async () => wallet.unlock()
)
- }, 'expect CONNECTED')
+ })
+ assert.equal(wallet.isLocked, false)
+
+ await assert.resolves(async () => {
+ console.log('expect BUSY...')
+ await click(
+ 'Verify current interface is HID, switch to Bluetooth device, then click to continue',
+ async () => wallet.config({ connection: 'ble' })
+ )
+ })
+ assert.equal(wallet.isLocked, false)
+
+ await assert.resolves(async () => {
+ console.log('expect CONNECTED...')
+ await click(
+ 'Verify current interface is BLE, switch back to USB device, then click to continue',
+ async () => wallet.config({ connection: 'usb' })
+ )
+ })
+ assert.equal(wallet.isLocked, false)
+
+ await assert.resolves(async () => {
+ console.log('expect CONNECTED...')
+ await click(
+ 'Verify current interface is USB, then click to continue',
+ async () => wallet.config({ connection: 'hid' })
+ )
+ })
assert.equal(wallet.isLocked, false)
})
await test('destroy wallet', async () => {
await wallet.destroy()
+ await click(
+ 'Click to finish Ledger tests by destroying wallet',
+ async () => new Promise(r => setTimeout(r))
+ )
await assert.rejects(wallet.unlock())
})
})