export class Ledger {
static #listenTimeout: 30000 = 30000
static #openTimeout: 3000 = 3000
+ static #polling: number | NodeJS.Timeout = 0
static #status: LedgerStatus = 'DISCONNECTED'
static #transport: typeof TransportHID | typeof TransportBLE | typeof TransportUSB
static #ADPU_CODES: { [key: string]: number } = Object.freeze({
* transport type according to the following priorities: HID, Bluetooth, USB.
*/
static get isUnsupported (): boolean {
- if (this.#transport === undefined) {
- console.log('Checking browser Ledger support...')
+ if (this.#transport !== undefined) {
+ return false
}
+ console.log('Checking browser Ledger support...')
if (typeof globalThis.navigator?.hid?.getDevices === 'function') {
this.#transport ??= TransportHID
return false
console.log(e)
if (e.device?.vendorId === ledgerUSBVendorId) {
console.log('Ledger connected via HID')
+ this.#polling = setInterval(this.connect, 1000)
const { hid } = globalThis.navigator
hid.addEventListener('disconnect', this.#onDisconnectHid)
hid.removeEventListener('connect', this.#onConnectHid)
console.log(e)
if (e.device?.vendorId === ledgerUSBVendorId) {
console.log('Ledger disconnected via HID')
+ clearInterval(this.#polling)
const { hid } = globalThis.navigator
hid.addEventListener('connect', this.#onConnectHid)
hid.removeEventListener('disconnect', this.#onDisconnectHid)
console.log(e)
if (e.device?.vendorId === ledgerUSBVendorId) {
console.log('Ledger connected via USB')
+ this.#polling = setInterval(this.connect, 1000)
const { usb } = globalThis.navigator
usb.addEventListener('disconnect', this.#onDisconnectUsb)
usb.removeEventListener('connect', this.#onConnectUsb)
console.log(e)
if (e.device?.vendorId === ledgerUSBVendorId) {
console.log('Ledger disconnected via USB')
+ clearInterval(this.#polling)
const { usb } = globalThis.navigator
usb.addEventListener('connect', this.#onConnectUsb)
usb.removeEventListener('disconnect', this.#onDisconnectUsb)
*/\r
export class Wallet {\r
static get DB_NAME (): 'Wallet' { return 'Wallet' }\r
+ static #ledger: typeof import('../ledger').Ledger\r
\r
/**\r
* Retrieves all wallets with encrypted secrets and unencrypted metadata from\r
*/\r
static async create (type: 'BIP-44' | 'BLAKE2b', password: string, mnemonicSalt?: string): Promise<Wallet>\r
static async create (type: WalletType, password?: string, mnemonicSalt?: string): Promise<Wallet> {\r
+ Wallet.#ledger ??= (await import('../ledger')).Ledger\r
Wallet.#isInternal = true\r
const self = new this(type)\r
{ ({ mnemonic: self.#mnemonic, seed: self.#seed } = await _create(self, self.#vault, password, mnemonicSalt)) }\r
*/\r
static async load (type: 'BIP-44' | 'BLAKE2b', password: string, mnemonicPhrase: string, mnemonicSalt?: string): Promise<Wallet>\r
static async load (type: WalletType, password: string, secret: string, mnemonicSalt?: string): Promise<Wallet> {\r
+ Wallet.#ledger ??= (await import('../ledger')).Ledger\r
Wallet.#isInternal = true\r
const self = new this(type)\r
await _load(self, self.#vault, password, secret, mnemonicSalt)\r
+ if (type === 'Ledger' && Wallet.#ledger === undefined) {\r
+ Wallet.#ledger = (await import('../ledger')).Ledger\r
+ }\r
return self\r
}\r
\r
*/\r
static async restore (): Promise<Wallet[]>\r
static async restore (id?: string): Promise<Wallet | Wallet[]> {\r
+ Wallet.#ledger ??= (await import('../ledger')).Ledger\r
const backups = await _restore(id)\r
const wallets = backups.map(backup => {\r
Wallet.#isInternal = true\r
* @returns True if the wallet is locked, else false\r
*/\r
get isLocked (): boolean {\r
- return this.#vault.isLocked\r
+ return this.type === 'Ledger'\r
+ ? Wallet.#ledger.status !== 'CONNECTED'\r
+ : this.#vault.isLocked\r
}\r
\r
/**\r
import { Tools } from './lib/tools'
import { Wallet } from './lib/wallet'
+let Ledger: typeof import('./lib/ledger').Ledger
+try {
+ Ledger = (await import('./lib/ledger')).Ledger
+} catch { }
+
export {
Account,
Blake2b,
Block,
+ Ledger,
Rolodex,
Rpc,
Tools,
//! SPDX-FileCopyrightText: 2025 Chris Duncan <chris@zoso.dev>
//! SPDX-License-Identifier: GPL-3.0-or-later
-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 { ledgerUSBVendorId } from '@ledgerhq/devices'
/**
* Represents a single Nano address and the associated public key. To include the
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 get UsbVendorId (): typeof ledgerUSBVendorId
+ /**
+ * Check which transport protocols are supported by the browser and return the
+ * transport type according to the following priorities: HID, Bluetooth, USB.
+ */
+ static get isUnsupported (): boolean
+ /**
+ * Status of the Ledger device connection.
+ *
+ * DISCONNECTED | BUSY | LOCKED | CONNECTED
+ */
+ static get status (): LedgerStatus
+ /**
+ * 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.
+ *
+ * @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 connect (api?: 'hid' | 'ble' | 'usb'): Promise<LedgerStatus>
+ /**
+ * Clears Ledger connections from all device interfaces.
+ */
+ static disconnect (): void
+ /**
+ * 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>
+}
+
/**
* Represents a basic address book of Nano accounts. Multiple addresses can be
* saved under one nickname.
*/
let Block
/**
+* @type {typeof import('../dist/types.d.ts').Ledger}
+*/
+let Ledger
+/**
* @type {typeof import('../dist/types.d.ts').Rpc}
*/
let Rpc
let Wallet
if (isNode) {
- ({ Account, Block, Rpc, Wallet } = await import('../dist/nodejs.min.js'))
+ ({ Account, Block, Ledger, Rpc, Wallet } = await import('../dist/nodejs.min.js'))
} else {
- ({ Account, Block, Rpc, Wallet } = await import('../dist/browser.min.js'))
+ ({ Account, Block, Ledger, Rpc, Wallet } = await import('../dist/browser.min.js'))
}
const rpc = new Rpc(env.NODE_URL ?? '', env.API_KEY_NAME)
* from their own Ledger hardware wallets.
*/
await Promise.all([
+ suite('Ledger unsupported', { skip: !(isNode || navigator?.usb == null) }, async () => {
+
+ await test('status UNSUPPORTED', async () => {
+ assert.equal(Ledger.status, 'UNSUPPORTED')
+ await assert.rejects(Wallet.create('Ledger'))
+ })
+ }),
+
suite('Ledger hardware wallet', { skip: false || isNode || navigator?.usb == null }, async () => {
const { LEDGER_MNEMONIC, LEDGER_SEED, LEDGER_PUBLIC_0, LEDGER_ADDRESS_0 } = CUSTOM_TEST_VECTORS
}
await test('request permissions', async () => {
- console.log('expect DISCONNECTED...')
await click(
- 'Reset permissions, unlock device, quit Nano app, then click to continue',
+ 'Reset permissions, then click to continue',
async () => new Promise(r => setTimeout(r, 5000))
)
await assert.rejects(wallet.unlock())
assert.equal(wallet.isLocked, true)
+ assert.equal(Ledger.status, 'DISCONNECTED')
await assert.rejects(async () => {
- console.log('expect BUSY...')
await click(
- 'Reset permissions, unlock device, quit Nano app, then click to continue',
+ 'Unlock device, quit Nano app, then click to continue',
async () => wallet.unlock()
)
})
assert.equal(wallet.isLocked, true)
+ assert.equal(Ledger.status, 'BUSY')
+
+ await new Promise(async (resolve) => {
+ console.log('Waiting 6 seconds...')
+ setTimeout(async () => {
+ // should still be locked and busy
+ assert.equal(wallet.isLocked, true)
+ assert.equal(Ledger.status, 'BUSY')
+ resolve(null)
+ }, 6000)
+ })
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()
)
})
assert.equal(wallet.isLocked, true)
+ assert.equal(Ledger.status, 'LOCKED')
+
+ await new Promise(async (resolve) => {
+ console.log('Waiting 6 seconds...')
+ setTimeout(async () => {
+ // should still be locked
+ assert.equal(wallet.isLocked, true)
+ assert.equal(Ledger.status, 'LOCKED')
+ resolve(null)
+ }, 6000)
+ })
await assert.resolves(async () => {
- console.log('expect CONNECTED...')
await click(
'Unlock device, verify Nano app is open, then click to continue',
async () => wallet.unlock()
)
})
assert.equal(wallet.isLocked, false)
+ assert.equal(Ledger.status, 'CONNECTED')
+
+ await new Promise(async (resolve) => {
+ console.log('Waiting 6 seconds...')
+ setTimeout(async () => {
+ // should still be unlocked
+ assert.equal(wallet.isLocked, false)
+ assert.equal(Ledger.status, 'CONNECTED')
+ resolve(null)
+ }, 6000)
+ })
+
+ await new Promise(async (resolve) => {
+ console.log('Waiting 60 seconds...')
+ setTimeout(async () => {
+ // should now be locked
+ assert.equal(wallet.isLocked, true)
+ assert.equal(Ledger.status, 'LOCKED')
+ resolve(null)
+ }, 60000)
+ })
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)
+ assert.equal(Ledger.status, 'BUSY')
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)
+ assert.equal(Ledger.status, 'CONNECTED')
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)
+ assert.equal(Ledger.status, 'CONNECTED')
})
await test('verify mnemonic', async () => {