]> git.codecow.com Git - libnemo.git/commitdiff
Move Ledger block signing tests into dedicated file.
authorChris Duncan <chris@zoso.dev>
Wed, 9 Jul 2025 17:55:57 +0000 (10:55 -0700)
committerChris Duncan <chris@zoso.dev>
Wed, 9 Jul 2025 17:55:57 +0000 (10:55 -0700)
index.html
src/lib/wallets/ledger-wallet.ts
test/GLOBALS.mjs
test/test.ledger.mjs
test/test.sign-blocks.mjs

index f193df59ced011b332ad0eecc1dc13c61338fa2f..b1a75c6cbc59eedcf2768849d1108c51b91e7208 100644 (file)
@@ -41,7 +41,6 @@ SPDX-License-Identifier: GPL-3.0-or-later
                                a = a.replace('%c', '<span style="font-weight:bold">')
                                output.innerHTML += `<pre>${a}</pre>`
                                consoleGroup(...args)
-                               window?.scrollTo(0, document.body.scrollHeight)
                        }
                        const consoleError = console.error
                        console.error = (...args) => {
@@ -49,7 +48,6 @@ SPDX-License-Identifier: GPL-3.0-or-later
                                a = a.replace('%cFAIL ,color:red,', '<span style="color:red">FAIL </span>')
                                output.innerHTML += `<pre>${a}</pre>`
                                consoleError(...args)
-                               window?.scrollTo(0, document.body.scrollHeight)
                        }
                        const consoleLog = console.log
                        console.log = (...args) => {
@@ -60,7 +58,6 @@ SPDX-License-Identifier: GPL-3.0-or-later
                                a = a.replace('%cTESTING COMPLETE,color:orange;font-weight:bold', '<span style="color:orange;font-weight:bold">TESTING COMPLETE</span>')
                                output.innerHTML += `<pre>${a}</pre>`
                                consoleLog(...args)
-                               window?.scrollTo(0, document.body.scrollHeight)
                        }
                })()
        </script>
