]> git.codecow.com Git - libnemo.git/commitdiff
Dynamically export Ledger class and start building additional support for its feature...
authorChris Duncan <chris@zoso.dev>
Tue, 16 Sep 2025 21:51:14 +0000 (14:51 -0700)
committerChris Duncan <chris@zoso.dev>
Tue, 16 Sep 2025 21:51:14 +0000 (14:51 -0700)
src/lib/ledger.ts
src/lib/wallet/index.ts
src/main.ts
src/types.d.ts
test/test.ledger.mjs

index dce46580b47215b656d4dcf2b6a8494780323a70..b892477ba32367b2c28835d75104220fe26565e1 100644 (file)
@@ -24,6 +24,7 @@ import { Wallet } from './wallet'
 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({
@@ -60,9 +61,10 @@ export class Ledger {
        * 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
@@ -381,6 +383,7 @@ export class Ledger {
                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)
@@ -391,6 +394,7 @@ export class Ledger {
                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)
@@ -402,6 +406,7 @@ export class Ledger {
                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)
@@ -412,6 +417,7 @@ export class Ledger {
                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)
index e567420a92fb7bf505fd175c2e24afae8a98efda..c73f2c1cbfbc9ed40af3e1b1036c6ad3de807675 100644 (file)
@@ -31,6 +31,7 @@ import { _verify } from './verify'
 */\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
@@ -60,6 +61,7 @@ export class Wallet {
        */\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
@@ -88,9 +90,13 @@ export class Wallet {
        */\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
@@ -109,6 +115,7 @@ export class Wallet {
        */\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
@@ -141,7 +148,9 @@ export class Wallet {
        * @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
index 432b4b9ac89e79f4aaac15c3a17fafc2c1f9466d..317289dbe282ffcb243b3cda5c83dd67eb0ac9ad 100644 (file)
@@ -9,10 +9,16 @@ import { Rpc } from './lib/rpc'
 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,
index f4839d585611ae768d05d0457eead3eff7efc7b3..4ed99950f778703d76fb3410ea66119999571be2 100644 (file)
@@ -1,9 +1,7 @@
 //! 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
@@ -471,6 +469,92 @@ interface LedgerSignResponse extends LedgerResponse {
        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.
index 6837f9ad3e6826005b4858c391fd9cd262068597..46b1276bc788360099430b16ddabe267dbb934ab 100644 (file)
@@ -15,6 +15,10 @@ let Account
 */
 let Block
 /**
+* @type {typeof import('../dist/types.d.ts').Ledger}
+*/
+let Ledger
+/**
 * @type {typeof import('../dist/types.d.ts').Rpc}
 */
 let Rpc
@@ -24,9 +28,9 @@ 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)
@@ -41,6 +45,14 @@ 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
@@ -53,67 +65,107 @@ await Promise.all([
                }
 
                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 () => {