From a18031f5b8bb3a38c12236d98b8313114de057a0 Mon Sep 17 00:00:00 2001 From: Florian Glatz Date: Tue, 7 Jul 2026 20:22:15 +0200 Subject: [PATCH 01/17] refactor(wallet): introduce signer abstraction ahead of hardware-wallet support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace raw-private-key threading with a Signer interface resolved by getSigner(walletIndex): {getAddress, signTransaction, signMessage, signTypedData}. All signing call sites (wallet IPC, tx recorder, x402 client) consume the interface; only the vault backend inside signers.js ever touches key material. signAndSendTransaction now signs then broadcasts as separate steps (signer.signTransaction → provider.broadcastTransaction), which a hardware signer requires — the provider only ever sees the serialized signed tx. dApp wire-shape normalization (0x-hex personal messages, JSON-string typed data) happens once in the factory so future backends can't drift. x402's createVaultBackedX402Client becomes createX402Client: it is backend-agnostic by construction now. No behavior change intended. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VtT8ToNv26pYdwB1BvXVyu --- src/main/wallet/signers.js | 114 ++++++++++++++++ src/main/wallet/signers.test.js | 123 +++++++++++++++++ src/main/wallet/transaction-service.js | 87 +++--------- src/main/wallet/transaction-service.test.js | 141 ++++++++++++-------- src/main/wallet/tx-recorder.js | 6 +- src/main/wallet/tx-recorder.test.js | 6 +- src/main/wallet/wallet-ipc.js | 22 +-- src/main/wallet/wallet-ipc.test.js | 2 +- src/main/x402/client.js | 72 ++++------ src/main/x402/client.test.js | 87 ++++-------- src/main/x402/ipc.test.js | 2 +- src/main/x402/sign-flow.js | 4 +- src/main/x402/sign-flow.test.js | 2 +- 13 files changed, 413 insertions(+), 255 deletions(-) create mode 100644 src/main/wallet/signers.js create mode 100644 src/main/wallet/signers.test.js diff --git a/src/main/wallet/signers.js b/src/main/wallet/signers.js new file mode 100644 index 00000000..a219e6fb --- /dev/null +++ b/src/main/wallet/signers.js @@ -0,0 +1,114 @@ +/** + * Signer factory. + * + * Resolves a wallet index to a signer object so callers never touch raw + * private keys. Today every account is vault-backed (mnemonic-derived); + * hardware-wallet account types plug in here by returning a different + * backend behind the same interface. + * + * Input normalization (0x-hex personal messages → raw bytes, JSON-string + * typed data → object) happens once in the factory, so backends always + * receive the same shapes. The EIP-712 payload keeps its full dApp wire + * shape (EIP712Domain in types) because backends genuinely diverge there: + * ethers wants the domain stripped, a Ledger app consumes the full payload. + * + * Error contract: vault-locked errors keep their identity (check with + * `isVaultLockedError`); hardware backends must surface user-rejection + * distinctly so approval UIs can tell "declined on device" from failure. + */ + +const { Wallet, computeAddress } = require('ethers'); + +const { withVaultPrivateKey, isValidWalletIndex } = require('./vault-access'); + +/** + * @typedef {Object} Signer + * @property {() => Promise} getAddress + * Checksummed address of the account (cached after first resolution). + * @property {(tx: object) => Promise} signTransaction + * Complete unsigned tx (nonce, gas, fees, chainId — no population) → + * serialized signed tx. + * @property {(message: string|Uint8Array) => Promise} signMessage + * EIP-191 signature over raw bytes (0x-hex is pre-decoded by the factory). + * @property {(typedData: object) => Promise} signTypedData + * EIP-712 signature; receives the parsed full payload + * ({domain, types, message}, EIP712Domain in types allowed). + */ + +/** 0x-hex dApp messages are signatures over the bytes, not the hex text. */ +function normalizeMessage(message) { + if (typeof message === 'string' && message.startsWith('0x')) { + return Buffer.from(message.slice(2), 'hex'); + } + return message; +} + +/** dApps send typed data either as an object or a JSON string. */ +function normalizeTypedData(typedData) { + return typeof typedData === 'string' ? JSON.parse(typedData) : typedData; +} + +function createVaultBackend(walletIndex) { + return { + getAddress: () => withVaultPrivateKey(walletIndex, (privateKey) => computeAddress(privateKey)), + signTransaction: (tx) => + withVaultPrivateKey(walletIndex, (privateKey) => new Wallet(privateKey).signTransaction(tx)), + signMessage: (message) => + withVaultPrivateKey(walletIndex, async (privateKey) => { + try { + // signMessage applies the EIP-191 prefix + return await new Wallet(privateKey).signMessage(message); + } catch (err) { + throw new Error(`Message signing failed: ${err.message}`, { cause: err }); + } + }), + signTypedData: (typedData) => + withVaultPrivateKey(walletIndex, async (privateKey) => { + try { + const { domain, types, message } = typedData; + // ethers computes the domain separator itself + const typesWithoutDomain = { ...types }; + delete typesWithoutDomain.EIP712Domain; + return await new Wallet(privateKey).signTypedData(domain, typesWithoutDomain, message); + } catch (err) { + throw new Error(`Typed data signing failed: ${err.message}`, { cause: err }); + } + }), + }; +} + +/** + * Build a signer for the given wallet index. + * + * Construction is cheap and does not touch the vault; each method borrows + * the key per call, so a locked vault fails at signing time with + * `VAULT_LOCKED_MESSAGE` (same behaviour callers relied on before). The + * address is memoized after the first successful resolution — it is public + * and immutable for a given index, and callers like the send flow need it + * (for the nonce) right before signing. + * + * @param {number} walletIndex + * @returns {Signer} + */ +function getSigner(walletIndex) { + if (!isValidWalletIndex(walletIndex)) { + throw new Error('Invalid wallet index'); + } + + const backend = createVaultBackend(walletIndex); + + let address = null; + return { + getAddress: async () => { + if (address === null) { + address = await backend.getAddress(); + } + return address; + }, + signTransaction: (tx) => backend.signTransaction(tx), + signMessage: (message) => backend.signMessage(normalizeMessage(message)), + signTypedData: (typedData) => backend.signTypedData(normalizeTypedData(typedData)), + }; +} + +module.exports = { getSigner }; diff --git a/src/main/wallet/signers.test.js b/src/main/wallet/signers.test.js new file mode 100644 index 00000000..6294ac47 --- /dev/null +++ b/src/main/wallet/signers.test.js @@ -0,0 +1,123 @@ +const { Wallet, Transaction, verifyMessage, verifyTypedData, getBytes } = require('ethers'); + +// Anvil/Hardhat-default test key — well-known, never funded on mainnet. +const TEST_PRIVATE_KEY = '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d'; +const testWallet = new Wallet(TEST_PRIVATE_KEY); + +const mockIdentity = { + isUnlocked: jest.fn(), + exportPrivateKey: jest.fn(), +}; +const mockResetVaultAutoLockTimer = jest.fn(); + +jest.mock('../identity-manager', () => ({ + loadIdentityModule: jest.fn(async () => mockIdentity), +})); +jest.mock('../vault-timer', () => ({ + resetVaultAutoLockTimer: mockResetVaultAutoLockTimer, +})); + +const { getSigner } = require('./signers'); + +beforeEach(() => { + mockIdentity.isUnlocked.mockReset().mockReturnValue(true); + mockIdentity.exportPrivateKey.mockReset().mockReturnValue(TEST_PRIVATE_KEY); + mockResetVaultAutoLockTimer.mockClear(); +}); + +describe('getSigner (vault-backed)', () => { + test('rejects an invalid wallet index up front', () => { + expect(() => getSigner(-1)).toThrow('Invalid wallet index'); + expect(() => getSigner('0')).toThrow('Invalid wallet index'); + }); + + test('getAddress resolves the address for the wallet index', async () => { + const signer = getSigner(0); + await expect(signer.getAddress()).resolves.toBe(testWallet.address); + expect(mockIdentity.exportPrivateKey).toHaveBeenCalledWith(0); + }); + + test('signMessage matches ethers Wallet.signMessage for plain text', async () => { + const signer = getSigner(0); + const signature = await signer.signMessage('hello freedom'); + expect(signature).toBe(await testWallet.signMessage('hello freedom')); + expect(verifyMessage('hello freedom', signature)).toBe(testWallet.address); + }); + + test.each([ + ['hex-encoded text', '0x48656c6c6f'], + ['binary data that is not valid UTF-8', '0xfffefd00010203deadbeef'], + ['a 32-byte hash', '0x' + 'ab'.repeat(32)], + ])('signMessage treats 0x-hex input as raw bytes: %s', async (_label, hexMessage) => { + const signer = getSigner(0); + const signature = await signer.signMessage(hexMessage); + expect(signature).toBe(await testWallet.signMessage(getBytes(hexMessage))); + expect(verifyMessage(getBytes(hexMessage), signature)).toBe(testWallet.address); + }); + + test('signTypedData accepts a full EIP-712 payload including EIP712Domain in types', async () => { + const signer = getSigner(0); + const domain = { name: 'Test', version: '1', chainId: 1 }; + const types = { + EIP712Domain: [ + { name: 'name', type: 'string' }, + { name: 'version', type: 'string' }, + { name: 'chainId', type: 'uint256' }, + ], + Mail: [ + { name: 'contents', type: 'string' }, + ], + }; + const message = { contents: 'gm' }; + + const signature = await signer.signTypedData({ domain, types, primaryType: 'Mail', message }); + expect(verifyTypedData(domain, { Mail: types.Mail }, message, signature)).toBe(testWallet.address); + + // dApps also send the payload as a JSON string (eth_signTypedData_v4) + const fromJson = await signer.signTypedData(JSON.stringify({ domain, types, primaryType: 'Mail', message })); + expect(fromJson).toBe(signature); + }); + + test('getAddress memoizes: repeated calls borrow the vault key once', async () => { + const signer = getSigner(0); + await signer.getAddress(); + await signer.getAddress(); + expect(mockIdentity.exportPrivateKey).toHaveBeenCalledTimes(1); + }); + + test('signTransaction returns a serialized signed tx recoverable to the wallet', async () => { + const signer = getSigner(0); + const signedTx = await signer.signTransaction({ + to: '0x209693Bc6afc0C5328bA36FaF03C514EF312287C', + value: '1000', + gasLimit: '21000', + maxFeePerGas: '2000000000', + maxPriorityFeePerGas: '1000000000', + nonce: 7, + chainId: 8453, + type: 2, + }); + + const parsed = Transaction.from(signedTx); + expect(parsed.from).toBe(testWallet.address); + expect(parsed.nonce).toBe(7); + expect(parsed.chainId).toBe(8453n); + expect(parsed.to).toBe('0x209693Bc6afc0C5328bA36FaF03C514EF312287C'); + }); + + test('every method rejects when the vault is locked', async () => { + mockIdentity.isUnlocked.mockReturnValue(false); + const signer = getSigner(0); + await expect(signer.getAddress()).rejects.toThrow(/locked/i); + await expect(signer.signMessage('x')).rejects.toThrow(/locked/i); + await expect(signer.signTypedData({ domain: {}, types: {}, message: {} })).rejects.toThrow(/locked/i); + await expect(signer.signTransaction({ chainId: 1 })).rejects.toThrow(/locked/i); + expect(mockResetVaultAutoLockTimer).not.toHaveBeenCalled(); + }); + + test('successful signing resets the vault auto-lock timer', async () => { + const signer = getSigner(0); + await signer.signMessage('keep the vault alive'); + expect(mockResetVaultAutoLockTimer).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/main/wallet/transaction-service.js b/src/main/wallet/transaction-service.js index 79fc770b..4ad64c40 100644 --- a/src/main/wallet/transaction-service.js +++ b/src/main/wallet/transaction-service.js @@ -1,11 +1,12 @@ /** * Transaction Service * - * Handles gas estimation, transaction building, signing, and broadcasting. - * Uses the vault's derived keys for signing. + * Handles gas estimation, transaction building, and broadcasting. + * Signing is delegated to a Signer (see ./signers.js), so this module + * never touches key material. */ -const { parseUnits, formatUnits, Interface, Wallet } = require('ethers'); +const { parseUnits, formatUnits, Interface } = require('ethers'); const { getProvider, withRetry } = require('./provider-manager'); const { getTxExplorerUrl } = require('./chains'); @@ -178,7 +179,12 @@ function buildTransaction({ } /** - * Sign and broadcast a transaction + * Sign and broadcast a transaction. + * + * Signing and broadcasting are separate steps so the signer can be + * anything implementing the signer interface (vault key, hardware + * device) — the provider only ever sees the serialized signed tx. + * * @param {Object} params - Transaction parameters * @param {string} params.to - Recipient (or token contract for ERC-20) * @param {string} params.value - Value in wei @@ -188,10 +194,10 @@ function buildTransaction({ * @param {string} [params.maxPriorityFeePerGas] - Max priority fee (EIP-1559) * @param {string} [params.gasPrice] - Gas price (legacy) * @param {number} params.chainId - Chain ID - * @param {string} privateKey - Private key for signing (0x-prefixed) + * @param {import('./signers').Signer} signer - Signer for the sending account * @returns {Promise} Transaction result */ -async function signAndSendTransaction(params, privateKey) { +async function signAndSendTransaction(params, signer) { const { to, value, data, gasLimit, maxFeePerGas, maxPriorityFeePerGas, gasPrice, chainId } = params; const provider = getProvider(chainId); @@ -200,11 +206,10 @@ async function signAndSendTransaction(params, privateKey) { } try { - // Create wallet from private key - const wallet = new Wallet(privateKey, provider); + const from = await signer.getAddress(); // Get nonce - const nonce = await withRetry(() => provider.getTransactionCount(wallet.address, 'pending'), 2, chainId); + const nonce = await withRetry(() => provider.getTransactionCount(from, 'pending'), 2, chainId); // Build transaction const tx = buildTransaction({ @@ -227,8 +232,8 @@ async function signAndSendTransaction(params, privateKey) { nonce: tx.nonce, }); - // Sign and send - const txResponse = await wallet.sendTransaction(tx); + const signedTx = await signer.signTransaction(tx); + const txResponse = await provider.broadcastTransaction(signedTx); console.log('[TransactionService] Transaction sent:', txResponse.hash); @@ -339,64 +344,6 @@ async function waitForTransaction(txHash, chainId, confirmations = 1) { } } -/** - * Sign a personal message (EIP-191) - * @param {string} message - Message to sign (hex string or UTF-8) - * @param {string} privateKey - Private key for signing - * @returns {Promise} Signature (hex string) - */ -async function signPersonalMessage(message, privateKey) { - try { - const wallet = new Wallet(privateKey); - - // If message is hex-encoded, convert to raw bytes (not UTF-8 string) - let messageToSign = message; - if (message.startsWith('0x')) { - messageToSign = Buffer.from(message.slice(2), 'hex'); - } - - // signMessage automatically applies EIP-191 prefix - const signature = await wallet.signMessage(messageToSign); - - console.log('[TransactionService] Message signed'); - return signature; - } catch (err) { - console.error('[TransactionService] Message signing failed:', err); - throw new Error(`Message signing failed: ${err.message}`, { cause: err }); - } -} - -/** - * Sign typed data (EIP-712) - * @param {Object} typedData - EIP-712 typed data object - * @param {string} privateKey - Private key for signing - * @returns {Promise} Signature (hex string) - */ -async function signTypedData(typedData, privateKey) { - try { - const wallet = new Wallet(privateKey); - - // Parse if string - const data = typeof typedData === 'string' ? JSON.parse(typedData) : typedData; - - // Extract domain, types, and message from EIP-712 structure - const { domain, types, message } = data; - - // Remove EIP712Domain from types (ethers handles it internally) - const typesWithoutDomain = { ...types }; - delete typesWithoutDomain.EIP712Domain; - - // Sign using ethers' signTypedData - const signature = await wallet.signTypedData(domain, typesWithoutDomain, message); - - console.log('[TransactionService] Typed data signed'); - return signature; - } catch (err) { - console.error('[TransactionService] Typed data signing failed:', err); - throw new Error(`Typed data signing failed: ${err.message}`, { cause: err }); - } -} - module.exports = { estimateGas, getGasPrices, @@ -407,6 +354,4 @@ module.exports = { signAndSendTransaction, getTransactionStatus, waitForTransaction, - signPersonalMessage, - signTypedData, }; diff --git a/src/main/wallet/transaction-service.test.js b/src/main/wallet/transaction-service.test.js index 585f9a9d..6ae48d95 100644 --- a/src/main/wallet/transaction-service.test.js +++ b/src/main/wallet/transaction-service.test.js @@ -1,71 +1,106 @@ -const { Wallet, verifyMessage, getBytes } = require('ethers'); -const { signPersonalMessage } = require('./transaction-service'); +const mockGetProvider = jest.fn(); +jest.mock('./provider-manager', () => ({ + getProvider: (...args) => mockGetProvider(...args), + withRetry: (fn) => fn(), +})); +jest.mock('./chains', () => ({ + getTxExplorerUrl: (chainId, hash) => `https://explorer.test/${chainId}/${hash}`, +})); + +const { Wallet, Transaction } = require('ethers'); +const { signAndSendTransaction } = require('./transaction-service'); // Deterministic test key (not a real wallet) const TEST_PRIVATE_KEY = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'; const testWallet = new Wallet(TEST_PRIVATE_KEY); -describe('signPersonalMessage', () => { - it('signs a plain text message', async () => { - const message = 'Hello, this is a test message'; - const signature = await signPersonalMessage(message, TEST_PRIVATE_KEY); - - expect(signature).toMatch(/^0x[0-9a-f]{130}$/); - // Verify the signature recovers to the correct address - const recovered = verifyMessage(message, signature); - expect(recovered.toLowerCase()).toBe(testWallet.address.toLowerCase()); - }); - - it('signs a hex-encoded text message (0x prefix)', async () => { - // "Hello" in hex - const hexMessage = '0x48656c6c6f'; - const signature = await signPersonalMessage(hexMessage, TEST_PRIVATE_KEY); +describe('signAndSendTransaction (signer-based)', () => { + // A signer that signs locally with the test key — mirrors what the + // vault signer produces, without the vault plumbing. Ledger signers + // implement the same interface, so this proves the sign/broadcast + // split works for any signer that can't hand out a raw key. + const signer = { + getAddress: async () => testWallet.address, + signTransaction: (tx) => testWallet.signTransaction(tx), + }; - expect(signature).toMatch(/^0x[0-9a-f]{130}$/); - // Verify: ethers.verifyMessage with raw bytes should recover the same address - const rawBytes = getBytes(hexMessage); - const recovered = verifyMessage(rawBytes, signature); - expect(recovered.toLowerCase()).toBe(testWallet.address.toLowerCase()); + let broadcastedRaw; + beforeEach(() => { + broadcastedRaw = null; + mockGetProvider.mockReset().mockReturnValue({ + getTransactionCount: async () => 5, + broadcastTransaction: async (raw) => { + broadcastedRaw = raw; + const parsed = Transaction.from(raw); + return { + hash: parsed.hash, + nonce: parsed.nonce, + from: parsed.from, + to: parsed.to, + value: parsed.value, + }; + }, + }); }); - it('signs hex-encoded binary data containing non-UTF-8 bytes', async () => { - // Arbitrary binary data that is NOT valid UTF-8 - // 0xff 0xfe are invalid UTF-8 lead bytes - const hexMessage = '0xfffefd00010203deadbeef'; - const signature = await signPersonalMessage(hexMessage, TEST_PRIVATE_KEY); - - expect(signature).toMatch(/^0x[0-9a-f]{130}$/); - // Verify the signature matches signing the raw bytes directly - const rawBytes = getBytes(hexMessage); - const recovered = verifyMessage(rawBytes, signature); - expect(recovered.toLowerCase()).toBe(testWallet.address.toLowerCase()); - }); + it('fetches the nonce for the signer address, signs, and broadcasts', async () => { + const result = await signAndSendTransaction( + { + to: '0x209693Bc6afc0C5328bA36FaF03C514EF312287C', + value: '1000', + gasLimit: '21000', + maxFeePerGas: '2000000000', + maxPriorityFeePerGas: '1000000000', + chainId: 8453, + }, + signer, + ); - it('signs a hex-encoded hash (32 bytes)', async () => { - // A keccak256 hash — common in dApp signing flows - const hashMessage = '0x' + 'ab'.repeat(32); - const signature = await signPersonalMessage(hashMessage, TEST_PRIVATE_KEY); + const parsed = Transaction.from(broadcastedRaw); + expect(parsed.from).toBe(testWallet.address); + expect(parsed.nonce).toBe(5); + expect(parsed.chainId).toBe(8453n); - expect(signature).toMatch(/^0x[0-9a-f]{130}$/); - const rawBytes = getBytes(hashMessage); - const recovered = verifyMessage(rawBytes, signature); - expect(recovered.toLowerCase()).toBe(testWallet.address.toLowerCase()); + expect(result).toMatchObject({ + hash: parsed.hash, + nonce: 5, + from: testWallet.address, + to: '0x209693Bc6afc0C5328bA36FaF03C514EF312287C', + value: '1000', + chainId: 8453, + explorerUrl: `https://explorer.test/8453/${parsed.hash}`, + }); }); - it('produces matching signatures for hex and equivalent raw bytes', async () => { - // Sign "Hello" as plain text hex - const hexSig = await signPersonalMessage('0x48656c6c6f', TEST_PRIVATE_KEY); - // Sign "Hello" by passing the same bytes through ethers directly - const directSig = await testWallet.signMessage(getBytes('0x48656c6c6f')); + it('maps insufficient-funds broadcast errors to a friendly message', async () => { + mockGetProvider.mockReturnValue({ + getTransactionCount: async () => 0, + broadcastTransaction: async () => { + throw new Error('insufficient funds for gas * price + value'); + }, + }); - expect(hexSig).toBe(directSig); + await expect( + signAndSendTransaction( + { to: '0x209693Bc6afc0C5328bA36FaF03C514EF312287C', value: '1', gasLimit: '21000', gasPrice: '7', chainId: 1 }, + signer, + ), + ).rejects.toThrow('Insufficient funds for transaction'); }); - it('treats non-0x messages as plain strings', async () => { - const message = 'no hex prefix here'; - const signature = await signPersonalMessage(message, TEST_PRIVATE_KEY); + it('surfaces signer rejection (e.g. user declined on device) unchanged', async () => { + const decliningSigner = { + ...signer, + signTransaction: async () => { + throw new Error('User rejected on device'); + }, + }; - const recovered = verifyMessage(message, signature); - expect(recovered.toLowerCase()).toBe(testWallet.address.toLowerCase()); + await expect( + signAndSendTransaction( + { to: '0x209693Bc6afc0C5328bA36FaF03C514EF312287C', value: '1', gasLimit: '21000', gasPrice: '7', chainId: 1 }, + decliningSigner, + ), + ).rejects.toThrow(/User rejected on device/); }); }); diff --git a/src/main/wallet/tx-recorder.js b/src/main/wallet/tx-recorder.js index 1e0ea87d..9c1f9c18 100644 --- a/src/main/wallet/tx-recorder.js +++ b/src/main/wallet/tx-recorder.js @@ -20,7 +20,7 @@ function toAtomicDecimal(value) { /** * @param {object} params Same shape as signAndSendTransaction. - * @param {string} privateKey + * @param {import('./signers').Signer} signer * @param {object} context * @param {string} context.kind paymentHistory.KINDS member * @param {string} [context.origin] normalised origin (dapp sends only) @@ -36,8 +36,8 @@ function toAtomicDecimal(value) { * you have the real recipient) * @param {object} [context.metadata] free-form per-kind extras */ -async function signAndRecord(params, privateKey, context) { - const response = await signAndSendTransaction(params, privateKey); +async function signAndRecord(params, signer, context) { + const response = await signAndSendTransaction(params, signer); let row; try { diff --git a/src/main/wallet/tx-recorder.test.js b/src/main/wallet/tx-recorder.test.js index bda382db..5277be65 100644 --- a/src/main/wallet/tx-recorder.test.js +++ b/src/main/wallet/tx-recorder.test.js @@ -23,6 +23,8 @@ jest.mock('../payment-history', () => ({ const { signAndRecord } = require('./tx-recorder'); +const fakeSigner = { getAddress: async () => '0xfrom' }; + describe('tx-recorder', () => { beforeEach(() => { mockSignAndSendTransaction.mockReset().mockResolvedValue({ @@ -44,7 +46,7 @@ describe('tx-recorder', () => { to: '0xtoken', value: '0', chainId: 8453, - }, '0xprivate', { + }, fakeSigner, { kind: 'dapp-send', origin: 'https://app.example', asset: '0xtoken', @@ -74,7 +76,7 @@ describe('tx-recorder', () => { to: '0xrecipient', value: '0x2a', chainId: 1, - }, '0xprivate', { + }, fakeSigner, { kind: 'wallet-send', }); diff --git a/src/main/wallet/wallet-ipc.js b/src/main/wallet/wallet-ipc.js index 9552f77f..87980bf0 100644 --- a/src/main/wallet/wallet-ipc.js +++ b/src/main/wallet/wallet-ipc.js @@ -16,13 +16,11 @@ const { parseAmount, getTransactionStatus, waitForTransaction, - signPersonalMessage, - signTypedData, } = require('./transaction-service'); const { signAndRecord, KINDS: PAYMENT_KINDS } = require('./tx-recorder'); const { getActiveWalletIndex } = require('../identity-manager'); const { getEffectiveRpcUrls } = require('./rpc-manager'); -const { withVaultPrivateKey } = require('./vault-access'); +const { getSigner } = require('./signers'); /** * Validate that an RPC URL is a known, trusted endpoint. @@ -63,12 +61,10 @@ async function handleSendTransaction(walletIndex, params, kind, context = {}) { if (!to || chainId === undefined || !gasLimit) { return { success: false, error: 'Missing required parameters: to, chainId, gasLimit' }; } - const result = await withVaultPrivateKey(walletIndex, (privateKey) => - signAndRecord( - { to, value, data, gasLimit, maxFeePerGas, maxPriorityFeePerGas, gasPrice, chainId }, - privateKey, - buildTxRecordContext(kind, context), - ) + const result = await signAndRecord( + { to, value, data, gasLimit, maxFeePerGas, maxPriorityFeePerGas, gasPrice, chainId }, + getSigner(walletIndex), + buildTxRecordContext(kind, context), ); return { success: true, ...result }; } catch (err) { @@ -282,9 +278,7 @@ function registerWalletIpc() { return { success: false, error: 'Message is required' }; } - const signature = await withVaultPrivateKey(walletIndex, (privateKey) => - signPersonalMessage(message, privateKey) - ); + const signature = await getSigner(walletIndex).signMessage(message); return { success: true, signature }; } catch (err) { @@ -300,9 +294,7 @@ function registerWalletIpc() { return { success: false, error: 'Typed data is required' }; } - const signature = await withVaultPrivateKey(walletIndex, (privateKey) => - signTypedData(typedData, privateKey) - ); + const signature = await getSigner(walletIndex).signTypedData(typedData); return { success: true, signature }; } catch (err) { diff --git a/src/main/wallet/wallet-ipc.test.js b/src/main/wallet/wallet-ipc.test.js index 40572add..405e421c 100644 --- a/src/main/wallet/wallet-ipc.test.js +++ b/src/main/wallet/wallet-ipc.test.js @@ -13,7 +13,7 @@ jest.mock('./tx-recorder', () => ({ })); jest.mock('../identity-manager', () => ({})); jest.mock('./rpc-manager', () => ({})); -jest.mock('./vault-access', () => ({})); +jest.mock('./signers', () => ({})); const { buildTxRecordContext } = require('./wallet-ipc'); diff --git a/src/main/x402/client.js b/src/main/x402/client.js index a66cf4a3..e49a5408 100644 --- a/src/main/x402/client.js +++ b/src/main/x402/client.js @@ -1,24 +1,23 @@ /** - * x402 vault-backed payment client. + * x402 payment client. * - * Wires `@x402/core`'s `x402Client` to the freedom-browser vault so payment - * authorizations get signed inside the existing wallet — no raw keys leave - * the main process, and the same auto-lock UX that protects dApp signing - * protects x402 payments. + * Wires `@x402/core`'s `x402Client` to the wallet's signer factory so + * payment authorizations are signed by whatever backend the wallet index + * resolves to (vault key today, hardware wallet later) — no raw keys + * leave the main process, and the same auto-lock UX that protects dApp + * signing protects x402 payments. * - * Higher layers (the navigation interceptor / interstitial in WP3) call - * `createVaultBackedX402Client(walletIndex)` after the user has approved a - * payment, then drive the returned client to produce the `PAYMENT-SIGNATURE` + * Higher layers (the navigation interceptor / interstitial) call + * `createX402Client(walletIndex)` after the user has approved a payment, + * then drive the returned client to produce the `PAYMENT-SIGNATURE` * header value. */ const { x402Client } = require('@x402/core/client'); const { ExactEvmScheme } = require('@x402/evm/exact/client'); const { ExactEvmSchemeV1 } = require('@x402/evm/exact/v1/client'); -const { Wallet } = require('ethers'); -const { withVaultPrivateKey } = require('../wallet/vault-access'); -const { signTypedData: signTypedDataWithKey } = require('../wallet/transaction-service'); +const { getSigner } = require('../wallet/signers'); // V1 servers use string network names (not CAIP-2); unknown ones fall // through to whichever V2 `accepts[]` entry the server also exposed. @@ -30,46 +29,30 @@ const { signTypedData: signTypedDataWithKey } = require('../wallet/transaction-s const V1_NETWORKS = ['base', 'ethereum']; /** - * Build a vault-backed `ClientEvmSigner` for the given wallet index. - * - * The returned signer matches `@x402/evm`'s `ClientEvmSigner` shape — just - * `address` + `signTypedData`. No `readContract` etc., so EIP-2612 / ERC-20- - * approval extensions aren't supported on this signer; the base USDC / - * EIP-3009 flow doesn't need them. - * - * @param {number} walletIndex - * @returns {Promise<{ address: string, signTypedData: (msg: object) => Promise }>} - */ -async function buildVaultSigner(walletIndex) { - // Resolve the address once at construction. withVaultPrivateKey also - // resets the auto-lock timer, so the typical "build then immediately - // sign" flow doesn't race the timeout. Each subsequent signTypedData - // call re-runs the same unlock check in case the vault re-locked. - const address = await withVaultPrivateKey(walletIndex, (privateKey) => - new Wallet(privateKey).address - ); - - return { - address, - signTypedData: (msg) => - withVaultPrivateKey(walletIndex, (privateKey) => - signTypedDataWithKey(msg, privateKey) - ), - }; -} - -/** - * Construct an `x402Client` whose signing flows through the vault. + * Construct an `x402Client` whose signing flows through the wallet's + * signer for the given index. * * Both V2 (CAIP-2, registered with the `eip155:*` glob) and V1 (legacy * string network names) schemes are wired so the client can produce * payment payloads against either flavour of x402 server. * + * The schemes receive `@x402/evm`'s `ClientEvmSigner` shape — just + * `address` + `signTypedData`. No `readContract` etc., so EIP-2612 / + * ERC-20-approval extensions aren't supported; the base USDC / EIP-3009 + * flow doesn't need them. The address is resolved once at construction — + * this also resets the vault auto-lock timer, so the typical "build then + * immediately sign" flow doesn't race the timeout; each signTypedData + * call re-runs the unlock check in case the vault re-locked. + * * @param {number} [walletIndex=0] * @returns {Promise} */ -async function createVaultBackedX402Client(walletIndex = 0) { - const signer = await buildVaultSigner(walletIndex); +async function createX402Client(walletIndex = 0) { + const walletSigner = getSigner(walletIndex); + const signer = { + address: await walletSigner.getAddress(), + signTypedData: walletSigner.signTypedData, + }; const client = new x402Client(); client.register('eip155:*', new ExactEvmScheme(signer)); @@ -83,7 +66,6 @@ async function createVaultBackedX402Client(walletIndex = 0) { } module.exports = { - buildVaultSigner, - createVaultBackedX402Client, + createX402Client, V1_NETWORKS, }; diff --git a/src/main/x402/client.test.js b/src/main/x402/client.test.js index a2d3b015..2d54352b 100644 --- a/src/main/x402/client.test.js +++ b/src/main/x402/client.test.js @@ -18,8 +18,7 @@ jest.mock('../vault-timer', () => ({ })); const { - buildVaultSigner, - createVaultBackedX402Client, + createX402Client, V1_NETWORKS, } = require('./client'); @@ -29,77 +28,43 @@ beforeEach(() => { mockResetVaultAutoLockTimer.mockClear(); }); -// === buildVaultSigner ==================================================== - -describe('buildVaultSigner', () => { +describe('createX402Client', () => { test('rejects when the vault is locked', async () => { mockIdentity.isUnlocked.mockReturnValue(false); - await expect(buildVaultSigner(0)).rejects.toThrow(/locked/i); + await expect(createX402Client(0)).rejects.toThrow(/locked/i); }); - test('exposes the address derived from the wallet index', async () => { - const signer = await buildVaultSigner(0); - expect(signer.address).toBe(TEST_ADDRESS); + test('exposes the signer address on the client so callers can stamp from_address', async () => { + const client = await createX402Client(0); + expect(client.address).toBe(TEST_ADDRESS); expect(mockIdentity.exportPrivateKey).toHaveBeenCalledWith(0); }); - test('signTypedData produces a signature recoverable to the wallet address', async () => { - // Mirrors the EIP-3009 shape `@x402/evm`'s exact/eip3009 client emits — - // the round-trip proves bigint values flow through ethers.signTypedData - // without us needing a stringification shim. - const signer = await buildVaultSigner(0); - const domain = { - name: 'USD Coin', - version: '2', - chainId: 8453, - verifyingContract: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', - }; - const types = { - TransferWithAuthorization: [ - { name: 'from', type: 'address' }, - { name: 'to', type: 'address' }, - { name: 'value', type: 'uint256' }, - { name: 'validAfter', type: 'uint256' }, - { name: 'validBefore', type: 'uint256' }, - { name: 'nonce', type: 'bytes32' }, - ], - }; - const message = { - from: TEST_ADDRESS, - to: '0x209693Bc6afc0C5328bA36FaF03C514EF312287C', - value: 10000n, - validAfter: 1700000000n, - validBefore: 1700000600n, - nonce: '0xf3746613c2d920b5fdabc0856f2aeb2d4f88ee6037b8cc5d04a71a4462f13480', - }; - - const sig = await signer.signTypedData({ domain, types, primaryType: 'TransferWithAuthorization', message }); - expect(sig).toMatch(/^0x[0-9a-f]{130}$/); - expect(verifyTypedData(domain, types, message, sig)).toBe(TEST_ADDRESS); - // Twice: once at signer construction (address derivation), once on sign. - expect(mockResetVaultAutoLockTimer).toHaveBeenCalledTimes(2); - }); - - test('signTypedData throws if the vault re-locks between construction and signing', async () => { - const signer = await buildVaultSigner(0); + test('payment signing throws if the vault re-locks after client construction', async () => { + const client = await createX402Client(0); mockResetVaultAutoLockTimer.mockClear(); // ignore the construction reset mockIdentity.isUnlocked.mockReturnValue(false); - await expect(signer.signTypedData({ domain: {}, types: {}, primaryType: 'X', message: {} })) - .rejects.toThrow(/locked/i); + await expect(client.createPaymentPayload({ + x402Version: 2, + resource: 'https://api.example/article', + accepts: [ + { + scheme: 'exact', + network: 'eip155:8453', + amount: '10000', + asset: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + payTo: '0x209693Bc6afc0C5328bA36FaF03C514EF312287C', + maxTimeoutSeconds: 60, + resource: 'https://api.example/article', + extra: { name: 'USD Coin', version: '2' }, + }, + ], + })).rejects.toThrow(/locked/i); expect(mockResetVaultAutoLockTimer).not.toHaveBeenCalled(); }); -}); - -// === createVaultBackedX402Client ========================================= - -describe('createVaultBackedX402Client', () => { - test('exposes the signer address on the client so callers can stamp from_address', async () => { - const client = await createVaultBackedX402Client(0); - expect(client.address).toBe(TEST_ADDRESS); - }); test('returns an x402Client with V2 and V1 schemes wired', async () => { - const client = await createVaultBackedX402Client(0); + const client = await createX402Client(0); // selectPaymentRequirements returns the picked accepts[] entry; assert // on its scheme + network to prove the right scheme was matched, not @@ -142,7 +107,7 @@ describe('createVaultBackedX402Client', () => { }); test('produces a verifiable V2 payment payload end-to-end (Base / USDC)', async () => { - const client = await createVaultBackedX402Client(0); + const client = await createX402Client(0); // Shape of the parsed `PAYMENT-REQUIRED` header for a Base USDC 402. const paymentRequired = { diff --git a/src/main/x402/ipc.test.js b/src/main/x402/ipc.test.js index bd882b17..12653754 100644 --- a/src/main/x402/ipc.test.js +++ b/src/main/x402/ipc.test.js @@ -23,7 +23,7 @@ const mockClient = { }; const mockCreateClient = jest.fn(async () => mockClient); jest.mock('./client', () => ({ - createVaultBackedX402Client: (idx) => mockCreateClient(idx), + createX402Client: (idx) => mockCreateClient(idx), })); const mockGetActiveWalletIndex = jest.fn(() => 0); diff --git a/src/main/x402/sign-flow.js b/src/main/x402/sign-flow.js index 4cbcd920..c84030bd 100644 --- a/src/main/x402/sign-flow.js +++ b/src/main/x402/sign-flow.js @@ -18,7 +18,7 @@ const { webContents } = require('electron'); const log = require('../logger'); -const { createVaultBackedX402Client } = require('./client'); +const { createX402Client } = require('./client'); const { getActiveWalletIndex } = require('../identity-manager'); const { normalizeOrigin } = require('../../shared/origin-utils'); const { @@ -90,7 +90,7 @@ async function signAndQueueRetry(webContentsId, opts = {}) { catch { throw new Error('Refusing to pay: unparseable URL'); } if (!origin) throw new Error('Refusing to pay: unnormalisable origin'); - const client = await createVaultBackedX402Client(getActiveWalletIndex()); + const client = await createX402Client(getActiveWalletIndex()); // Pre-filter `accepts[]` down to the chosen entry so the SDK's default // first-of-filtered selector signs the right one. Avoids registering a // custom paymentRequirementsSelector for what is effectively a one- diff --git a/src/main/x402/sign-flow.test.js b/src/main/x402/sign-flow.test.js index 7ff64686..4c3ddcfe 100644 --- a/src/main/x402/sign-flow.test.js +++ b/src/main/x402/sign-flow.test.js @@ -17,7 +17,7 @@ const mockClient = { }; const mockCreateClient = jest.fn(async () => mockClient); jest.mock('./client', () => ({ - createVaultBackedX402Client: (idx) => mockCreateClient(idx), + createX402Client: (idx) => mockCreateClient(idx), })); jest.mock('../identity-manager', () => ({ From 8d1e399e5773fd1ffc2b5b2a9ade1d6b2e22165f Mon Sep 17 00:00:00 2001 From: Florian Glatz Date: Tue, 7 Jul 2026 20:54:39 +0200 Subject: [PATCH 02/17] feat(wallet): Ledger account model, device transport, and connect flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds hardware-wallet accounts to the wallet list: derivedWallets[] entries gain a type ('mnemonic' | 'ledger'); Ledger entries persist the device-read address and derivation path since nothing can be re-derived locally. getWalletRecord/getWalletList become the shared normalization seam, and withVaultPrivateKey now refuses to derive a vault key at a hardware account's index — the chokepoint guard that keeps any future caller from silently signing with a phantom mnemonic key. Main-process ledger module (src/main/wallet/ledger/): - transport.js: node-hid transport behind a serialization queue (one APDU exchange at a time), lazy native-module load, account discovery over Ledger Live and legacy derivation schemes - errors.js: APDU status words and transport errors mapped to stable LEDGER_* codes with user-facing instructions - signer.js: signer-factory backend; getAddress from the stored record, signing fails closed until the device-confirmation flow lands UI: "Connect Hardware Wallet" in the wallet selector opens a subscreen that polls for a device with the Ethereum app open, pages through device accounts, and adds the chosen one (no vault unlock needed). Ledger accounts show a badge in the selector; private-key export is blocked for them. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VtT8ToNv26pYdwB1BvXVyu --- package-lock.json | 940 ++++++++++++++++++++- package.json | 2 + src/main/identity-manager.js | 174 +++- src/main/identity-manager.test.js | 125 +++ src/main/index.js | 2 + src/main/preload.js | 5 + src/main/preload.test.js | 3 +- src/main/wallet/ledger/errors.js | 117 +++ src/main/wallet/ledger/errors.test.js | 73 ++ src/main/wallet/ledger/ipc.js | 29 + src/main/wallet/ledger/signer.js | 31 + src/main/wallet/ledger/transport.js | 105 +++ src/main/wallet/ledger/transport.test.js | 117 +++ src/main/wallet/signers.js | 17 +- src/main/wallet/signers.test.js | 47 ++ src/main/wallet/vault-access.js | 9 +- src/main/wallet/vault-access.test.js | 13 + src/main/x402/client.test.js | 2 + src/renderer/index.html | 98 +++ src/renderer/lib/wallet-ui.js | 5 +- src/renderer/lib/wallet/connect-ledger.js | 283 +++++++ src/renderer/lib/wallet/wallet-selector.js | 17 +- src/renderer/styles/sidebar.css | 182 ++++ 23 files changed, 2371 insertions(+), 25 deletions(-) create mode 100644 src/main/wallet/ledger/errors.js create mode 100644 src/main/wallet/ledger/errors.test.js create mode 100644 src/main/wallet/ledger/ipc.js create mode 100644 src/main/wallet/ledger/signer.js create mode 100644 src/main/wallet/ledger/transport.js create mode 100644 src/main/wallet/ledger/transport.test.js create mode 100644 src/renderer/lib/wallet/connect-ledger.js diff --git a/package-lock.json b/package-lock.json index 25f89487..33ac8ee8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,8 @@ "@corpus-core/colibri-stateless": "^1.1.30", "@ensdomains/content-hash": "^3.0.0", "@ethersphere/bee-js": "^12.2.1", + "@ledgerhq/hw-app-eth": "^7.8.8", + "@ledgerhq/hw-transport-node-hid": "^6.33.5", "@metamask/browser-passworder": "^6.0.0", "@scure/bip39": "^2.2.0", "@x402/core": "^2.12.0", @@ -2075,6 +2077,404 @@ "beeApiVersion": "7.2.0" } }, + "node_modules/@ethersproject/abi": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/abi/-/abi-5.8.0.tgz", + "integrity": "sha512-b9YS/43ObplgyV6SlyQsG53/vkSal0MNA1fskSC4mbnCMi8R+NkcH8K9FPYNESf6jUefBUniE4SOKms0E/KK1Q==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/address": "^5.8.0", + "@ethersproject/bignumber": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/constants": "^5.8.0", + "@ethersproject/hash": "^5.8.0", + "@ethersproject/keccak256": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "@ethersproject/strings": "^5.8.0" + } + }, + "node_modules/@ethersproject/abstract-provider": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/abstract-provider/-/abstract-provider-5.8.0.tgz", + "integrity": "sha512-wC9SFcmh4UK0oKuLJQItoQdzS/qZ51EJegK6EmAWlh+OptpQ/npECOR3QqECd8iGHC0RJb4WKbVdSfif4ammrg==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bignumber": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/networks": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "@ethersproject/transactions": "^5.8.0", + "@ethersproject/web": "^5.8.0" + } + }, + "node_modules/@ethersproject/abstract-signer": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/abstract-signer/-/abstract-signer-5.8.0.tgz", + "integrity": "sha512-N0XhZTswXcmIZQdYtUnd79VJzvEwXQw6PK0dTl9VoYrEBxxCPXqS0Eod7q5TNKRxe1/5WUMuR0u0nqTF/avdCA==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/abstract-provider": "^5.8.0", + "@ethersproject/bignumber": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/properties": "^5.8.0" + } + }, + "node_modules/@ethersproject/address": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/address/-/address-5.8.0.tgz", + "integrity": "sha512-GhH/abcC46LJwshoN+uBNoKVFPxUuZm6dA257z0vZkKmU1+t8xTn8oK7B9qrj8W2rFRMch4gbJl6PmVxjxBEBA==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bignumber": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/keccak256": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/rlp": "^5.8.0" + } + }, + "node_modules/@ethersproject/base64": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/base64/-/base64-5.8.0.tgz", + "integrity": "sha512-lN0oIwfkYj9LbPx4xEkie6rAMJtySbpOAFXSDVQaBnAzYfB4X2Qr+FXJGxMoc3Bxp2Sm8OwvzMrywxyw0gLjIQ==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.8.0" + } + }, + "node_modules/@ethersproject/bignumber": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/bignumber/-/bignumber-5.8.0.tgz", + "integrity": "sha512-ZyaT24bHaSeJon2tGPKIiHszWjD/54Sz8t57Toch475lCLljC6MgPmxk7Gtzz+ddNN5LuHea9qhAe0x3D+uYPA==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "bn.js": "^5.2.1" + } + }, + "node_modules/@ethersproject/bytes": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/bytes/-/bytes-5.8.0.tgz", + "integrity": "sha512-vTkeohgJVCPVHu5c25XWaWQOZ4v+DkGoC42/TS2ond+PARCxTJvgTFUNDZovyQ/uAQ4EcpqqowKydcdmRKjg7A==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/logger": "^5.8.0" + } + }, + "node_modules/@ethersproject/constants": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/constants/-/constants-5.8.0.tgz", + "integrity": "sha512-wigX4lrf5Vu+axVTIvNsuL6YrV4O5AXl5ubcURKMEME5TnWBouUh0CDTWxZ2GpnRn1kcCgE7l8O5+VbV9QTTcg==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bignumber": "^5.8.0" + } + }, + "node_modules/@ethersproject/hash": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/hash/-/hash-5.8.0.tgz", + "integrity": "sha512-ac/lBcTbEWW/VGJij0CNSw/wPcw9bSRgCB0AIBz8CvED/jfvDoV9hsIIiWfvWmFEi8RcXtlNwp2jv6ozWOsooA==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/abstract-signer": "^5.8.0", + "@ethersproject/address": "^5.8.0", + "@ethersproject/base64": "^5.8.0", + "@ethersproject/bignumber": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/keccak256": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "@ethersproject/strings": "^5.8.0" + } + }, + "node_modules/@ethersproject/keccak256": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/keccak256/-/keccak256-5.8.0.tgz", + "integrity": "sha512-A1pkKLZSz8pDaQ1ftutZoaN46I6+jvuqugx5KYNeQOPqq+JZ0Txm7dlWesCHB5cndJSu5vP2VKptKf7cksERng==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.8.0", + "js-sha3": "0.8.0" + } + }, + "node_modules/@ethersproject/keccak256/node_modules/js-sha3": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz", + "integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==", + "license": "MIT" + }, + "node_modules/@ethersproject/logger": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/logger/-/logger-5.8.0.tgz", + "integrity": "sha512-Qe6knGmY+zPPWTC+wQrpitodgBfH7XoceCGL5bJVejmH+yCS3R8jJm8iiWuvWbG76RUmyEG53oqv6GMVWqunjA==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT" + }, + "node_modules/@ethersproject/networks": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/networks/-/networks-5.8.0.tgz", + "integrity": "sha512-egPJh3aPVAzbHwq8DD7Po53J4OUSsA1MjQp8Vf/OZPav5rlmWUaFLiq8cvQiGK0Z5K6LYzm29+VA/p4RL1FzNg==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/logger": "^5.8.0" + } + }, + "node_modules/@ethersproject/properties": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/properties/-/properties-5.8.0.tgz", + "integrity": "sha512-PYuiEoQ+FMaZZNGrStmN7+lWjlsoufGIHdww7454FIaGdbe/p5rnaCXTr5MtBYl3NkeoVhHZuyzChPeGeKIpQw==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/logger": "^5.8.0" + } + }, + "node_modules/@ethersproject/rlp": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/rlp/-/rlp-5.8.0.tgz", + "integrity": "sha512-LqZgAznqDbiEunaUvykH2JAoXTT9NV0Atqk8rQN9nx9SEgThA/WMx5DnW8a9FOufo//6FZOCHZ+XiClzgbqV9Q==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/logger": "^5.8.0" + } + }, + "node_modules/@ethersproject/signing-key": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/signing-key/-/signing-key-5.8.0.tgz", + "integrity": "sha512-LrPW2ZxoigFi6U6aVkFN/fa9Yx/+4AtIUe4/HACTvKJdhm0eeb107EVCIQcrLZkxaSIgc/eCrX8Q1GtbH+9n3w==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "bn.js": "^5.2.1", + "elliptic": "6.6.1", + "hash.js": "1.1.7" + } + }, + "node_modules/@ethersproject/strings": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/strings/-/strings-5.8.0.tgz", + "integrity": "sha512-qWEAk0MAvl0LszjdfnZ2uC8xbR2wdv4cDabyHiBh3Cldq/T8dPH3V4BbBsAYJUeonwD+8afVXld274Ls+Y1xXg==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/constants": "^5.8.0", + "@ethersproject/logger": "^5.8.0" + } + }, + "node_modules/@ethersproject/transactions": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/transactions/-/transactions-5.8.0.tgz", + "integrity": "sha512-UglxSDjByHG0TuU17bDfCemZ3AnKO2vYrL5/2n2oXvKzvb7Cz+W9gOWXKARjp2URVwcWlQlPOEQyAviKwT4AHg==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/address": "^5.8.0", + "@ethersproject/bignumber": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/constants": "^5.8.0", + "@ethersproject/keccak256": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "@ethersproject/rlp": "^5.8.0", + "@ethersproject/signing-key": "^5.8.0" + } + }, + "node_modules/@ethersproject/web": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/web/-/web-5.8.0.tgz", + "integrity": "sha512-j7+Ksi/9KfGviws6Qtf9Q7KCqRhpwrYKQPs+JBA/rKVFF/yaWLHJEH3zfVP2plVu+eys0d2DlFmhoQJayFewcw==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/base64": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "@ethersproject/strings": "^5.8.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -3004,6 +3404,249 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@ledgerhq/client-ids": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@ledgerhq/client-ids/-/client-ids-0.11.0.tgz", + "integrity": "sha512-/lXsAdtu3kuR+lWP94fiNT9BE4pXtqFE/j3wKAWXuPoLsZ5VYwrj2NcvKtUgNzzoSGOpernfLP/8ys4xlv18JQ==", + "license": "Apache-2.0", + "dependencies": { + "@ledgerhq/live-env": "^2.40.0", + "@reduxjs/toolkit": "2.11.2", + "uuid": "^9.0.0" + } + }, + "node_modules/@ledgerhq/client-ids/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@ledgerhq/devices": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/@ledgerhq/devices/-/devices-8.16.0.tgz", + "integrity": "sha512-brXLPzkvGM3D5YNsWQ25P5G4SmWdSNBed9W8wKoOIRLGdRfvE+bg9mzFty0iZ+aRLBkLoXwX7xKIL9zUi6LBKQ==", + "license": "Apache-2.0", + "dependencies": { + "semver": "7.7.3" + } + }, + "node_modules/@ledgerhq/devices/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@ledgerhq/domain-service": { + "version": "1.8.8", + "resolved": "https://registry.npmjs.org/@ledgerhq/domain-service/-/domain-service-1.8.8.tgz", + "integrity": "sha512-n/ikZTLEx5ssmnbu6YnzjcjC/9W80x6Q41wwzes7QoV2GqjWscy6dqKTMpa4DOoe85w0KgVNDiFnJh7XXjQHMg==", + "license": "Apache-2.0", + "dependencies": { + "@ledgerhq/errors": "^6.37.0", + "@ledgerhq/logs": "^6.17.0", + "@ledgerhq/types-live": "^6.113.0", + "axios": "1.13.5", + "eip55": "^2.1.1", + "react": "19.0.0", + "react-dom": "19.0.0" + } + }, + "node_modules/@ledgerhq/domain-service/node_modules/axios": { + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/@ledgerhq/domain-service/node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/@ledgerhq/errors": { + "version": "6.37.0", + "resolved": "https://registry.npmjs.org/@ledgerhq/errors/-/errors-6.37.0.tgz", + "integrity": "sha512-T5yiKI5UX7ugeocdTF3TUsCIN2BH41Bio4ZeN410YFjFOf3es08n/5JyMzzKwzRgP0blG3HfBf7s7vJKqCSAeg==", + "license": "Apache-2.0" + }, + "node_modules/@ledgerhq/evm-tools": { + "version": "1.12.11", + "resolved": "https://registry.npmjs.org/@ledgerhq/evm-tools/-/evm-tools-1.12.11.tgz", + "integrity": "sha512-qh+uM7DNBOsPMv0SJmM97xmAHtrwHAwDGpgEJMpIZYKYoAcbt7Fusw0QK7hgEahopphjcMh34CNBCEMSb/2nQw==", + "license": "Apache-2.0", + "dependencies": { + "@ethersproject/constants": "^5.7.0", + "@ethersproject/hash": "^5.7.0", + "@ledgerhq/live-env": "^2.40.0", + "axios": "1.13.5", + "crypto-js": "4.2.0" + } + }, + "node_modules/@ledgerhq/evm-tools/node_modules/axios": { + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/@ledgerhq/evm-tools/node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/@ledgerhq/hw-app-eth": { + "version": "7.8.8", + "resolved": "https://registry.npmjs.org/@ledgerhq/hw-app-eth/-/hw-app-eth-7.8.8.tgz", + "integrity": "sha512-EUT8J9g/QaYZDhdQtMfFJoTN1PEVHgZtpp0heU+vWmQotOGWpjXlT8fjkh02n45hym6irdJob3vYrt3qOCAUlg==", + "license": "Apache-2.0", + "dependencies": { + "@ethersproject/abi": "^5.7.0", + "@ethersproject/rlp": "^5.7.0", + "@ethersproject/transactions": "^5.7.0", + "@ledgerhq/domain-service": "^1.8.8", + "@ledgerhq/errors": "^6.37.0", + "@ledgerhq/evm-tools": "^1.12.11", + "@ledgerhq/hw-transport": "6.35.5", + "@ledgerhq/hw-transport-mocker": "^6.34.5", + "@ledgerhq/logs": "^6.17.0", + "@ledgerhq/types-live": "^6.113.0", + "axios": "1.13.5", + "bignumber.js": "^9.1.2", + "semver": "7.7.3" + } + }, + "node_modules/@ledgerhq/hw-app-eth/node_modules/axios": { + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/@ledgerhq/hw-app-eth/node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/@ledgerhq/hw-app-eth/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@ledgerhq/hw-transport": { + "version": "6.35.5", + "resolved": "https://registry.npmjs.org/@ledgerhq/hw-transport/-/hw-transport-6.35.5.tgz", + "integrity": "sha512-P4+wtLewLWgxPtIb90h5kjpzXVlC6f4IBQBmvowVFkInvZt34ffXkX7wa5KfMzu4l3cqCcpNSqtPSCMp+0vuqg==", + "license": "Apache-2.0", + "dependencies": { + "@ledgerhq/devices": "8.16.0", + "@ledgerhq/errors": "^6.37.0", + "@ledgerhq/logs": "^6.17.0", + "events": "^3.3.0" + } + }, + "node_modules/@ledgerhq/hw-transport-mocker": { + "version": "6.34.5", + "resolved": "https://registry.npmjs.org/@ledgerhq/hw-transport-mocker/-/hw-transport-mocker-6.34.5.tgz", + "integrity": "sha512-CSo8bRkyzYgrIpMIP7jgP+tB3+meC+orUJSnKwmLL1eT5XdOKlYc3Xl3yB6CRNUZ6LAqQPas0a/Oi9krwkCV1A==", + "license": "Apache-2.0", + "dependencies": { + "@ledgerhq/hw-transport": "6.35.5", + "@ledgerhq/logs": "^6.17.0", + "rxjs": "7.8.2" + } + }, + "node_modules/@ledgerhq/hw-transport-node-hid": { + "version": "6.33.5", + "resolved": "https://registry.npmjs.org/@ledgerhq/hw-transport-node-hid/-/hw-transport-node-hid-6.33.5.tgz", + "integrity": "sha512-uJ7VztZEDdhCAmCByMltH9Zg6qrU1CrTjVpLrO7IQD5paQbMhZNOLSrtBhWSoP8LYNo/gx5VtLTdtUIIedjsVA==", + "license": "Apache-2.0", + "dependencies": { + "@ledgerhq/devices": "8.16.0", + "@ledgerhq/errors": "^6.37.0", + "@ledgerhq/hw-transport": "6.35.5", + "@ledgerhq/hw-transport-node-hid-noevents": "^6.36.0", + "@ledgerhq/logs": "^6.17.0", + "lodash": "^4.17.21", + "node-hid": "2.1.2", + "usb": "2.9.0" + } + }, + "node_modules/@ledgerhq/hw-transport-node-hid-noevents": { + "version": "6.36.0", + "resolved": "https://registry.npmjs.org/@ledgerhq/hw-transport-node-hid-noevents/-/hw-transport-node-hid-noevents-6.36.0.tgz", + "integrity": "sha512-hsqybDCDtpNWeWVY2b/emrsFDqVfc3Rkv4+2b6h5ubPGsKWFd+7t0u6oy0sJEl5v6C+V/uxl03shBjWEcSoxgw==", + "license": "Apache-2.0", + "dependencies": { + "@ledgerhq/devices": "8.16.0", + "@ledgerhq/errors": "^6.37.0", + "@ledgerhq/hw-transport": "6.35.5", + "@ledgerhq/logs": "^6.17.0", + "node-hid": "2.1.2" + } + }, + "node_modules/@ledgerhq/live-env": { + "version": "2.40.0", + "resolved": "https://registry.npmjs.org/@ledgerhq/live-env/-/live-env-2.40.0.tgz", + "integrity": "sha512-TwiFiw+eLk/ONCp1v89j8fddzgohLqVlnclbLjDvkrkpZ6h2tigGstuzd+pgNfHi5ixEapg3WJF4naZgu40TSg==", + "license": "Apache-2.0", + "dependencies": { + "rxjs": "7.8.2", + "utility-types": "^3.10.0" + } + }, + "node_modules/@ledgerhq/logs": { + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/@ledgerhq/logs/-/logs-6.17.0.tgz", + "integrity": "sha512-yra33g5q/AU7+PwAws+GaVpQGUuxnDREjVBnviJjcaJLVKuLzI4pnj8Bd3nY3fypM5k1yZEYKEXfUuGFUjP2+w==", + "license": "Apache-2.0" + }, + "node_modules/@ledgerhq/types-live": { + "version": "6.113.0", + "resolved": "https://registry.npmjs.org/@ledgerhq/types-live/-/types-live-6.113.0.tgz", + "integrity": "sha512-gfCGfCl1yUBmJ35AqKbVy4nKIVPQXK0Hd+F8tPRNhJ2C8Y8SsKlYoG/SJOMRLYDPgckUqc0BCVWBhSBQC4TKBQ==", + "license": "Apache-2.0", + "dependencies": { + "@ledgerhq/client-ids": "0.11.0", + "bignumber.js": "^9.1.2", + "rxjs": "7.8.2" + } + }, "node_modules/@malept/cross-spawn-promise": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", @@ -3277,6 +3920,32 @@ "node": ">=18" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@scure/base": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", @@ -3395,6 +4064,18 @@ "@sinonjs/commons": "^3.0.1" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@szmarczak/http-timer": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", @@ -3782,6 +4463,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/w3c-web-usb": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/@types/w3c-web-usb/-/w3c-web-usb-1.0.14.tgz", + "integrity": "sha512-Qu3Nn6JFuF4+sHKYl+IcX9vYiI40ogleXzFFSxoE1W94rG98o/kXs8uJ0QSfFzuwBCZWlGfUGpPkgwuuX4PchA==", + "license": "MIT" + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -5041,6 +5728,15 @@ "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" } }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", @@ -5068,6 +5764,12 @@ "dev": true, "license": "MIT" }, + "node_modules/bn.js": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.4.tgz", + "integrity": "sha512-QL7sb18rJ1PbdsKsqPA0guxL563vIMwRHgzNrW/uzQuRGN1Cjqd/wonUBAVqHox9KwzHA6vCbM0lXx3k4iQMow==", + "license": "MIT" + }, "node_modules/boolean": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", @@ -5090,6 +5792,12 @@ "node": "18 || 20 || >=22" } }, + "node_modules/brorand": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", + "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==", + "license": "MIT" + }, "node_modules/browserslist": { "version": "4.28.4", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", @@ -5606,6 +6314,12 @@ "node": ">= 8" } }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -5985,6 +6699,15 @@ "dev": true, "license": "MIT" }, + "node_modules/eip55": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/eip55/-/eip55-2.1.1.tgz", + "integrity": "sha512-WcagVAmNu2Ww2cDUfzuWVntYwFxbvZ5MvIyLZpMjTTkjD6sCvkGOiS86jTppzu9/gWsc8isLHAeMBWK02OnZmA==", + "license": "MIT", + "dependencies": { + "keccak": "^3.0.3" + } + }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -6226,6 +6949,27 @@ "dev": true, "license": "MIT" }, + "node_modules/elliptic": { + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.1.tgz", + "integrity": "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.11.9", + "brorand": "^1.1.0", + "hash.js": "^1.0.0", + "hmac-drbg": "^1.0.1", + "inherits": "^2.0.4", + "minimalistic-assert": "^1.0.1", + "minimalistic-crypto-utils": "^1.0.1" + } + }, + "node_modules/elliptic/node_modules/bn.js": { + "version": "4.12.4", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.4.tgz", + "integrity": "sha512-njR1b+ixG2ufvL9Zn9JGneW+b5GV6jqpYyPPpg4QVt723b5kJPGUczkUyWEH9BwEA74UakJZ43I4FDLBF7ci0g==", + "license": "MIT" + }, "node_modules/emittery": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", @@ -6737,6 +7481,15 @@ "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", "license": "MIT" }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -7377,6 +8130,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -7389,6 +8152,17 @@ "node": ">= 0.4" } }, + "node_modules/hmac-drbg": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", + "integrity": "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==", + "license": "MIT", + "dependencies": { + "hash.js": "^1.0.3", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.1" + } + }, "node_modules/hosted-git-info": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", @@ -7529,6 +8303,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "11.1.11", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.11.tgz", + "integrity": "sha512-qzXuyXAkPySAGYkfsAwodDPWT8Zm7/Uo5BNt4BjhMhG5WlWyZZ4wQqnWwdS8kjlQ1Cwu6gjw3A6+0gTQwlyYtw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-local": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", @@ -9540,6 +10324,21 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/keccak": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/keccak/-/keccak-3.0.4.tgz", + "integrity": "sha512-3vKuW0jV8J3XNTzvfyicFR5qvxrSAGl7KIhvgOu5cmWwM7tZRj3fMbj/pfIf4be7aznbc+prBWGjywox/g2Y6Q==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^2.0.0", + "node-gyp-build": "^4.2.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -9842,6 +10641,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, + "node_modules/minimalistic-crypto-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", + "integrity": "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==", + "license": "MIT" + }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -9964,6 +10775,12 @@ "node": ">=22.12.0" } }, + "node_modules/node-addon-api": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-2.0.2.tgz", + "integrity": "sha512-Ntyt4AIXyaLIuMHF6IOoTakB3K+RWxwtsHNRxllEoA6vPwP9o4866g6YWDLUdnucilZhmkxiHwHr11gAENw+QA==", + "license": "MIT" + }, "node_modules/node-api-version": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz", @@ -9999,6 +10816,17 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-gyp/node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -10045,6 +10873,30 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/node-hid": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/node-hid/-/node-hid-2.1.2.tgz", + "integrity": "sha512-qhCyQqrPpP93F/6Wc/xUR7L8mAJW0Z6R7HMQV8jCHHksAxNDe/4z4Un/H9CpLOT+5K39OPyt9tIQlavxWES3lg==", + "hasInstallScript": true, + "license": "(MIT OR X11)", + "dependencies": { + "bindings": "^1.5.0", + "node-addon-api": "^3.0.2", + "prebuild-install": "^7.1.1" + }, + "bin": { + "hid-showdevices": "src/show-devices.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-hid/node_modules/node-addon-api": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.2.1.tgz", + "integrity": "sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==", + "license": "MIT" + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -10970,6 +11822,27 @@ "node": ">=0.10.0" } }, + "node_modules/react": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz", + "integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz", + "integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.25.0" + }, + "peerDependencies": { + "react": "^19.0.0" + } + }, "node_modules/react-is-18": { "name": "react-is", "version": "18.3.1", @@ -11013,6 +11886,21 @@ "node": ">= 6" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", @@ -11114,6 +12002,12 @@ "url": "https://github.com/sponsors/jet2jet" } }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, "node_modules/resolve-alpn": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", @@ -11208,6 +12102,15 @@ "license": "BSD-3-Clause", "optional": true }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -11247,6 +12150,12 @@ "node": ">=11.0.0" } }, + "node_modules/scheduler": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", + "integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==", + "license": "MIT" + }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -11823,7 +12732,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/tunnel-agent": { @@ -12053,6 +12961,27 @@ "punycode": "^2.1.0" } }, + "node_modules/usb": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/usb/-/usb-2.9.0.tgz", + "integrity": "sha512-G0I/fPgfHUzWH8xo2KkDxTTFruUWfppgSFJ+bQxz/kVY2x15EQ/XDB7dqD1G432G4gBG4jYQuF3U7j/orSs5nw==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@types/w3c-web-usb": "^1.0.6", + "node-addon-api": "^6.0.0", + "node-gyp-build": "^4.5.0" + }, + "engines": { + "node": ">=10.20.0 <11.x || >=12.17.0 <13.0 || >=14.0.0" + } + }, + "node_modules/usb/node_modules/node-addon-api": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", + "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", + "license": "MIT" + }, "node_modules/utf8-byte-length": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", @@ -12066,6 +12995,15 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/utility-types": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz", + "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/uuid": { "version": "14.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz", diff --git a/package.json b/package.json index 705ba392..41230236 100644 --- a/package.json +++ b/package.json @@ -199,6 +199,8 @@ "@corpus-core/colibri-stateless": "^1.1.30", "@ensdomains/content-hash": "^3.0.0", "@ethersphere/bee-js": "^12.2.1", + "@ledgerhq/hw-app-eth": "^7.8.8", + "@ledgerhq/hw-transport-node-hid": "^6.33.5", "@metamask/browser-passworder": "^6.0.0", "@scure/bip39": "^2.2.0", "@x402/core": "^2.12.0", diff --git a/src/main/identity-manager.js b/src/main/identity-manager.js index ac56e0ff..9d79e1d2 100644 --- a/src/main/identity-manager.js +++ b/src/main/identity-manager.js @@ -281,10 +281,13 @@ async function getUserWalletKey(walletIndex) { throw new Error('Wallet index must be a non-negative integer'); } - const wallets = await getDerivedWallets(); - if (!wallets.some((wallet) => wallet.index === walletIndex)) { + const record = getWalletRecord(walletIndex); + if (!record) { throw new Error(`Wallet with index ${walletIndex} does not exist`); } + if (record.type !== WALLET_TYPES.MNEMONIC) { + throw new Error('Hardware wallet accounts have no derivable private key'); + } const identity = await loadIdentityModule(); const mnemonic = identity.getMnemonic(); @@ -783,9 +786,66 @@ async function exportMnemonic() { // Multi-Wallet Support // ============================================ +/** + * Wallet account types. Entries in vault-meta's `derivedWallets[]` without + * a `type` field predate hardware-wallet support and are mnemonic-derived. + */ +const WALLET_TYPES = { + MNEMONIC: 'mnemonic', + LEDGER: 'ledger', +}; + +/** + * The wallet list stored in vault-meta, with the implicit pre-multi-wallet + * default (just the main wallet) when `derivedWallets` was never written. + * + * @param {Object} meta - Parsed vault-meta + * @returns {Array} Raw derivedWallets entries + */ +function getWalletList(meta) { + return ( + meta.derivedWallets || [ + { index: 0, name: 'Main Wallet', address: meta.addresses?.userWallet || null }, + ] + ); +} + +/** + * Look up a single wallet account record by index, normalized: `type` + * always present, address falling back to the stored main-wallet address + * for index 0. Returns null when the index is unknown. + * + * Used by the signer factory and the vault-access guard to decide which + * signing backend an index resolves to — must stay synchronous and cheap. + * + * @param {number} walletIndex + * @param {Object} [meta] - Already-loaded vault-meta, to skip the disk read + * @returns {{index: number, name: string, address: string|null, type: string, path?: string}|null} + */ +function getWalletRecord(walletIndex, meta = getVaultMeta()) { + if (!meta) { + return null; + } + const record = getWalletList(meta).find((wallet) => wallet.index === walletIndex); + if (!record) { + return null; + } + let address = record.address || null; + if (!address && record.index === 0) { + address = meta.addresses?.userWallet || null; + } + return { + index: record.index, + name: record.name, + address, + type: record.type || WALLET_TYPES.MNEMONIC, + ...(record.path ? { path: record.path } : {}), + }; +} + /** * Get list of derived user wallets - * @returns {Array<{index: number, name: string, address: string}>} + * @returns {Array<{index: number, name: string, address: string, type: string}>} */ async function getDerivedWallets() { const identity = await loadIdentityModule(); @@ -813,7 +873,7 @@ async function getDerivedWallets() { activeWalletIndex: 0, }); - return wallets; + return wallets.map((wallet) => ({ ...wallet, type: WALLET_TYPES.MNEMONIC })); } // If vault is unlocked, derive addresses; otherwise use stored addresses @@ -821,9 +881,14 @@ async function getDerivedWallets() { const wallets = []; for (const wallet of meta.derivedWallets) { + const type = wallet.type || WALLET_TYPES.MNEMONIC; let address = null; - if (mnemonic) { + if (type === WALLET_TYPES.LEDGER) { + // Hardware accounts: the address was read from the device when the + // account was added; there is nothing to derive locally. + address = wallet.address || null; + } else if (mnemonic) { // Derive address from mnemonic const derived = identity.deriveUserWallet(mnemonic, wallet.index); address = derived.address; @@ -840,12 +905,69 @@ async function getDerivedWallets() { index: wallet.index, name: wallet.name, address, + type, + ...(wallet.path ? { path: wallet.path } : {}), }); } return wallets; } +/** + * Add a Ledger hardware-wallet account to the wallet list. + * + * The address comes from the device during account discovery and is + * persisted — it can never be re-derived locally. Does not require the + * vault to be unlocked (no mnemonic involved), only that a vault exists + * so there is a wallet list to add to. + * + * @param {string} name - Display name ('' → auto "Ledger N") + * @param {string} address - Checksummed address read from the device + * @param {string} path - Derivation path in device format (e.g. "44'/60'/0'/0/0") + * @returns {Promise<{index: number, name: string, address: string, type: string, path: string}>} + */ +async function addLedgerWallet(name, address, path) { + const { isAddress } = require('ethers'); + if (typeof address !== 'string' || !isAddress(address)) { + throw new Error('Invalid Ledger account address'); + } + if (typeof path !== 'string' || !path) { + throw new Error('Missing derivation path for Ledger account'); + } + + const meta = getVaultMeta(); + if (!meta) { + throw new Error('No vault found'); + } + + const wallets = getWalletList(meta); + + const duplicate = wallets.find( + (wallet) => wallet.address && wallet.address.toLowerCase() === address.toLowerCase() + ); + if (duplicate) { + throw new Error(`This account is already in your wallet list as "${duplicate.name}"`); + } + + const newIndex = wallets.reduce((max, w) => Math.max(max, w.index), -1) + 1; + const ledgerCount = wallets.filter((w) => w.type === WALLET_TYPES.LEDGER).length; + const newWallet = { + index: newIndex, + name: (name || '').trim() || `Ledger ${ledgerCount + 1}`, + address, + type: WALLET_TYPES.LEDGER, + path, + }; + wallets.push(newWallet); + + saveVaultMeta({ + ...meta, + derivedWallets: wallets, + }); + + return { ...newWallet }; +} + /** * Get the active wallet index * @returns {number} @@ -866,7 +988,7 @@ async function setActiveWalletIndex(index) { } // Verify wallet exists - const wallets = meta.derivedWallets || [{ index: 0, name: 'Main Wallet' }]; + const wallets = getWalletList(meta); const walletExists = wallets.some((w) => w.index === index); if (!walletExists) { @@ -898,7 +1020,7 @@ async function createDerivedWallet(name) { } // Get current wallets - const wallets = meta.derivedWallets || [{ index: 0, name: 'Main Wallet' }]; + const wallets = getWalletList(meta); // Find next available index (use account index, starting from max + 1) const maxIndex = wallets.reduce((max, w) => Math.max(max, w.index), -1); @@ -939,7 +1061,7 @@ async function renameDerivedWallet(index, newName) { throw new Error('No vault found'); } - const wallets = meta.derivedWallets || [{ index: 0, name: 'Main Wallet' }]; + const wallets = getWalletList(meta); const walletIndex = wallets.findIndex((w) => w.index === index); if (walletIndex === -1) { @@ -981,7 +1103,7 @@ async function deleteDerivedWallet(index) { throw new Error('No vault found'); } - const wallets = meta.derivedWallets || [{ index: 0, name: 'Main Wallet' }]; + const wallets = getWalletList(meta); const walletIndex = wallets.findIndex((w) => w.index === index); if (walletIndex === -1) { @@ -1025,6 +1147,14 @@ async function getActiveWalletAddress() { } const activeIndex = meta.activeWalletIndex ?? 0; + + // Hardware accounts always use the stored device address — there is + // no local derivation, unlocked vault or not. + const record = getWalletRecord(activeIndex, meta); + if (record && record.type !== WALLET_TYPES.MNEMONIC) { + return record.address; + } + const mnemonic = identity.getMnemonic(); if (mnemonic) { @@ -1033,11 +1163,7 @@ async function getActiveWalletAddress() { } // Vault locked - can only return main wallet address from stored meta - if (activeIndex === 0) { - return meta.addresses?.userWallet || null; - } - - return null; + return activeIndex === 0 ? (record?.address ?? null) : null; } /** @@ -1190,6 +1316,13 @@ function registerIdentityIpc() { if (!password) { return { success: false, error: 'Password is required to export private key' }; } + const record = getWalletRecord(accountIndex); + if (record && record.type !== WALLET_TYPES.MNEMONIC) { + return { + success: false, + error: 'Hardware wallet accounts have no exportable private key — the key never leaves the device', + }; + } const identity = await loadIdentityModule(); const dataDir = getIdentityDataDir(); await identity.verifyPassword(dataDir, password); @@ -1274,6 +1407,16 @@ function registerIdentityIpc() { } }); + // Add a Ledger hardware-wallet account (address read from the device) + ipcMain.handle('wallet:add-ledger-wallet', async (_event, name, address, path) => { + try { + const wallet = await addLedgerWallet(name, address, path); + return { success: true, wallet }; + } catch (err) { + return { success: false, error: err.message }; + } + }); + // Rename wallet ipcMain.handle('wallet:rename-wallet', async (_event, index, newName) => { try { @@ -1329,10 +1472,13 @@ module.exports = { getUserWalletKey, // Multi-wallet operations + WALLET_TYPES, + getWalletRecord, getDerivedWallets, getActiveWalletIndex, setActiveWalletIndex, createDerivedWallet, + addLedgerWallet, renameDerivedWallet, deleteDerivedWallet, getActiveWalletAddress, diff --git a/src/main/identity-manager.test.js b/src/main/identity-manager.test.js index 2382cbec..57e24245 100644 --- a/src/main/identity-manager.test.js +++ b/src/main/identity-manager.test.js @@ -236,6 +236,131 @@ describe('identity-manager wallet deletion', () => { }); }); +describe('identity-manager ledger accounts', () => { + let tmpDir; + let envSnapshot; + let identityManager; + + const LEDGER_ADDRESS = '0x209693Bc6afc0C5328bA36FaF03C514EF312287C'; + const LEDGER_PATH = "44'/60'/0'/0/0"; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'identity-manager-ledger-')); + envSnapshot = snapshotEnv(); + process.env.FREEDOM_IDENTITY_DATA = tmpDir; + identityManager = loadMainModule(require.resolve('./identity-manager'), { + userDataDir: tmpDir, + extraMocks: { + [require.resolve('./identity')]: () => ({ + getMnemonic: jest.fn(() => null), // vault locked — ledger ops must not need it + isUnlocked: jest.fn(() => false), + }), + }, + }).mod; + }); + + afterEach(() => { + restoreEnv(envSnapshot); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function writeVaultMeta(meta) { + fs.writeFileSync(path.join(tmpDir, 'vault-meta.json'), JSON.stringify(meta, null, 2), 'utf-8'); + } + + function readVaultMeta() { + return JSON.parse(fs.readFileSync(path.join(tmpDir, 'vault-meta.json'), 'utf-8')); + } + + function seedMainWallet() { + writeVaultMeta({ + activeWalletIndex: 0, + addresses: { userWallet: '0x0000000000000000000000000000000000000001' }, + derivedWallets: [ + { index: 0, name: 'Main Wallet', address: '0x0000000000000000000000000000000000000001' }, + ], + }); + } + + test('addLedgerWallet appends a typed record with device address and path', async () => { + seedMainWallet(); + + const wallet = await identityManager.addLedgerWallet('My Stax', LEDGER_ADDRESS, LEDGER_PATH); + + expect(wallet).toEqual({ + index: 1, + name: 'My Stax', + address: LEDGER_ADDRESS, + type: 'ledger', + path: LEDGER_PATH, + }); + expect(readVaultMeta().derivedWallets).toHaveLength(2); + expect(readVaultMeta().derivedWallets[1]).toMatchObject({ type: 'ledger', path: LEDGER_PATH }); + }); + + test('addLedgerWallet auto-names and works with the vault locked', async () => { + seedMainWallet(); + const wallet = await identityManager.addLedgerWallet('', LEDGER_ADDRESS, LEDGER_PATH); + expect(wallet.name).toBe('Ledger 1'); + }); + + test('addLedgerWallet rejects duplicates and bad input', async () => { + seedMainWallet(); + await identityManager.addLedgerWallet('My Stax', LEDGER_ADDRESS, LEDGER_PATH); + + await expect(identityManager.addLedgerWallet('Again', LEDGER_ADDRESS.toLowerCase(), LEDGER_PATH)) + .rejects.toThrow(/already in your wallet list/); + await expect(identityManager.addLedgerWallet('Bad', '0x123', LEDGER_PATH)) + .rejects.toThrow('Invalid Ledger account address'); + // Mixed-case address with a broken EIP-55 checksum must be rejected too + await expect(identityManager.addLedgerWallet('Bad', LEDGER_ADDRESS.replace('9', 'a'), LEDGER_PATH)) + .rejects.toThrow('Invalid Ledger account address'); + await expect(identityManager.addLedgerWallet('Bad', '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', '')) + .rejects.toThrow('Missing derivation path'); + }); + + test('getDerivedWallets returns the stored device address without derivation', async () => { + seedMainWallet(); + await identityManager.addLedgerWallet('My Stax', LEDGER_ADDRESS, LEDGER_PATH); + + const wallets = await identityManager.getDerivedWallets(); + + expect(wallets).toEqual([ + expect.objectContaining({ index: 0, type: 'mnemonic' }), + expect.objectContaining({ index: 1, type: 'ledger', address: LEDGER_ADDRESS, path: LEDGER_PATH }), + ]); + }); + + test('getWalletRecord normalizes type and exposes the ledger path', async () => { + seedMainWallet(); + await identityManager.addLedgerWallet('My Stax', LEDGER_ADDRESS, LEDGER_PATH); + + expect(identityManager.getWalletRecord(0)).toMatchObject({ type: 'mnemonic' }); + expect(identityManager.getWalletRecord(1)).toMatchObject({ + type: 'ledger', + address: LEDGER_ADDRESS, + path: LEDGER_PATH, + }); + expect(identityManager.getWalletRecord(99)).toBeNull(); + }); + + test('getUserWalletKey refuses to derive for a ledger account', async () => { + seedMainWallet(); + await identityManager.addLedgerWallet('My Stax', LEDGER_ADDRESS, LEDGER_PATH); + + await expect(identityManager.getUserWalletKey(1)) + .rejects.toThrow('Hardware wallet accounts have no derivable private key'); + }); + + test('getActiveWalletAddress returns the device address for an active ledger account', async () => { + seedMainWallet(); + const wallet = await identityManager.addLedgerWallet('My Stax', LEDGER_ADDRESS, LEDGER_PATH); + await identityManager.setActiveWalletIndex(wallet.index); + + await expect(identityManager.getActiveWalletAddress()).resolves.toBe(LEDGER_ADDRESS); + }); +}); + /** * Regression guard for issue #90: Bee's restart after (re)injection is owned by * injectBeeIdentity via the lifecycle hook (stop → wipe → start), so Bee must diff --git a/src/main/index.js b/src/main/index.js index 105993e2..333115a8 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -180,6 +180,7 @@ const { const { registerIdentityIpc, hasVault, setBeeLifecycle } = require('./identity-manager'); const { registerQuickUnlockIpc } = require('./quick-unlock'); const { registerWalletIpc } = require('./wallet/wallet-ipc'); +const { registerLedgerIpc } = require('./wallet/ledger/ipc'); const { registerTokenRegistryIpc } = require('./token-registry'); const { registerRpcManagerIpc } = require('./wallet/rpc-manager'); const { registerNetworkConfigIpc } = require('./networks/network-ipc'); @@ -266,6 +267,7 @@ async function bootstrap() { registerIdentityIpc(); registerQuickUnlockIpc(); registerWalletIpc(); + registerLedgerIpc(); // Let identity (re)injection stop the Bee node before wiping its statestore // (which it holds a LevelDB lock on) and restart it with the new key. Without diff --git a/src/main/preload.js b/src/main/preload.js index 021249bc..f2da956b 100644 --- a/src/main/preload.js +++ b/src/main/preload.js @@ -410,6 +410,11 @@ contextBridge.exposeInMainWorld('wallet', { proxyRpc: (rpcUrl, method, params) => ipcRenderer.invoke('wallet:proxy-rpc', { rpcUrl, method, params }), }); +contextBridge.exposeInMainWorld('ledger', { + getAccounts: (options) => ipcRenderer.invoke('ledger:get-accounts', options), + addAccount: (name, address, path) => ipcRenderer.invoke('wallet:add-ledger-wallet', name, address, path), +}); + contextBridge.exposeInMainWorld('swarmNode', { getStamps: () => ipcRenderer.invoke('swarm:get-stamps'), getStorageCost: (sizeGB, durationDays) => ipcRenderer.invoke('swarm:get-storage-cost', sizeGB, durationDays), diff --git a/src/main/preload.test.js b/src/main/preload.test.js index e2c148ee..c0d9ea02 100644 --- a/src/main/preload.test.js +++ b/src/main/preload.test.js @@ -83,7 +83,7 @@ describe('preload', () => { beeApiEnv: 'http://127.0.0.1:1700', }); - expect(contextBridge.exposeInMainWorld).toHaveBeenCalledTimes(20); + expect(contextBridge.exposeInMainWorld).toHaveBeenCalledTimes(21); expect(Object.keys(exposures)).toEqual([ 'nodeConfig', 'internalPages', @@ -96,6 +96,7 @@ describe('preload', () => { 'identity', 'quickUnlock', 'wallet', + 'ledger', 'swarmNode', 'networks', 'payments', diff --git a/src/main/wallet/ledger/errors.js b/src/main/wallet/ledger/errors.js new file mode 100644 index 00000000..ed899621 --- /dev/null +++ b/src/main/wallet/ledger/errors.js @@ -0,0 +1,117 @@ +/** + * Ledger error mapping. + * + * Transport and app errors from `@ledgerhq/*` come as TransportStatusError + * (with a `statusCode` APDU status word), named transport errors, or plain + * node-hid failures. Map them to stable machine codes + user-facing + * messages so the renderer can drive the connect/sign UX ("unlock your + * Ledger", "open the Ethereum app", "request rejected on device") without + * string-matching library internals. + */ + +const LEDGER_ERROR_CODES = { + DEVICE_NOT_FOUND: 'LEDGER_DEVICE_NOT_FOUND', + DEVICE_LOCKED: 'LEDGER_DEVICE_LOCKED', + ETH_APP_NOT_OPEN: 'LEDGER_ETH_APP_NOT_OPEN', + USER_REJECTED: 'LEDGER_USER_REJECTED', + DISCONNECTED: 'LEDGER_DISCONNECTED', + BUSY: 'LEDGER_BUSY', + SIGNING_UNAVAILABLE: 'LEDGER_SIGNING_UNAVAILABLE', + UNKNOWN: 'LEDGER_UNKNOWN', +}; + +const MESSAGES = { + [LEDGER_ERROR_CODES.DEVICE_NOT_FOUND]: 'No Ledger device found. Connect it via USB and unlock it.', + [LEDGER_ERROR_CODES.DEVICE_LOCKED]: 'Ledger is locked. Unlock it with your PIN.', + [LEDGER_ERROR_CODES.ETH_APP_NOT_OPEN]: 'Open the Ethereum app on your Ledger.', + [LEDGER_ERROR_CODES.USER_REJECTED]: 'Request rejected on the Ledger device.', + [LEDGER_ERROR_CODES.DISCONNECTED]: 'Ledger was disconnected. Reconnect it and try again.', + [LEDGER_ERROR_CODES.BUSY]: 'Ledger is busy with another request. Finish or dismiss it on the device.', + [LEDGER_ERROR_CODES.SIGNING_UNAVAILABLE]: 'Ledger signing is not available yet in this build', + [LEDGER_ERROR_CODES.UNKNOWN]: 'Ledger error. Reconnect the device and try again.', +}; + +/** + * Mint an error from the code registry — the only way LEDGER_* errors + * should be created outside of `mapLedgerError`. + * + * @param {string} code - One of LEDGER_ERROR_CODES + * @returns {Error & {code: string}} + */ +function createLedgerError(code) { + const err = new Error(MESSAGES[code] || MESSAGES[LEDGER_ERROR_CODES.UNKNOWN]); + err.code = code; + return err; +} + +// APDU status words (TransportStatusError.statusCode) +const STATUS_TO_CODE = { + 0x5515: LEDGER_ERROR_CODES.DEVICE_LOCKED, + 0x6982: LEDGER_ERROR_CODES.DEVICE_LOCKED, // security not satisfied (locked mid-session) + 0x6985: LEDGER_ERROR_CODES.USER_REJECTED, // conditions of use not satisfied + 0x5501: LEDGER_ERROR_CODES.USER_REJECTED, // user refused on device + 0x6511: LEDGER_ERROR_CODES.ETH_APP_NOT_OPEN, // app not started + 0x6d00: LEDGER_ERROR_CODES.ETH_APP_NOT_OPEN, // INS not supported (wrong app) + 0x6e00: LEDGER_ERROR_CODES.ETH_APP_NOT_OPEN, // CLA not supported (wrong app) + 0x6e01: LEDGER_ERROR_CODES.ETH_APP_NOT_OPEN, +}; + +// Named errors from @ledgerhq/errors / node-hid +const NAME_TO_CODE = { + TransportOpenUserCancelled: LEDGER_ERROR_CODES.DEVICE_NOT_FOUND, + CantOpenDevice: LEDGER_ERROR_CODES.DEVICE_NOT_FOUND, + NoDeviceFound: LEDGER_ERROR_CODES.DEVICE_NOT_FOUND, + DisconnectedDevice: LEDGER_ERROR_CODES.DISCONNECTED, + DisconnectedDeviceDuringOperation: LEDGER_ERROR_CODES.DISCONNECTED, + TransportRaceCondition: LEDGER_ERROR_CODES.BUSY, + TransportInterfaceNotAvailable: LEDGER_ERROR_CODES.BUSY, + LockedDeviceError: LEDGER_ERROR_CODES.DEVICE_LOCKED, +}; + +/** + * Classify a raw `@ledgerhq/*` / node-hid error into a stable code. + * + * @param {unknown} err + * @returns {string} One of LEDGER_ERROR_CODES + */ +function classifyLedgerError(err) { + if (!err || typeof err !== 'object') return LEDGER_ERROR_CODES.UNKNOWN; + if (typeof err.statusCode === 'number' && STATUS_TO_CODE[err.statusCode]) { + return STATUS_TO_CODE[err.statusCode]; + } + if (err.name && NAME_TO_CODE[err.name]) { + return NAME_TO_CODE[err.name]; + } + const message = String(err.message || ''); + if (/cannot open device|no device/i.test(message)) { + return LEDGER_ERROR_CODES.DEVICE_NOT_FOUND; + } + if (/disconnected/i.test(message)) { + return LEDGER_ERROR_CODES.DISCONNECTED; + } + return LEDGER_ERROR_CODES.UNKNOWN; +} + +/** + * Wrap a raw Ledger error into an Error with a stable `.code` and a + * user-facing message. Errors that already carry a LEDGER_* code pass + * through unchanged. + * + * @param {unknown} err + * @returns {Error & {code: string}} + */ +function mapLedgerError(err) { + if (err && typeof err === 'object' && typeof err.code === 'string' && err.code.startsWith('LEDGER_')) { + return err; + } + const code = classifyLedgerError(err); + const mapped = new Error(MESSAGES[code], { cause: err }); + mapped.code = code; + return mapped; +} + +function isLedgerUserRejection(err) { + return mapLedgerError(err).code === LEDGER_ERROR_CODES.USER_REJECTED; +} + +module.exports = { LEDGER_ERROR_CODES, createLedgerError, mapLedgerError, isLedgerUserRejection }; diff --git a/src/main/wallet/ledger/errors.test.js b/src/main/wallet/ledger/errors.test.js new file mode 100644 index 00000000..0ae3f81e --- /dev/null +++ b/src/main/wallet/ledger/errors.test.js @@ -0,0 +1,73 @@ +const { LEDGER_ERROR_CODES, createLedgerError, mapLedgerError, isLedgerUserRejection } = require('./errors'); + +function statusError(statusCode) { + const err = new Error(`status ${statusCode.toString(16)}`); + err.statusCode = statusCode; + return err; +} + +function namedError(name) { + const err = new Error(name); + err.name = name; + return err; +} + +describe('mapLedgerError', () => { + test.each([ + [0x6985, LEDGER_ERROR_CODES.USER_REJECTED], + [0x5501, LEDGER_ERROR_CODES.USER_REJECTED], + [0x5515, LEDGER_ERROR_CODES.DEVICE_LOCKED], + [0x6982, LEDGER_ERROR_CODES.DEVICE_LOCKED], + [0x6511, LEDGER_ERROR_CODES.ETH_APP_NOT_OPEN], + [0x6d00, LEDGER_ERROR_CODES.ETH_APP_NOT_OPEN], + [0x6e00, LEDGER_ERROR_CODES.ETH_APP_NOT_OPEN], + ])('maps APDU status 0x%s to a stable code', (statusCode, expected) => { + const mapped = mapLedgerError(statusError(statusCode)); + expect(mapped.code).toBe(expected); + expect(mapped.message).not.toMatch(/status/); // user-facing, not raw + expect(mapped.cause).toBeDefined(); + }); + + test.each([ + ['CantOpenDevice', LEDGER_ERROR_CODES.DEVICE_NOT_FOUND], + ['DisconnectedDeviceDuringOperation', LEDGER_ERROR_CODES.DISCONNECTED], + ['TransportRaceCondition', LEDGER_ERROR_CODES.BUSY], + ['LockedDeviceError', LEDGER_ERROR_CODES.DEVICE_LOCKED], + ])('maps named transport error %s', (name, expected) => { + expect(mapLedgerError(namedError(name)).code).toBe(expected); + }); + + test('maps node-hid "cannot open device" message', () => { + expect(mapLedgerError(new Error('cannot open device with path X')).code) + .toBe(LEDGER_ERROR_CODES.DEVICE_NOT_FOUND); + }); + + test('unknown errors get the UNKNOWN code, never throw', () => { + expect(mapLedgerError(new Error('wat')).code).toBe(LEDGER_ERROR_CODES.UNKNOWN); + expect(mapLedgerError(undefined).code).toBe(LEDGER_ERROR_CODES.UNKNOWN); + expect(mapLedgerError('string').code).toBe(LEDGER_ERROR_CODES.UNKNOWN); + }); + + test('already-mapped errors pass through unchanged', () => { + const mapped = mapLedgerError(statusError(0x6985)); + expect(mapLedgerError(mapped)).toBe(mapped); + }); +}); + +describe('createLedgerError', () => { + test('mints an error with the registry code and message', () => { + const err = createLedgerError(LEDGER_ERROR_CODES.SIGNING_UNAVAILABLE); + expect(err.code).toBe('LEDGER_SIGNING_UNAVAILABLE'); + expect(err.message).toMatch(/not available/i); + // Round-trips through mapLedgerError unchanged (LEDGER_ prefix pass-through) + expect(mapLedgerError(err)).toBe(err); + }); +}); + +describe('isLedgerUserRejection', () => { + test('true only for user rejection', () => { + expect(isLedgerUserRejection(statusError(0x6985))).toBe(true); + expect(isLedgerUserRejection(statusError(0x6511))).toBe(false); + expect(isLedgerUserRejection(new Error('x'))).toBe(false); + }); +}); diff --git a/src/main/wallet/ledger/ipc.js b/src/main/wallet/ledger/ipc.js new file mode 100644 index 00000000..9013b6a2 --- /dev/null +++ b/src/main/wallet/ledger/ipc.js @@ -0,0 +1,29 @@ +/** + * Ledger IPC handlers. + * + * Device discovery for the "Connect hardware wallet" flow. Adding the + * chosen account to the wallet list goes through identity-manager's + * `wallet:add-ledger-wallet` handler, next to the other wallet-list + * mutations. + */ + +const { ipcMain } = require('electron'); +const { listAccounts } = require('./transport'); + +function registerLedgerIpc() { + // Requires an attached, unlocked device with the Ethereum app open; + // errors carry a stable LEDGER_* code the renderer turns into + // instructions ("plug it in", "open the app", …). The connect screen + // polls this while waiting for the user to get the device ready. + ipcMain.handle('ledger:get-accounts', async (_event, options = {}) => { + try { + const accounts = await listAccounts(options); + return { success: true, accounts }; + } catch (err) { + console.error('[LedgerIPC] Account discovery failed:', err.message); + return { success: false, error: err.message, code: err.code }; + } + }); +} + +module.exports = { registerLedgerIpc }; diff --git a/src/main/wallet/ledger/signer.js b/src/main/wallet/ledger/signer.js new file mode 100644 index 00000000..9c406ff4 --- /dev/null +++ b/src/main/wallet/ledger/signer.js @@ -0,0 +1,31 @@ +/** + * Ledger signing backend for the wallet signer factory. + * + * getAddress serves the address stored on the account record (read from + * the device when the account was added) — no device round-trip, no vault. + * + * Signing methods arrive with the device-confirmation flow (WP3); until + * then they fail closed with a stable code so approval UIs can explain + * instead of silently mis-signing. + */ + +const { LEDGER_ERROR_CODES, createLedgerError } = require('./errors'); + +function signingUnavailable() { + return Promise.reject(createLedgerError(LEDGER_ERROR_CODES.SIGNING_UNAVAILABLE)); +} + +/** + * @param {{address: string, path: string}} record - Ledger wallet record from vault-meta + * @returns {import('../signers').Signer} + */ +function createLedgerBackend(record) { + return { + getAddress: async () => record.address, + signTransaction: () => signingUnavailable(), + signMessage: () => signingUnavailable(), + signTypedData: () => signingUnavailable(), + }; +} + +module.exports = { createLedgerBackend }; diff --git a/src/main/wallet/ledger/transport.js b/src/main/wallet/ledger/transport.js new file mode 100644 index 00000000..d3924feb --- /dev/null +++ b/src/main/wallet/ledger/transport.js @@ -0,0 +1,105 @@ +/** + * Ledger device transport (main process). + * + * Wraps `@ledgerhq/hw-transport-node-hid` + `@ledgerhq/hw-app-eth` behind + * a small API. The device speaks one APDU exchange at a time, so all + * access is serialized through a queue — concurrent IPC calls (e.g. a + * dApp signing request racing account discovery) wait their turn instead + * of corrupting the exchange. + * + * The transport is opened per operation and closed afterwards: cheap, + * and it keeps the device usable by other apps (Ledger Live) between + * our calls. + */ + +const { mapLedgerError } = require('./errors'); + +// Lazy-required so the app doesn't pay node-hid's native-module load cost +// (or crash on unsupported platforms) until a Ledger feature is touched. +let TransportNodeHid = null; +let EthApp = null; +function loadLedgerLibs() { + if (!TransportNodeHid) { + TransportNodeHid = require('@ledgerhq/hw-transport-node-hid').default; + EthApp = require('@ledgerhq/hw-app-eth').default; + } +} + +// Derivation path schemes offered during account discovery. Paths are in +// device format (no leading "m/") — exactly what hw-app-eth consumes and +// what we persist on the account record. +const PATH_SCHEMES = { + live: { + label: 'Ledger Live', + buildPath: (i) => `44'/60'/${i}'/0/0`, + }, + legacy: { + label: 'Legacy (MEW / MyCrypto)', + buildPath: (i) => `44'/60'/0'/${i}`, + }, +}; + +let deviceQueue = Promise.resolve(); + +/** + * Run `task` with an open Ethereum app instance, serialized against all + * other device access. The transport is always closed afterwards. + * + * @template T + * @param {(eth: import('@ledgerhq/hw-app-eth').default) => Promise} task + * @returns {Promise} + */ +function withEthApp(task) { + const run = deviceQueue.then(async () => { + loadLedgerLibs(); + let transport; + try { + transport = await TransportNodeHid.open(''); + } catch (err) { + throw mapLedgerError(err); + } + try { + return await task(new EthApp(transport)); + } catch (err) { + throw mapLedgerError(err); + } finally { + await transport.close().catch(() => {}); + } + }); + // Keep the queue alive after failures; errors surface to the caller only. + deviceQueue = run.catch(() => {}); + return run; +} + +/** + * List addresses on the device for a derivation-path scheme. + * + * Requires the device to be unlocked with the Ethereum app open; + * otherwise rejects with a mapped LEDGER_* error the UI can act on. + * Addresses are NOT shown on the device screen during discovery + * (`display: false`) — verification on-device happens when the user + * confirms their first signature. + * + * @param {{scheme?: string, start?: number, count?: number}} [options] + * @returns {Promise>} + */ +async function listAccounts({ scheme = 'live', start = 0, count = 5 } = {}) { + const pathScheme = PATH_SCHEMES[scheme]; + if (!pathScheme) { + throw new Error(`Unknown derivation scheme: ${scheme}`); + } + const safeStart = Math.max(0, Math.trunc(start)); + const safeCount = Math.min(20, Math.max(1, Math.trunc(count))); + + return withEthApp(async (eth) => { + const accounts = []; + for (let i = safeStart; i < safeStart + safeCount; i++) { + const path = pathScheme.buildPath(i); + const { address } = await eth.getAddress(path, false); + accounts.push({ path, address }); + } + return accounts; + }); +} + +module.exports = { withEthApp, listAccounts, PATH_SCHEMES }; diff --git a/src/main/wallet/ledger/transport.test.js b/src/main/wallet/ledger/transport.test.js new file mode 100644 index 00000000..c39c0a6a --- /dev/null +++ b/src/main/wallet/ledger/transport.test.js @@ -0,0 +1,117 @@ +const mockGetAddress = jest.fn(); +const mockClose = jest.fn(async () => {}); +const mockOpen = jest.fn(); +const mockList = jest.fn(); + +jest.mock('@ledgerhq/hw-transport-node-hid', () => ({ + default: { + open: (...args) => mockOpen(...args), + list: (...args) => mockList(...args), + }, +})); +jest.mock('@ledgerhq/hw-app-eth', () => ({ + default: class MockEth { + constructor(transport) { + this.transport = transport; + } + getAddress(...args) { + return mockGetAddress(...args); + } + }, +})); + +const { listAccounts, withEthApp } = require('./transport'); +const { LEDGER_ERROR_CODES } = require('./errors'); + +beforeEach(() => { + mockOpen.mockReset().mockResolvedValue({ close: mockClose }); + mockList.mockReset(); + mockGetAddress.mockReset(); + mockClose.mockClear(); +}); + +describe('listAccounts', () => { + test('walks the Ledger Live path scheme without on-device display', async () => { + mockGetAddress.mockImplementation(async (path) => ({ address: `0xaddr:${path}` })); + + const accounts = await listAccounts({ scheme: 'live', start: 0, count: 3 }); + + expect(accounts).toEqual([ + { path: "44'/60'/0'/0/0", address: "0xaddr:44'/60'/0'/0/0" }, + { path: "44'/60'/1'/0/0", address: "0xaddr:44'/60'/1'/0/0" }, + { path: "44'/60'/2'/0/0", address: "0xaddr:44'/60'/2'/0/0" }, + ]); + expect(mockGetAddress).toHaveBeenCalledWith("44'/60'/0'/0/0", false); + expect(mockClose).toHaveBeenCalledTimes(1); + }); + + test('supports the legacy path scheme and paging', async () => { + mockGetAddress.mockImplementation(async (path) => ({ address: `0xaddr:${path}` })); + + const accounts = await listAccounts({ scheme: 'legacy', start: 5, count: 2 }); + + expect(accounts.map((a) => a.path)).toEqual(["44'/60'/0'/5", "44'/60'/0'/6"]); + }); + + test('rejects unknown schemes before touching the device', async () => { + await expect(listAccounts({ scheme: 'nope' })).rejects.toThrow('Unknown derivation scheme'); + expect(mockOpen).not.toHaveBeenCalled(); + }); + + test('maps app-not-open APDU errors and still closes the transport', async () => { + const apduError = new Error('0x6511'); + apduError.statusCode = 0x6511; + mockGetAddress.mockRejectedValue(apduError); + + await expect(listAccounts()).rejects.toMatchObject({ + code: LEDGER_ERROR_CODES.ETH_APP_NOT_OPEN, + }); + expect(mockClose).toHaveBeenCalledTimes(1); + }); + + test('maps transport-open failures to DEVICE_NOT_FOUND', async () => { + mockOpen.mockRejectedValue(new Error('cannot open device with path')); + await expect(listAccounts()).rejects.toMatchObject({ + code: LEDGER_ERROR_CODES.DEVICE_NOT_FOUND, + }); + }); +}); + +describe('withEthApp serialization', () => { + test('device operations run one at a time, in order', async () => { + const events = []; + let releaseFirst; + const firstGate = new Promise((resolve) => { + releaseFirst = resolve; + }); + + const first = withEthApp(async () => { + events.push('first:start'); + await firstGate; + events.push('first:end'); + return 1; + }); + const second = withEthApp(async () => { + events.push('second:start'); + return 2; + }); + + // Give the second task a chance to (incorrectly) start early. + await new Promise((resolve) => setImmediate(resolve)); + expect(events).toEqual(['first:start']); + + releaseFirst(); + await expect(Promise.all([first, second])).resolves.toEqual([1, 2]); + expect(events).toEqual(['first:start', 'first:end', 'second:start']); + }); + + test('a failed operation does not wedge the queue', async () => { + mockGetAddress.mockRejectedValueOnce(Object.assign(new Error('rejected'), { statusCode: 0x6985 })); + await expect(withEthApp((eth) => eth.getAddress('x', false))).rejects.toMatchObject({ + code: LEDGER_ERROR_CODES.USER_REJECTED, + }); + + mockGetAddress.mockResolvedValueOnce({ address: '0xok' }); + await expect(withEthApp((eth) => eth.getAddress('x', false))).resolves.toEqual({ address: '0xok' }); + }); +}); diff --git a/src/main/wallet/signers.js b/src/main/wallet/signers.js index a219e6fb..bd8a19df 100644 --- a/src/main/wallet/signers.js +++ b/src/main/wallet/signers.js @@ -2,9 +2,10 @@ * Signer factory. * * Resolves a wallet index to a signer object so callers never touch raw - * private keys. Today every account is vault-backed (mnemonic-derived); - * hardware-wallet account types plug in here by returning a different - * backend behind the same interface. + * private keys. The account's `type` (from vault-meta, see + * identity-manager's WALLET_TYPES) picks the backend: vault-backed + * mnemonic accounts sign locally with a borrowed key, Ledger accounts + * sign on the device. * * Input normalization (0x-hex personal messages → raw bytes, JSON-string * typed data → object) happens once in the factory, so backends always @@ -20,6 +21,8 @@ const { Wallet, computeAddress } = require('ethers'); const { withVaultPrivateKey, isValidWalletIndex } = require('./vault-access'); +const { getWalletRecord, WALLET_TYPES } = require('../identity-manager'); +const { createLedgerBackend } = require('./ledger/signer'); /** * @typedef {Object} Signer @@ -95,7 +98,13 @@ function getSigner(walletIndex) { throw new Error('Invalid wallet index'); } - const backend = createVaultBackend(walletIndex); + // Unknown indexes fall through to the vault backend, which fails with + // its own vault-derivation errors — the pre-hardware-wallet behaviour. + const record = getWalletRecord(walletIndex); + const backend = + record && record.type === WALLET_TYPES.LEDGER + ? createLedgerBackend(record) + : createVaultBackend(walletIndex); let address = null; return { diff --git a/src/main/wallet/signers.test.js b/src/main/wallet/signers.test.js index 6294ac47..b5e53625 100644 --- a/src/main/wallet/signers.test.js +++ b/src/main/wallet/signers.test.js @@ -10,8 +10,12 @@ const mockIdentity = { }; const mockResetVaultAutoLockTimer = jest.fn(); +const mockGetWalletRecord = jest.fn(); + jest.mock('../identity-manager', () => ({ loadIdentityModule: jest.fn(async () => mockIdentity), + getWalletRecord: (...args) => mockGetWalletRecord(...args), + WALLET_TYPES: { MNEMONIC: 'mnemonic', LEDGER: 'ledger' }, })); jest.mock('../vault-timer', () => ({ resetVaultAutoLockTimer: mockResetVaultAutoLockTimer, @@ -22,6 +26,7 @@ const { getSigner } = require('./signers'); beforeEach(() => { mockIdentity.isUnlocked.mockReset().mockReturnValue(true); mockIdentity.exportPrivateKey.mockReset().mockReturnValue(TEST_PRIVATE_KEY); + mockGetWalletRecord.mockReset().mockReturnValue({ index: 0, name: 'Main Wallet', type: 'mnemonic' }); mockResetVaultAutoLockTimer.mockClear(); }); @@ -120,4 +125,46 @@ describe('getSigner (vault-backed)', () => { await signer.signMessage('keep the vault alive'); expect(mockResetVaultAutoLockTimer).toHaveBeenCalledTimes(1); }); + + test('an unknown wallet record falls through to the vault backend', async () => { + mockGetWalletRecord.mockReturnValue(null); + const signer = getSigner(3); + await expect(signer.getAddress()).resolves.toBe(testWallet.address); + expect(mockIdentity.exportPrivateKey).toHaveBeenCalledWith(3); + }); +}); + +describe('getSigner (ledger-backed)', () => { + const LEDGER_RECORD = { + index: 2, + name: 'My Stax', + address: '0x209693Bc6afc0C5328bA36FaF03C514EF312287C', + type: 'ledger', + path: "44'/60'/0'/0/0", + }; + + beforeEach(() => { + mockGetWalletRecord.mockReturnValue(LEDGER_RECORD); + }); + + test('getAddress serves the stored device address without touching the vault', async () => { + const signer = getSigner(2); + await expect(signer.getAddress()).resolves.toBe(LEDGER_RECORD.address); + expect(mockIdentity.isUnlocked).not.toHaveBeenCalled(); + expect(mockIdentity.exportPrivateKey).not.toHaveBeenCalled(); + }); + + test('signing fails closed until device signing lands (never vault-signs)', async () => { + const signer = getSigner(2); + await expect(signer.signMessage('0x48656c6c6f')).rejects.toMatchObject({ + code: 'LEDGER_SIGNING_UNAVAILABLE', + }); + await expect(signer.signTransaction({ chainId: 1 })).rejects.toMatchObject({ + code: 'LEDGER_SIGNING_UNAVAILABLE', + }); + await expect(signer.signTypedData({ domain: {}, types: {}, message: {} })).rejects.toMatchObject({ + code: 'LEDGER_SIGNING_UNAVAILABLE', + }); + expect(mockIdentity.exportPrivateKey).not.toHaveBeenCalled(); + }); }); diff --git a/src/main/wallet/vault-access.js b/src/main/wallet/vault-access.js index 1aa9d70f..969d0f2b 100644 --- a/src/main/wallet/vault-access.js +++ b/src/main/wallet/vault-access.js @@ -16,7 +16,7 @@ * incidentally by the consolidation. */ -const { loadIdentityModule } = require('../identity-manager'); +const { loadIdentityModule, getWalletRecord, WALLET_TYPES } = require('../identity-manager'); const { resetVaultAutoLockTimer } = require('../vault-timer'); const { VAULT_LOCKED_MESSAGE } = require('./vault-errors'); @@ -54,6 +54,13 @@ async function withVaultPrivateKey(walletIndex, callback) { if (!isValidWalletIndex(walletIndex)) { throw new Error('Invalid wallet index'); } + // Hard stop for non-mnemonic accounts at the key-derivation chokepoint: + // deriving a mnemonic key at a hardware account's index would silently + // sign with a key whose address the user has never seen. + const record = getWalletRecord(walletIndex); + if (record && record.type !== WALLET_TYPES.MNEMONIC) { + throw new Error('Hardware wallet accounts have no vault key; sign via their device signer'); + } const identity = await loadIdentityModule(); if (!identity.isUnlocked()) { throw new Error(VAULT_LOCKED_MESSAGE); diff --git a/src/main/wallet/vault-access.test.js b/src/main/wallet/vault-access.test.js index 6e075695..9ce62d8d 100644 --- a/src/main/wallet/vault-access.test.js +++ b/src/main/wallet/vault-access.test.js @@ -3,9 +3,12 @@ const mockIdentity = { exportPrivateKey: jest.fn(), }; const mockResetVaultAutoLockTimer = jest.fn(); +const mockGetWalletRecord = jest.fn(); jest.mock('../identity-manager', () => ({ loadIdentityModule: jest.fn(async () => mockIdentity), + getWalletRecord: (...args) => mockGetWalletRecord(...args), + WALLET_TYPES: { MNEMONIC: 'mnemonic', LEDGER: 'ledger' }, })); jest.mock('../vault-timer', () => ({ resetVaultAutoLockTimer: mockResetVaultAutoLockTimer, @@ -22,6 +25,7 @@ beforeEach(() => { // test's tampering to leak into the next. mockIdentity.isUnlocked.mockReset().mockReturnValue(true); mockIdentity.exportPrivateKey.mockReset().mockReturnValue(TEST_KEY); + mockGetWalletRecord.mockReset().mockReturnValue(null); mockResetVaultAutoLockTimer.mockClear(); }); @@ -68,6 +72,15 @@ describe('withVaultPrivateKey', () => { expect(mockIdentity.exportPrivateKey).toHaveBeenCalledWith(3); }); + test('refuses to derive a vault key for a hardware-wallet index', async () => { + // The chokepoint guard: even a caller that bypasses the signer + // factory must never get a mnemonic key at a ledger account's index. + mockGetWalletRecord.mockReturnValue({ index: 3, type: 'ledger', address: '0xstax' }); + await expect(withVaultPrivateKey(3, () => 'unreachable')) + .rejects.toThrow('Hardware wallet accounts have no vault key'); + expect(mockIdentity.exportPrivateKey).not.toHaveBeenCalled(); + }); + test.each([ ['negative', -1], ['non-integer', 1.5], diff --git a/src/main/x402/client.test.js b/src/main/x402/client.test.js index 2d54352b..73844172 100644 --- a/src/main/x402/client.test.js +++ b/src/main/x402/client.test.js @@ -12,6 +12,8 @@ const mockResetVaultAutoLockTimer = jest.fn(); jest.mock('../identity-manager', () => ({ loadIdentityModule: jest.fn(async () => mockIdentity), + getWalletRecord: jest.fn(() => null), + WALLET_TYPES: { MNEMONIC: 'mnemonic', LEDGER: 'ledger' }, })); jest.mock('../vault-timer', () => ({ resetVaultAutoLockTimer: mockResetVaultAutoLockTimer, diff --git a/src/renderer/index.html b/src/renderer/index.html index b69189b8..7151a39c 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -835,6 +835,15 @@

Set Up Your Identity

Create New Wallet + @@ -1230,6 +1239,95 @@

Wallet Created!

+ + +