index 3bf2093654445ee96d753354c1c324651640f837..d33b42bfa60a39622db551cc01f78089080b1126 100644 (file)
@@ -80,7 +80,10 @@ export class LedgerWallet extends Wallet {
        */\r
        async destroy (): Promise<void> {\r
                await super.destroy()\r
-               await this.close()\r
+               const { status } = await this.close()\r
+               if (status !== 'OK') {\r
+                       throw new Error('Failed to close wallet', { cause: status })\r
+               }\r
        }\r
 \r
        /**\r
@@ -108,8 +111,8 @@ export class LedgerWallet extends Wallet {
        * @returns True if successfully locked\r
        */\r
        async lock (): Promise<boolean> {\r
-               const result = await this.close()\r
-               return result.status === 'OK'\r
+               const { status } = await this.close()\r
+               return status === 'OK'\r
        }\r
 \r
        /**\r
@@ -121,8 +124,8 @@ export class LedgerWallet extends Wallet {
        * @returns True if successfully unlocked\r
        */\r
        async unlock (): Promise<boolean> {\r
-               const result = await this.connect()\r
-               return result === 'OK'\r
+               const { status } = await this.connect()\r
+               return status === 'OK'\r
        }\r
 \r
        async init (): Promise<void> {\r
@@ -157,7 +160,7 @@ export class LedgerWallet extends Wallet {
                }\r
        }\r
 \r
-       async connect (): Promise<string> {\r
+       async connect (): Promise<LedgerResponse> {\r
                const { usb } = globalThis.navigator\r
                if (usb) {\r
                        usb.removeEventListener('disconnect', this.onDisconnectUsb.bind(this))\r
@@ -185,7 +188,7 @@ export class LedgerWallet extends Wallet {
                } else {\r
                        this.#status = 'DISCONNECTED'\r
                }\r
-               return this.status\r
+               return { status: this.status }\r
        }\r
 \r
        async onDisconnectUsb (e: USBConnectionEvent): Promise<void> {\r
@@ -197,7 +200,7 @@ export class LedgerWallet extends Wallet {
        }\r
 \r
        /**\r
-       * Open Nano app by launching user flow.\r
+       * Open the Nano app by launching a user flow.\r
        *\r
        * https://developers.ledger.com/docs/connectivity/ledgerJS/open-close-info-on-apps#open-application\r
        *\r
@@ -219,7 +222,7 @@ export class LedgerWallet extends Wallet {
        }\r
 \r
        /**\r
-       * Close the currently running app.\r
+       * Close the currently running app and return to the device dashboard.\r
        *\r
        * https://developers.ledger.com/docs/connectivity/ledgerJS/open-close-info-on-apps#quit-application\r
        *\r
@@ -347,18 +350,20 @@ export class LedgerWallet extends Wallet {
                const statusCode = bytes.toDec(response.slice(-2)) as number\r
                const status = LEDGER_STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR'\r
 \r
-               if (status !== 'OK') {\r
+               if (response.byteLength === 2) {\r
                        return { status, signature: null }\r
                }\r
-               if (response.byteLength > 66) {\r
+               if (response.byteLength === 66) {\r
                        const signature = bytes.toHex(response.slice(0, 64))\r
                        return { status, signature }\r
                }\r
+               if (response.byteLength === 98) {\r
+                       const hash = bytes.toHex(response.slice(0, 32))\r
+                       const signature = bytes.toHex(response.slice(32, 96))\r
+                       return { status, signature, hash }\r
+               }\r
 \r
-               const hash = bytes.toHex(response.slice(0, 32))\r
-               const signature = bytes.toHex(response.slice(32, 96))\r
-               return { status, signature, hash }\r
-\r
+               throw new Error('Unexpected byte length from device signature', { cause: response })\r
        }\r
 \r
        /**\r
index 62770406d3eade12028b930fab9cd37f2273f0ac..1af784abe3155a0ea602bfbe0e40b6682c63a4b9 100644 (file)
@@ -24,6 +24,29 @@ export { process }
 
 export const isNode = process.versions?.node != null
 
+export async function click (text, fn) {
+       return new Promise((resolve, rejects) => {
+               const button = document.createElement('button')
+               const hourglass = document.createTextNode('⏳')
+               button.innerText = text
+               button.addEventListener('click', async () => {
+                       button.innerText = 'Waiting for device...'
+                       button.after(hourglass)
+                       try {
+                               const result = await fn()
+                               resolve(result)
+                       } catch (err) {
+                               rejects(err)
+                       } finally {
+                               hourglass.remove()
+                               button.remove()
+                       }
+               })
+               document.body.appendChild(button)
+               window?.scrollTo(0, document.body.scrollHeight)
+       })
+}
+
 export function stats (times) {
        if (times == null || times.length === 0) return null
 
@@ -131,7 +154,7 @@ export function suite (name, opts, fn) {
                        console.groupEnd()
                        return
                }
-               if (fn.constructor.name === 'AsyncFunction') fn = fn()
+               if (fn?.constructor?.name === 'AsyncFunction') fn = fn()
                if (typeof fn === 'function') fn = new Promise(resolve => resolve(fn()))
                console.group(`%c${name}`, 'font-weight:bold')
                await fn
@@ -171,6 +194,7 @@ export function test (name, opts, fn) {
        } else {
                fail(`${name}: test cannot execute on ${typeof fn} ${fn}`)
        }
+       window?.scrollTo(0, document.body.scrollHeight)
 }
 
 export const assert = {
index 32ec01b1d80c7b9fa62a02353af13c830cf4ab94..4b3ea0dbd91f8babc4d67af458d00239b0e69717 100644 (file)
 
 'use strict'
 
-import { assert, isNode, suite, test } from './GLOBALS.mjs'
+import { assert, click, isNode, suite, test } from './GLOBALS.mjs'
 import { NANO_TEST_VECTORS } from './VECTORS.js'
-import { LedgerWallet } from '../dist/main.min.js'
+import { Account, LedgerWallet, ReceiveBlock, SendBlock } from '../dist/main.min.js'
 
-async function click (text, fn) {
-       return new Promise(resolve => {
-               const button = document.createElement('button')
-               button.innerText = text
-               button.addEventListener('click', async (event) => {
-                       const result = await fn
-                       document.body.removeChild(button)
-                       resolve(result)
-               })
-               document.body.appendChild(button)
+/**
+* HID interactions require user gestures, so to reduce clicks, the variables
+* shared among tests like wallet and account are declared at the top-level.
+*/
+await suite('Ledger hardware wallet', { skip: false || isNode }, async () => {
+
+       let wallet, account, openBlock, sendBlock, receiveBlock
+
+       await test('connect to the device and then disconnect', async () => {
+               wallet = await LedgerWallet.create()
+               const { status } = await click(
+                       'Unlock, then click to connect',
+                       async () => wallet.connect()
+               )
+               assert.equals(status, 'CONNECTED')
        })
-}
 
-await suite('Ledger hardware wallet', async () => {
+       // nonce signing is currently broken: https://github.com/LedgerHQ/app-nano/pull/14
+       await test('sign a nonce', { skip: true }, async () => {
+               // const nonce = new TextEncoder().encode('0123456789abcdef')
+               // const {status, signature} = await click('Click to sign nonce', wallet.sign(0, nonce))
 
-       await test('fail when using new', async () => {
-               assert.throws(() => new LedgerWallet())
+               // assert.equals(resultSignNonce.status, 'OK')
+               // assert.OK(/[A-Fa-f0-9]{128}/.test(resultSignNonce.signature))
+       })
+
+       await test('get first account', async () => {
+               account = await click(
+                       'Click to get account',
+                       async () => wallet.account()
+               )
+
+               assert.exists(account)
+               assert.ok(account instanceof Account)
+               assert.exists(account.publicKey)
+               assert.exists(account.address)
+       })
+
+       await test('sign open block from block', async () => {
+               openBlock = new ReceiveBlock(
+                       account,
+                       '0',
+                       NANO_TEST_VECTORS.RECEIVE_BLOCK.link,
+                       NANO_TEST_VECTORS.RECEIVE_BLOCK.balance,
+                       NANO_TEST_VECTORS.RECEIVE_BLOCK.representative,
+                       '0'
+               )
+
+               assert.ok(/[A-Fa-f0-9]{64}/.test(openBlock.hash))
+               assert.nullish(openBlock.signature)
+               assert.equals(openBlock.account.publicKey, account.publicKey)
+
+               const { status, hash, signature } = await click(
+                       'Click to sign opening ReceiveBlock from wallet',
+                       async () => wallet.sign(0, openBlock)
+               )
+
+               assert.equals(status, 'OK')
+               assert.ok(/[A-Fa-f0-9]{64}/.test(hash))
+               assert.ok(/[A-Fa-f0-9]{128}/.test(signature))
+
+               await click(
+                       'Click to sign open ReceiveBlock from block and compare signatures',
+                       async () => openBlock.sign(0)
+               )
+
+               assert.ok(/[A-Fa-f0-9]{128}/.test(openBlock.signature))
+               assert.equals(signature, openBlock.signature)
        })
 
-       await test('connect to a Ledger device and then disconnect', { skip: false || isNode }, async () => {
-               const wallet = await LedgerWallet.create()
-               const openStatus = await click('Unlock, then click to connect', wallet.connect())
+       await test('cache open block', async () => {
+               const { status } = await click(
+                       'Click to cache open block',
+                       async () => wallet.updateCache(0, openBlock)
+               )
+
+               assert.equals(status, 'OK')
+       })
 
-               assert.equals(openStatus, 'CONNECTED')
+       await test('sign send block from wallet which requires cache to be up-to-date', async () => {
+               sendBlock = new SendBlock(
+                       account,
+                       openBlock.balance,
+                       account.address,
+                       '0',
+                       NANO_TEST_VECTORS.SEND_BLOCK.representative,
+                       openBlock.hash
+               )
+
+               assert.ok(/[A-Fa-f0-9]{64}/.test(sendBlock.hash))
+               assert.nullish(sendBlock.signature)
+               assert.equals(sendBlock.account.publicKey, account.publicKey)
+
+               const { status, hash, signature } = await click(
+                       'Click to sign SendBlock from wallet',
+                       async () => wallet.sign(0, sendBlock)
+               )
+
+               assert.equals(status, 'OK')
+               assert.ok(/[A-Fa-f0-9]{64}/.test(hash))
+               assert.ok(/[A-Fa-f0-9]{128}/.test(signature))
+               sendBlock.signature = signature
+       })
+
+       await test('sign a receive block from block object which can accept previous block for cache', async () => {
+               receiveBlock = new ReceiveBlock(
+                       account,
+                       sendBlock.balance,
+                       sendBlock.hash,
+                       '0',
+                       NANO_TEST_VECTORS.RECEIVE_BLOCK.representative,
+                       sendBlock.hash
+               )
+
+               assert.ok(/[A-Fa-f0-9]{64}/.test(sendBlock.hash))
+               assert.nullish(receiveBlock.signature)
+               assert.equals(receiveBlock.account.publicKey, account.publicKey)
+
+               await click(
+                       'Click to sign SendBlock from block',
+                       async () => receiveBlock.sign(0, sendBlock)
+               )
+
+               assert.ok(/[A-Fa-f0-9]{128}/.test(receiveBlock.signature))
+       })
+
+       await test('destroy wallet', async () => {
+               const { status } = await click(
+                       'Click to close',
+                       async () => wallet.close()
+               )
+               assert.equals(status, 'OK')
+               await wallet.destroy()
+       })
+
+       await test('fail when using new', async () => {
+               assert.throws(() => new LedgerWallet())
+       })
 
-               const closeStatus = await click('Click to close', wallet.close())
-               assert.equals(closeStatus.status, 'OK')
+       await test('fail to sign a block without caching frontier', async () => {
+               sendBlock = new SendBlock(
+                       account,
+                       receiveBlock.balance,
+                       account.address,
+                       '0',
+                       NANO_TEST_VECTORS.SEND_BLOCK.representative,
+                       receiveBlock.previous
+               )
+               await assert.rejects(click(
+                       'Fail to sign',
+                       async () => sendBlock.sign(0)
+               ))
        })
 })
index cc76c171c05f994a017e9d445ac293704c4315ce..6997be33468a1f02cda52a5dd99da6f149b29975 100644 (file)
@@ -5,7 +5,7 @@
 \r
 import { assert, suite, test } from './GLOBALS.mjs'\r
 import { NANO_TEST_VECTORS } from './VECTORS.js'\r
-import { SendBlock, ReceiveBlock, ChangeBlock, LedgerWallet } from '../dist/main.min.js'\r
+import { SendBlock, ReceiveBlock, ChangeBlock } from '../dist/main.min.js'\r
 \r
 await suite('Valid blocks', async () => {\r
 \r
@@ -163,97 +163,3 @@ await suite('Block signing tests using official test vectors', async () => {
                assert.equals(block.work, '')\r
        })\r
 })\r
-\r
-await suite('Ledger device signing tests', { skip: false }, async () => {\r
-       const wallet = await LedgerWallet.create()\r
-       const account = await new Promise(resolve => {\r
-               const button = document.createElement('button')\r
-               button.innerText = 'Unlock Ledger, then click to continue'\r
-               button.addEventListener('click', async (event) => {\r
-                       await wallet.connect()\r
-                       resolve(await wallet.account())\r
-                       document.body.removeChild(button)\r
-               })\r
-               document.body.appendChild(button)\r
-       })\r
-\r
-       // nonce signing is currently broken: https://github.com/LedgerHQ/app-nano/pull/14\r
-       await test('should sign a nonce with a Ledger device', { skip: true }, async () => {\r
-               let status = await new Promise(resolve => {\r
-                       const button = document.createElement('button')\r
-                       button.innerText = 'Unlock Ledger, then click to continue'\r
-                       button.addEventListener('click', async (event) => {\r
-                               await wallet.connect()\r
-                               const result = await wallet.sign(0, new TextEncoder().encode('0123456789abcdef'))\r
-\r
-                               assert.equals(result.status, 'OK')\r
-                               assert.equals(result.signature.toUpperCase(), '2BD2F905E74B5BEE3E2277CED1D1E3F7535E5286B6E22F7B08A814AA9E5C4E1FEA69B61D60B435ADC2CE756E6EE5F5BE7EC691FE87E024A0B22A3D980CA5B305')\r
-\r
-                               document.body.removeChild(button)\r
-                               resolve(result)\r
-                       })\r
-                       document.body.appendChild(button)\r
-               })\r
-       })\r
-\r
-       await test('should sign an open block and send block with a Ledger device', async () => {\r
-               const openBlock = new ReceiveBlock(\r
-                       account,\r
-                       '0',\r
-                       NANO_TEST_VECTORS.RECEIVE_BLOCK.link,\r
-                       NANO_TEST_VECTORS.RECEIVE_BLOCK.balance,\r
-                       NANO_TEST_VECTORS.RECEIVE_BLOCK.representative,\r
-                       '0'\r
-               )\r
-               await new Promise(resolve => {\r
-                       const button = document.createElement('button')\r
-                       button.innerText = 'Unlock Ledger, then click to test signing open ReceiveBlock'\r
-                       button.addEventListener('click', async (event) => {\r
-                               assert.nullish(openBlock.signature)\r
-                               await openBlock.sign(0)\r
-                               assert.ok(/[A-Fa-f0-9]{128}/.test(openBlock.signature))\r
-                               resolve(document.body.removeChild(button))\r
-                       })\r
-                       document.body.appendChild(button)\r
-               })\r
-               assert.ok(/[A-Fa-f0-9]{128}/.test(openBlock.signature))\r
-               assert.ok(/[A-Fa-f0-9]{64}/.test(openBlock.hash))\r
-\r
-               const sendBlock = new SendBlock(\r
-                       account,\r
-                       NANO_TEST_VECTORS.RECEIVE_BLOCK.balance,\r
-                       NANO_TEST_VECTORS.RECEIVE_BLOCK.account,\r
-                       '0',\r
-                       NANO_TEST_VECTORS.RECEIVE_BLOCK.representative,\r
-                       openBlock.hash\r
-               )\r
-               await new Promise(resolve => {\r
-                       const button = document.createElement('button')\r
-                       button.innerText = 'Unlock Ledger, then click to test signing SendBlock'\r
-                       button.addEventListener('click', async (event) => {\r
-                               assert.nullish(sendBlock.signature)\r
-                               await sendBlock.sign(0, openBlock)\r
-                               assert.ok(/[A-Fa-f0-9]{128}/.test(sendBlock.signature))\r
-                               resolve(document.body.removeChild(button))\r
-                       })\r
-                       document.body.appendChild(button)\r
-               })\r
-       })\r
-\r
-       await test('should fail sign a block with a Ledger device without caching frontier', async () => {\r
-               const block = new SendBlock(\r
-                       NANO_TEST_VECTORS.SEND_BLOCK.account,\r
-                       NANO_TEST_VECTORS.SEND_BLOCK.balance,\r
-                       NANO_TEST_VECTORS.SEND_BLOCK.link,\r
-                       '0',\r
-                       NANO_TEST_VECTORS.SEND_BLOCK.representative,\r
-                       NANO_TEST_VECTORS.SEND_BLOCK.previous,\r
-                       NANO_TEST_VECTORS.SEND_BLOCK.work\r
-               )\r
-               assert.rejects(block.sign(0))\r
-       })\r
-\r
-       await test('destroy Ledger wallet', async () => {\r
-               await wallet.destroy()\r
-       })\r
-})\r