]> git.codecow.com Git - libnemo.git/commitdiff
Add wallet method to sign blocks by account index. Remove nonce signing from Ledger...
authorChris Duncan <chris@zoso.dev>
Thu, 24 Jul 2025 21:23:56 +0000 (14:23 -0700)
committerChris Duncan <chris@zoso.dev>
Thu, 24 Jul 2025 21:23:56 +0000 (14:23 -0700)
src/lib/block.ts
src/lib/wallets/ledger-wallet.ts
src/lib/wallets/wallet.ts
src/types.d.ts
test/test.blocks.mjs
test/test.ledger.mjs

index aff883f4c273d58a8145c5179522d5f68b82ba59..a56d2a1e7d964c563c5d4da2f049b522c84762ec 100644 (file)
@@ -141,27 +141,27 @@ abstract class Block {
        * property.
        *
        * @param {number} index - Account index between 0x0 and 0x7fffffff
-       * @param {object} [block] - JSON of previous block for offline signing
+       * @param {object} [frontier] - JSON of frontier block for offline signing
        */
-       async sign (index?: number, block?: { [key: string]: string }): Promise<void>
-       async sign (input?: number | string, block?: { [key: string]: string }): Promise<void> {
+       async sign (index?: number, frontier?: ChangeBlock | ReceiveBlock | SendBlock): Promise<void>
+       async sign (input?: number | string, frontier?: ChangeBlock | ReceiveBlock | SendBlock): Promise<void> {
                if (typeof input === 'number') {
                        const index = input
                        const { LedgerWallet } = await import('./wallets')
                        const ledger = await LedgerWallet.create()
                        await ledger.connect()
-                       if (block) {
+                       if (frontier) {
                                try {
-                                       await ledger.updateCache(index, block)
+                                       await ledger.updateCache(index, frontier)
                                } catch (err) {
                                        console.warn('Error updating Ledger cache of previous block, attempting signature anyway', err)
                                }
                        }
-                       const result = await ledger.sign(index, this as SendBlock | ReceiveBlock | ChangeBlock)
-                       if (result.status !== 'OK' || result.signature == null) {
-                               throw new Error(result.status)
+                       try {
+                               this.signature = await ledger.sign(index, this as SendBlock | ReceiveBlock | ChangeBlock)
+                       } catch (err) {
+                               throw new Error('failed to sign with ledger', { cause: err })
                        }
-                       this.signature = result.signature
                } else if (typeof input === 'string') {
                        try {
                                const account = await Account.import({ index: 0, privateKey: input }, '')
index c2b059f16da80a6db2abebc615610af7e857ff96..a813183ac33cee7672fad7dacf883e8de664e84c 100644 (file)
@@ -11,28 +11,7 @@ import { BIP44_COIN_NANO, BIP44_PURPOSE, HARDENED_OFFSET, LEDGER_ADPU_CODES, LED
 import { bytes, dec, hex } from '#src/lib/convert.js'\r
 import { Entropy } from '#src/lib/entropy.js'\r
 import { Rpc } from '#src/lib/rpc.js'\r
-import { KeyPair } from '#types'\r
-\r
-type DeviceStatus = 'DISCONNECTED' | 'BUSY' | 'LOCKED' | 'CONNECTED'\r
-\r
-interface LedgerResponse {\r
-       status: string\r
-}\r
-\r
-interface LedgerVersionResponse extends LedgerResponse {\r
-       name: string | null,\r
-       version: string | null\r
-}\r
-\r
-interface LedgerAccountResponse extends LedgerResponse {\r
-       publicKey: string | null,\r
-       address: string | null\r
-}\r
-\r
-interface LedgerSignResponse extends LedgerResponse {\r
-       signature: string | null,\r
-       hash?: string\r
-}\r
+import { DeviceStatus, KeyPair, LedgerAccountResponse, LedgerResponse, LedgerSignResponse, LedgerVersionResponse } from '#types'\r
 \r
 /**\r
 * Ledger hardware wallet created by communicating with a Ledger device via ADPU\r
@@ -221,29 +200,31 @@ export class LedgerWallet extends Wallet {
        *\r
        * @param {number} index - Account number\r
        * @param {object} block - Block data to sign\r
-       * @returns {Promise} Status, signature, and block hash\r
+       * @returns {Promise<string>} Signature\r
        */\r
-       async sign (index: number, block: SendBlock | ReceiveBlock | ChangeBlock): Promise<LedgerSignResponse>\r
-       /**\r
-       * Sign a nonce with the Ledger device.\r
-       *\r
-       * @param {number} index - Account number\r
-       * @param {Uint8Array} nonce - 128-bit value to sign\r
-       * @returns {Promise} Status and signature\r
-       */\r
-       async sign (index: number, nonce: Uint8Array<ArrayBuffer>): Promise<LedgerSignResponse>\r
-       async sign (index: number = 0, input: Uint8Array<ArrayBuffer> | SendBlock | ReceiveBlock | ChangeBlock): Promise<LedgerSignResponse> {\r
+       async sign (index: number, block: SendBlock | ReceiveBlock | ChangeBlock, frontier?: SendBlock | ReceiveBlock | ChangeBlock): Promise<string> {\r
                if (typeof index !== 'number' || index < 0 || index >= HARDENED_OFFSET) {\r
                        throw new TypeError('Invalid account index')\r
                }\r
+               if (frontier != null) {\r
+                       const { status } = await this.#cacheBlock(index, frontier)\r
+                       if (status !== 'OK') {\r
+                               throw new Error('failed to cache frontier block in ledger', { cause: status })\r
+                       }\r
+               }\r
                console.log('Waiting for signature confirmation on Ledger device...')\r
-               if (input instanceof Uint8Array) {\r
-                       // input is a nonce\r
-                       return await this.#signNonce(index, input)\r
-               } else {\r
-                       // input is a block\r
-                       return await this.#signBlock(index, input)\r
+               const { status, signature, hash } = await this.#signBlock(index, block)\r
+               if (status !== 'OK') {\r
+                       throw new Error('signing with ledger failed', { cause: status })\r
+               }\r
+               if (hash !== block.hash) {\r
+                       throw new Error('hash from ledger does not match hash from block', { cause: `${hash} | ${block.hash}` })\r
+               }\r
+               if (signature == null) {\r
+                       throw new Error('ledger failed to return signature')\r
                }\r
+               block.signature = signature\r
+               return signature\r
        }\r
 \r
        /**\r
@@ -264,7 +245,7 @@ export class LedgerWallet extends Wallet {
        * @param {number} index - Account number\r
        * @param {object} block - JSON-formatted block data\r
        */\r
-       async updateCache (index: number, block: { [key: string]: string }): Promise<LedgerResponse>\r
+       async updateCache (index: number, block: ChangeBlock | ReceiveBlock | SendBlock): Promise<LedgerResponse>\r
        /**\r
        * Update cache from a block hash by calling out to a node. Suitable for online\r
        * use only.\r
@@ -288,7 +269,7 @@ export class LedgerWallet extends Wallet {
                }\r
                const { status } = await this.#cacheBlock(index, input)\r
                if (status !== 'OK') {\r
-                       throw new Error(status)\r
+                       throw new Error('failed to cache frontier block in ledger', { cause: status })\r
                }\r
                return { status }\r
        }\r
@@ -392,11 +373,11 @@ export class LedgerWallet extends Wallet {
        * @param {any} block - Block data to cache\r
        * @returns Status of command\r
        */\r
-       async #cacheBlock (index: number = 0, block: SendBlock | ReceiveBlock | ChangeBlock): Promise<LedgerResponse> {\r
+       async #cacheBlock (index: number = 0, block: ChangeBlock | ReceiveBlock | SendBlock): Promise<LedgerResponse> {\r
                if (typeof index !== 'number' || index < 0 || index >= HARDENED_OFFSET) {\r
                        throw new TypeError('Invalid account index')\r
                }\r
-               if (!(block instanceof SendBlock) && !(block instanceof ReceiveBlock) && !(block instanceof ChangeBlock)) {\r
+               if (!(block instanceof ChangeBlock) && !(block instanceof ReceiveBlock) && !(block instanceof SendBlock)) {\r
                        throw new TypeError('Invalid block format')\r
                }\r
                if (!block.signature) {\r
index 7a8153f92ad440ea175ebd2ef2916468448f7ad1..fc5d92429d8b0678045643f4777c40ac0874bff3 100644 (file)
@@ -2,6 +2,7 @@
 //! SPDX-License-Identifier: GPL-3.0-or-later\r
 \r
 import { Account, AccountList } from '#src/lib/account.js'\r
+import { ChangeBlock, ReceiveBlock, SendBlock } from '#src/lib/block.js'\r
 import { Bip39Mnemonic } from '#src/lib/bip39-mnemonic.js'\r
 import { ADDRESS_GAP } from '#src/lib/constants.js'\r
 import { bytes, hex, utf8 } from '#src/lib/convert.js'\r
@@ -234,6 +235,26 @@ export abstract class Wallet {
                return accounts\r
        }\r
 \r
+       /**\r
+       * Signs a block using the private key of the account at the wallet index\r
+       * specified. The signature is appended to the signature field of the block\r
+       * before being returned. The wallet must be unlocked prior to signing.\r
+       *\r
+       * @param {(ChangeBlock|ReceiveBlock|SendBlock)} block - Block data to be hashed and signed\r
+       * @param {number} index - Account to use for signing\r
+       * @returns {Promise<string>} Hexadecimal-formatted 64-byte signature\r
+       */\r
+       async sign (index: number, block: ChangeBlock | ReceiveBlock | SendBlock): Promise<string> {\r
+               if (this.#locked) throw new Error('wallet must be unlocked to sign')\r
+               if (this.#s == null) throw new Error('wallet seed not found')\r
+               try {\r
+                       const account = this.account(index)\r
+                       return (await account).sign(block, new Uint8Array(this.#s))\r
+               } catch (err) {\r
+                       throw new Error(`failed to sign block`, { cause: err })\r
+               }\r
+       }\r
+\r
        /**\r
        * Unlocks the wallet using the same password as used prior to lock it.\r
        *\r
index f980098f71f85d260b4be0f7f3d24459a44f045f..6180d2f81e4ffada9be8706affd99cb26fac9ee5 100644 (file)
@@ -318,11 +318,9 @@ declare abstract class Block {
        * property.
        *
        * @param {number} index - Account index between 0x0 and 0x7fffffff
-       * @param {object} [block] - JSON of previous block for offline signing
+       * @param {object} [frontier] - JSON of frontier block for offline signing
        */
-       sign (index?: number, block?: {
-               [key: string]: string
-       }): Promise<void>
+       sign (index?: number, frontier?: ChangeBlock | ReceiveBlock | SendBlock): Promise<void>
        /**
        * Verifies the signature of the block. If a key is not provided, the public
        * key of the block's account will be used if it exists.
@@ -666,6 +664,16 @@ export declare abstract class Wallet {
        */
        refresh (rpc: Rpc | string | URL, from?: number, to?: number): Promise<AccountList>
        /**
+       * Signs a block using the private key of the account at the wallet index
+       * specified. The signature is appended to the signature field of the block
+       * before being returned. The wallet must be unlocked prior to signing.
+       *
+       * @param {(ChangeBlock|ReceiveBlock|SendBlock)} block - Block data to be hashed and signed
+       * @param {number} index - Account to use for signing
+       * @returns {Promise<string>} Hexadecimal-formatted 64-byte signature
+       */
+       sign (index: number, block: ChangeBlock | ReceiveBlock | SendBlock): Promise<string>
+       /**
        * Unlocks the wallet using the same password as used prior to lock it.
        *
        * @param {Key} password Used previously to lock the wallet
@@ -887,17 +895,26 @@ export declare class Blake2bWallet extends Wallet {
 }
 
 type DeviceStatus = 'DISCONNECTED' | 'BUSY' | 'LOCKED' | 'CONNECTED'
+
 interface LedgerResponse {
        status: string
 }
+
 interface LedgerVersionResponse extends LedgerResponse {
-       name: string | null
+       name: string | null,
        version: string | null
 }
+
+interface LedgerAccountResponse extends LedgerResponse {
+       publicKey: string | null,
+       address: string | null
+}
+
 interface LedgerSignResponse extends LedgerResponse {
-       signature: string | null
+       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
@@ -971,17 +988,9 @@ export declare class LedgerWallet extends Wallet {
        *
        * @param {number} index - Account number
        * @param {object} block - Block data to sign
-       * @returns {Promise} Status, signature, and block hash
-       */
-       sign (index: number, block: SendBlock | ReceiveBlock | ChangeBlock): Promise<LedgerSignResponse>
-       /**
-       * Sign a nonce with the Ledger device.
-       *
-       * @param {number} index - Account number
-       * @param {Uint8Array} nonce - 128-bit value to sign
-       * @returns {Promise} Status and signature
+       * @returns {Promise<string>} Signature
        */
-       sign (index: number, nonce: Uint8Array<ArrayBuffer>): Promise<LedgerSignResponse>
+       sign (index: number, block: SendBlock | ReceiveBlock | ChangeBlock, frontier?: SendBlock | ReceiveBlock | ChangeBlock): Promise<string>
        /**
        * Attempts to connect to the Ledger device.
        *
@@ -997,9 +1006,7 @@ export declare class LedgerWallet extends Wallet {
        * @param {number} index - Account number
        * @param {object} block - JSON-formatted block data
        */
-       updateCache (index: number, block: {
-               [key: string]: string
-       }): Promise<LedgerResponse>
+       updateCache (index: number, block: ChangeBlock | ReceiveBlock | SendBlock): Promise<LedgerResponse>
        /**
        * Update cache from a block hash by calling out to a node. Suitable for online
        * use only.
index bc1804aa2612a6a5bd9e76356133c4a4da419d37..6869879ac4ef19c86250f78385f5be428e7aebc1 100644 (file)
@@ -6,6 +6,10 @@
 import { assert, isNode, suite, test } from './GLOBALS.mjs'\r
 import { NANO_TEST_VECTORS } from './VECTORS.mjs'\r
 \r
+/**\r
+* @type {typeof import('../dist/types.d.ts').Bip44Wallet}\r
+*/\r
+let Bip44Wallet\r
 /**\r
 * @type {typeof import('../dist/types.d.ts').ChangeBlock}\r
 */\r
@@ -19,9 +23,9 @@ let ReceiveBlock
 */\r
 let SendBlock\r
 if (isNode) {\r
-       ({ ChangeBlock, ReceiveBlock, SendBlock } = await import('../dist/nodejs.min.js'))\r
+       ({ Bip44Wallet, ChangeBlock, ReceiveBlock, SendBlock } = await import('../dist/nodejs.min.js'))\r
 } else {\r
-       ({ ChangeBlock, ReceiveBlock, SendBlock } = await import('../dist/browser.min.js'))\r
+       ({ Bip44Wallet, ChangeBlock, ReceiveBlock, SendBlock } = await import('../dist/browser.min.js'))\r
 }\r
 \r
 await Promise.all([\r
@@ -80,7 +84,39 @@ await Promise.all([
 \r
        suite('Block signing using official test vectors', async () => {\r
 \r
-               await test('sign open block', async () => {\r
+               await test('sign open block with wallet', async () => {\r
+                       const wallet = await Bip44Wallet.fromSeed(NANO_TEST_VECTORS.PASSWORD, NANO_TEST_VECTORS.BIP39_SEED)\r
+                       await assert.resolves(wallet.unlock(NANO_TEST_VECTORS.PASSWORD))\r
+\r
+                       const block = new ReceiveBlock(\r
+                               NANO_TEST_VECTORS.ADDRESS_0,\r
+                               '0',\r
+                               NANO_TEST_VECTORS.OPEN_BLOCK.link,\r
+                               NANO_TEST_VECTORS.OPEN_BLOCK.balance,\r
+                               NANO_TEST_VECTORS.OPEN_BLOCK.representative,\r
+                               NANO_TEST_VECTORS.OPEN_BLOCK.previous,\r
+                               NANO_TEST_VECTORS.OPEN_BLOCK.work\r
+                       )\r
+                       const signature = await wallet.sign(0, block)\r
+                       assert.equal(block.signature, signature)\r
+               })\r
+\r
+               await test('sign open block with account', async () => {\r
+                       const block = new ReceiveBlock(\r
+                               NANO_TEST_VECTORS.OPEN_BLOCK.account,\r
+                               '0',\r
+                               NANO_TEST_VECTORS.OPEN_BLOCK.link,\r
+                               NANO_TEST_VECTORS.OPEN_BLOCK.balance,\r
+                               NANO_TEST_VECTORS.OPEN_BLOCK.representative,\r
+                               NANO_TEST_VECTORS.OPEN_BLOCK.previous,\r
+                               NANO_TEST_VECTORS.OPEN_BLOCK.work\r
+                       )\r
+                       await block.sign(NANO_TEST_VECTORS.OPEN_BLOCK.key)\r
+                       assert.equal(block.hash, NANO_TEST_VECTORS.OPEN_BLOCK.hash)\r
+                       assert.equal(block.signature, NANO_TEST_VECTORS.OPEN_BLOCK.signature)\r
+               })\r
+\r
+               await test('sign open block with private key', async () => {\r
                        const block = new ReceiveBlock(\r
                                NANO_TEST_VECTORS.OPEN_BLOCK.account,\r
                                '0',\r
index 8dd25713722d3fa795a5d7629112b2a7f384aef6..384c3d44bed30bc02d587c9ecf578bc12cc190c1 100644 (file)
@@ -41,7 +41,7 @@ const rpc = new Rpc(env.NODE_URL ?? '', env.API_KEY_NAME)
 */
 await Promise.all([
        /* node:coverage disable */
-       suite('Ledger hardware wallet', { skip: true || isNode }, async () => {
+       suite('Ledger hardware wallet', { skip: false || isNode }, async () => {
 
                let wallet, account, openBlock, sendBlock, receiveBlock
 
@@ -130,10 +130,8 @@ await Promise.all([
                        assert.nullish(openBlock.signature)
                        assert.equal(openBlock.account.publicKey, account.publicKey)
 
-                       const { status, hash, signature } = await wallet.sign(0, openBlock)
+                       const signature = await wallet.sign(0, openBlock)
 
-                       assert.equal(status, 'OK')
-                       assert.ok(/^[A-Fa-f0-9]{64}$/.test(hash))
                        assert.ok(/^[A-Fa-f0-9]{128}$/.test(signature))
 
                        await openBlock.sign(0)
@@ -163,12 +161,10 @@ await Promise.all([
                        assert.nullish(sendBlock.signature)
                        assert.equal(sendBlock.account.publicKey, account.publicKey)
 
-                       const { status, hash, signature } = await wallet.sign(0, sendBlock)
+                       const signature = await wallet.sign(0, sendBlock)
 
-                       assert.equal(status, 'OK')
-                       assert.ok(/^[A-Fa-f0-9]{64}$/.test(hash))
                        assert.ok(/^[A-Fa-f0-9]{128}$/.test(signature))
-                       sendBlock.signature = signature
+                       assert.equal(signature, sendBlock.signature)
                })
 
                await test('sign a receive block from block object which can accept previous block for cache', async () => {
@@ -191,13 +187,25 @@ await Promise.all([
                        assert.ok(/^[A-Fa-f0-9]{128}$/.test(receiveBlock.signature ?? ''))
                })
 
-               // 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('sign a receive block from wallet including frontier block for cache', async () => {
+                       sendBlock = new ReceiveBlock(
+                               account,
+                               receiveBlock.balance,
+                               receiveBlock.hash,
+                               '0',
+                               NANO_TEST_VECTORS.RECEIVE_BLOCK.representative,
+                               receiveBlock.hash
+                       )
+
+                       assert.ok(/^[A-Fa-f0-9]{64}$/.test(receiveBlock.hash))
+                       assert.nullish(sendBlock.signature)
+                       assert.equal(sendBlock.account.publicKey, account.publicKey)
+
+                       const signature = await wallet.sign(0, sendBlock, receiveBlock)
 
-                       assert.equal(status, 'OK')
-                       assert.OK(/^[A-Fa-f0-9]{128}$/.test(signature))
+                       assert.exists(sendBlock.signature)
+                       assert.ok(/^[A-Fa-f0-9]{128}$/.test(sendBlock.signature ?? ''))
+                       assert.equal(signature, sendBlock.signature)
                })
 
                await test('destroy wallet', async () => {