From ab6e0a9147e3a6890449f1083e694ffd4bf5e1de Mon Sep 17 00:00:00 2001 From: "zy0n.bear" Date: Mon, 11 May 2026 16:39:04 +0000 Subject: [PATCH] feat(wallet): hardware wallet via external signer connector HardwareWallet now extends RailgunWallet and delegates signing to a pluggable ExternalSignerConnector, with an optional batch-approval step that emits a sub-session string forwarded to each per-transaction sign call. AbstractWallet, RailgunWallet typing on TransactionBatch, and ViewOnlyWallet are intentionally left untouched; the hardware wallet stores its own spendingPublicKey reference to avoid leaning on private base-class state. RailgunEngine gains createHardwareWalletFromShareableViewingKey and loadExistingHardwareWallet, mirroring the view-only flow and unloading any conflicting wallet type on the same ID before reattaching the connector. --- src/railgun-engine.ts | 51 +++- .../__tests__/transaction-erc20.test.ts | 91 +++++++ src/transaction/transaction-batch.ts | 45 ++- src/wallet/__tests__/hardware-wallet.test.ts | 256 ++++++++++++++++++ src/wallet/hardware-wallet.ts | 151 ++++++++++- src/wallet/index.ts | 1 + 6 files changed, 582 insertions(+), 13 deletions(-) create mode 100644 src/wallet/__tests__/hardware-wallet.test.ts diff --git a/src/railgun-engine.ts b/src/railgun-engine.ts index 08fafc56..c2836c07 100644 --- a/src/railgun-engine.ts +++ b/src/railgun-engine.ts @@ -32,6 +32,7 @@ import { UnshieldStoredEvent, } from './models/event-types'; import { ViewOnlyWallet } from './wallet/view-only-wallet'; +import { HardwareWallet, type ExternalSignerConnector } from './wallet/hardware-wallet'; import { AbstractWallet } from './wallet/abstract-wallet'; import WalletInfo from './wallet/wallet-info'; import { @@ -2115,14 +2116,42 @@ class RailgunEngine extends EventEmitter { * @returns id */ async loadExistingViewOnlyWallet(encryptionKey: string, id: string): Promise { - if (isDefined(this.wallets[id])) { - return this.wallets[id] as ViewOnlyWallet; + const loadedWallet = this.wallets[id]; + if (isDefined(loadedWallet)) { + if (loadedWallet instanceof ViewOnlyWallet) { + return loadedWallet; + } + this.unloadWallet(id); } const wallet = await ViewOnlyWallet.loadExisting(this.db, encryptionKey, id, this.prover); await this.loadWallet(wallet); return wallet; } + async loadExistingHardwareWallet( + encryptionKey: string, + id: string, + connector: ExternalSignerConnector, + ): Promise { + const loadedWallet = this.wallets[id]; + if (isDefined(loadedWallet)) { + if (loadedWallet instanceof HardwareWallet) { + loadedWallet.setConnector(connector); + return loadedWallet; + } + this.unloadWallet(id); + } + const wallet = await HardwareWallet.loadExisting( + this.db, + encryptionKey, + id, + this.prover, + ); + wallet.setConnector(connector); + await this.loadWallet(wallet); + return wallet; + } + async deleteWallet(id: string) { this.unloadWallet(id); return AbstractWallet.delete(this.db, id); @@ -2169,6 +2198,24 @@ class RailgunEngine extends EventEmitter { return wallet; } + async createHardwareWalletFromShareableViewingKey( + encryptionKey: string, + shareableViewingKey: string, + creationBlockNumbers: Optional, + connector: ExternalSignerConnector, + ): Promise { + const wallet = await HardwareWallet.fromShareableViewingKey( + this.db, + encryptionKey, + shareableViewingKey, + creationBlockNumbers, + this.prover, + ); + wallet.setConnector(connector); + await this.loadWallet(wallet); + return wallet; + } + async getAllShieldCommitments( txidVersion: TXIDVersion, chain: Chain, diff --git a/src/transaction/__tests__/transaction-erc20.test.ts b/src/transaction/__tests__/transaction-erc20.test.ts index 09660670..3f8c8380 100644 --- a/src/transaction/__tests__/transaction-erc20.test.ts +++ b/src/transaction/__tests__/transaction-erc20.test.ts @@ -33,6 +33,7 @@ import { Database } from '../../database/database'; import { AddressData } from '../../key-derivation/bech32'; import { TransactNote } from '../../note/transact-note'; import { Prover, SnarkJSGroth16 } from '../../prover/prover'; +import { type ExternalSignerConnector, HardwareWallet } from '../../wallet/hardware-wallet'; import { RailgunWallet } from '../../wallet/railgun-wallet'; import { config } from '../../test/config.test'; import { hashBoundParamsV2, hashBoundParamsV3 } from '../bound-params'; @@ -851,6 +852,96 @@ describe('transaction-erc20', function test() { expect(signature).to.deep.equal(signEDDSA(privateKey, msg)); }); + it('Should request one hardware wallet batch approval and sign with the returned sub-session', async () => { + transactionBatch.addOutput(await makeNote(1n)); + + const hardwareWallet = await HardwareWallet.fromShareableViewingKey( + db, + testEncryptionKey, + wallet.generateShareableViewingKey(), + undefined, + prover, + ); + await hardwareWallet.loadUTXOMerkletree(txidVersion, utxoMerkletree); + await hardwareWallet.decryptBalances(txidVersion, chain, () => {}, false); + await hardwareWallet.refreshPOIsForTXIDVersion(chain, txidVersion, true); + + const signedSubSessions: Optional[] = []; + const expectedHashes: bigint[] = []; + let batchApprovalCalls = 0; + let approvedRequestCount = 0; + const connector: ExternalSignerConnector = { + requestBatchApproval: async (requests) => { + batchApprovalCalls += 1; + approvedRequestCount = requests.length; + return 'batch-sub-session'; + }, + sign: async (expectedHash, _publicInputs, subSession) => { + signedSubSessions.push(subSession); + expectedHashes.push(expectedHash); + return signEDDSA( + (await wallet.getSpendingKeyPair(testEncryptionKey)).privateKey, + expectedHash, + ); + }, + }; + hardwareWallet.setConnector(connector); + + const { provedTransactions } = await transactionBatch.generateTransactions( + prover, + hardwareWallet, + txidVersion, + testEncryptionKey, + () => {}, + false, + ); + + expect(provedTransactions).to.have.length(1); + expect(batchApprovalCalls).to.equal(1); + expect(approvedRequestCount).to.equal(1); + expect(expectedHashes).to.have.length(1); + expect(signedSubSessions).to.deep.equal(['batch-sub-session']); + }); + + it('Should sign hardware wallet transactions without a batch approval sub-session when unsupported', async () => { + transactionBatch.addOutput(await makeNote(1n)); + + const hardwareWallet = await HardwareWallet.fromShareableViewingKey( + db, + testEncryptionKey, + wallet.generateShareableViewingKey(), + undefined, + prover, + ); + await hardwareWallet.loadUTXOMerkletree(txidVersion, utxoMerkletree); + await hardwareWallet.decryptBalances(txidVersion, chain, () => {}, false); + await hardwareWallet.refreshPOIsForTXIDVersion(chain, txidVersion, true); + + const signedSubSessions: Optional[] = []; + const connector: ExternalSignerConnector = { + sign: async (expectedHash, _publicInputs, subSession) => { + signedSubSessions.push(subSession); + return signEDDSA( + (await wallet.getSpendingKeyPair(testEncryptionKey)).privateKey, + expectedHash, + ); + }, + }; + hardwareWallet.setConnector(connector); + + const { provedTransactions } = await transactionBatch.generateTransactions( + prover, + hardwareWallet, + txidVersion, + testEncryptionKey, + () => {}, + false, + ); + + expect(provedTransactions).to.have.length(1); + expect(signedSubSessions).to.deep.equal([undefined]); + }); + it('Should generate validated inputs for transaction batch', async () => { transactionBatch.addOutput(await makeNote()); const spendingSolutionGroups = diff --git a/src/transaction/transaction-batch.ts b/src/transaction/transaction-batch.ts index c98b78bb..7ef92c55 100644 --- a/src/transaction/transaction-batch.ts +++ b/src/transaction/transaction-batch.ts @@ -18,13 +18,15 @@ import { stringifySafe } from '../utils/stringify'; import { Chain } from '../models/engine-types'; import { TransactNote } from '../note/transact-note'; import { + PrivateInputsRailgun, PreTransactionPOIsPerTxidLeafPerList, + PublicInputsRailgun, TXIDVersion, TreeBalance, UnprovedTransactionInputs, } from '../models'; import { getTokenDataHash } from '../note/note-util'; -import { AbstractWallet } from '../wallet'; +import { AbstractWallet, HardwareWallet } from '../wallet'; import { BoundParamsStruct } from '../abi/typechain/RailgunSmartWallet'; import { isDefined } from '../utils/is-defined'; import { POI } from '../poi'; @@ -427,9 +429,16 @@ export class TransactionBatch { data: '0x', // TODO-V3: Add RelayAdapt encoded calldata }; - for (let index = 0; index < transactionDatas.length; index += 1) { - const { transaction, utxos, hasUnshield } = transactionDatas[index]; + const generatedRequests: { + transaction: Transaction; + utxos: TXO[]; + hasUnshield: boolean; + publicInputs: PublicInputsRailgun; + privateInputs: PrivateInputsRailgun; + boundParams: BoundParamsStruct | PoseidonMerkleVerifier.BoundParamsStruct; + }[] = []; + for (const { transaction, utxos, hasUnshield } of transactionDatas) { const { publicInputs, privateInputs, boundParams } = // eslint-disable-next-line no-await-in-loop await transaction.generateTransactionRequest( @@ -439,8 +448,36 @@ export class TransactionBatch { globalBoundParams, ); + generatedRequests.push({ + transaction, + utxos, + hasUnshield, + publicInputs, + privateInputs, + boundParams, + }); + } + + let subSession: Optional; + if (wallet instanceof HardwareWallet) { + subSession = await wallet.requestBatchApproval(generatedRequests); + } + + for (let index = 0; index < generatedRequests.length; index += 1) { + const { + transaction, + utxos, + hasUnshield, + publicInputs, + privateInputs, + boundParams, + } = generatedRequests[index]; + // eslint-disable-next-line no-await-in-loop - const signature = await wallet.sign(publicInputs, encryptionKey); + const signature = await wallet.sign( + publicInputs, + wallet instanceof HardwareWallet ? (subSession ?? '') : encryptionKey, + ); // Specific types per TXIDVersion let treeNumber: BigNumberish; diff --git a/src/wallet/__tests__/hardware-wallet.test.ts b/src/wallet/__tests__/hardware-wallet.test.ts new file mode 100644 index 00000000..9685b9ae --- /dev/null +++ b/src/wallet/__tests__/hardware-wallet.test.ts @@ -0,0 +1,256 @@ +import chai from 'chai'; +import memdown from 'memdown'; +import { afterEach, beforeEach, describe, it } from 'mocha'; +import type { Prover } from '../../prover/prover'; +import { Database } from '../../database/database'; +import { RailgunEngine } from '../../railgun-engine'; +import { type PublicInputsRailgun } from '../../models'; +import type { ArtifactGetter } from '../../models/prover-types'; +import { HardwareWallet, type ExternalSignerConnector } from '../hardware-wallet'; +import { ViewOnlyWallet } from '../view-only-wallet'; + +const { expect } = chai; + +const testArtifactGetter: ArtifactGetter = { + assertArtifactExists: () => {}, + getArtifacts: async () => { + throw new Error('Artifacts not used in hardware wallet create/load tests.'); + }, + getArtifactsPOI: async () => { + throw new Error('POI artifacts not used in hardware wallet create/load tests.'); + }, +}; + +const testEncryptionKey = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; +const testSharedViewingKey = '82a57670726976d94034326232623861643234306331323630396633623265363865656137613636373330306437373332633335346238373338343266373433313135313836303066a473707562d94061316166356531353935616330303736303734646465653034323737356230363365366434653666313966613632633333323935636336643363646635313165'; + +describe('hardware-wallet', () => { + let db: Database; + let wallet: HardwareWallet; + + beforeEach(async () => { + db = new Database(memdown()); + wallet = await HardwareWallet.fromShareableViewingKey( + db, + testEncryptionKey, + testSharedViewingKey, + undefined, + {} as Prover, + ); + }); + + afterEach(async () => { + await db.close(); + }); + + it('delegates signing to the external signer connector', async () => { + const publicInputs: PublicInputsRailgun = { + merkleRoot: 11n, + boundParamsHash: 12n, + nullifiers: [13n, 14n], + commitmentsOut: [15n, 16n], + }; + const signature = { + R8: [21n, 22n] as [bigint, bigint], + S: 23n, + }; + const received: { + expectedHash?: bigint; + publicInputs?: PublicInputsRailgun; + subSession?: string; + } = {}; + + const connector: ExternalSignerConnector = { + sign: async (expectedHash, connectorPublicInputs, connectorSubSession) => { + received.expectedHash = expectedHash; + received.publicInputs = connectorPublicInputs; + received.subSession = connectorSubSession; + return signature; + }, + }; + + wallet.setConnector(connector); + + const result = await wallet.sign(publicInputs, 'batch-sub-session'); + + expect(result).to.deep.equal(signature); + expect(received.expectedHash).to.be.a('bigint'); + expect(received.publicInputs).to.deep.equal(publicInputs); + expect(received.subSession).to.equal('batch-sub-session'); + }); + + it('treats batch approval as optional', async () => { + wallet.setConnector({ + sign: async () => ({ R8: [1n, 2n], S: 3n }), + }); + + const result = await wallet.requestBatchApproval([]); + + expect(result).to.equal(undefined); + }); + + it('delegates batch approval when the connector provides it', async () => { + const requests = [{ transaction: {} }] as const; + let capturedRequests: readonly unknown[] | undefined; + + wallet.setConnector({ + sign: async () => ({ R8: [1n, 2n], S: 3n }), + requestBatchApproval: async (connectorRequests) => { + capturedRequests = connectorRequests; + return 'batch-sub-session'; + }, + }); + + const result = await wallet.requestBatchApproval(requests); + + expect(result).to.equal('batch-sub-session'); + expect(capturedRequests).to.equal(requests); + }); + + it('creates and loads hardware wallets through RailgunEngine', async () => { + const connector: ExternalSignerConnector = { + sign: async () => ({ R8: [1n, 2n], S: 3n }), + }; + const engine = await RailgunEngine.initForWallet( + 'test wallet', + memdown(), + testArtifactGetter, + async () => ({ + commitmentEvents: [], + unshieldEvents: [], + nullifierEvents: [], + }), + async () => [], + async () => true, + async () => ({ txidIndex: undefined, merkleroot: undefined }), + undefined, + false, + ); + + try { + const createdWallet = await engine.createHardwareWalletFromShareableViewingKey( + testEncryptionKey, + testSharedViewingKey, + undefined, + connector, + ); + + expect(createdWallet.id).to.equal(wallet.id); + + engine.unloadWallet(createdWallet.id); + + const loadedWallet = await engine.loadExistingHardwareWallet( + testEncryptionKey, + createdWallet.id, + connector, + ); + + expect(loadedWallet.id).to.equal(createdWallet.id); + expect(await loadedWallet.sign({ + merkleRoot: 1n, + boundParamsHash: 2n, + nullifiers: [3n], + commitmentsOut: [4n], + }, '')).to.deep.equal({ R8: [1n, 2n], S: 3n }); + } finally { + await engine.db.close(); + } + }); + + it('refreshes the connector when reloading an already loaded hardware wallet', async () => { + const firstConnector: ExternalSignerConnector = { + sign: async () => ({ R8: [1n, 2n], S: 3n }), + }; + const secondConnector: ExternalSignerConnector = { + sign: async () => ({ R8: [4n, 5n], S: 6n }), + }; + const engine = await RailgunEngine.initForWallet( + 'test wallet', + memdown(), + testArtifactGetter, + async () => ({ + commitmentEvents: [], + unshieldEvents: [], + nullifierEvents: [], + }), + async () => [], + async () => true, + async () => ({ txidIndex: undefined, merkleroot: undefined }), + undefined, + false, + ); + + try { + const createdWallet = await engine.createHardwareWalletFromShareableViewingKey( + testEncryptionKey, + testSharedViewingKey, + undefined, + firstConnector, + ); + + const loadedWallet = await engine.loadExistingHardwareWallet( + testEncryptionKey, + createdWallet.id, + secondConnector, + ); + + expect(loadedWallet).to.equal(createdWallet); + expect(await loadedWallet.sign({ + merkleRoot: 1n, + boundParamsHash: 2n, + nullifiers: [3n], + commitmentsOut: [4n], + }, '')).to.deep.equal({ R8: [4n, 5n], S: 6n }); + } finally { + await engine.db.close(); + } + }); + + it('replaces a loaded view-only wallet when loading the hardware wallet for the same ID', async () => { + const connector: ExternalSignerConnector = { + sign: async () => ({ R8: [7n, 8n], S: 9n }), + }; + const engine = await RailgunEngine.initForWallet( + 'test wallet', + memdown(), + testArtifactGetter, + async () => ({ + commitmentEvents: [], + unshieldEvents: [], + nullifierEvents: [], + }), + async () => [], + async () => true, + async () => ({ txidIndex: undefined, merkleroot: undefined }), + undefined, + false, + ); + + try { + const viewOnlyWallet = await engine.createViewOnlyWalletFromShareableViewingKey( + testEncryptionKey, + testSharedViewingKey, + undefined, + ); + + expect(viewOnlyWallet).to.be.instanceOf(ViewOnlyWallet); + + const loadedWallet = await engine.loadExistingHardwareWallet( + testEncryptionKey, + viewOnlyWallet.id, + connector, + ); + + expect(loadedWallet).to.be.instanceOf(HardwareWallet); + expect(loadedWallet.id).to.equal(viewOnlyWallet.id); + expect(await loadedWallet.sign({ + merkleRoot: 1n, + boundParamsHash: 2n, + nullifiers: [3n], + commitmentsOut: [4n], + }, '')).to.deep.equal({ R8: [7n, 8n], S: 9n }); + } finally { + await engine.db.close(); + } + }); +}); \ No newline at end of file diff --git a/src/wallet/hardware-wallet.ts b/src/wallet/hardware-wallet.ts index 86735afa..40127a97 100644 --- a/src/wallet/hardware-wallet.ts +++ b/src/wallet/hardware-wallet.ts @@ -1,12 +1,149 @@ import { Signature } from '@railgun-community/circomlibjs'; -import { PublicInputsRailgun } from '../models'; -import { ViewOnlyWallet } from './view-only-wallet'; +import { PublicInputsRailgun, type ViewOnlyWalletData } from '../models'; +import { Database } from '../database/database'; +import { SpendingKeyPair, SpendingPublicKey, ViewingKeyPair } from '../key-derivation/wallet-node'; +import { ByteUtils } from '../utils/bytes'; +import { sha256 } from '../utils/hash'; +import { getPublicViewingKey } from '../utils/keys-utils'; +import { AbstractWallet } from './abstract-wallet'; +import { RailgunWallet } from './railgun-wallet'; +import { Prover } from '../prover/prover'; +import { isDefined } from '../utils/is-defined'; +import { poseidon } from '../utils/poseidon'; -class HardwareWallet extends ViewOnlyWallet { - // eslint-disable-next-line @typescript-eslint/no-unused-vars, class-methods-use-this - async sign(_publicInputs: PublicInputsRailgun, _encryptionKey: string): Promise { - throw new Error('Signer not implemented for hardware wallet.'); +export type ExternalSignerConnectorSignFn = ( + expectedHash: bigint, + publicInputs?: PublicInputsRailgun, + subSession?: string, +) => Promise; + +export type ExternalSignerConnector = { + sign: ExternalSignerConnectorSignFn; + requestBatchApproval?: ( + requests: readonly unknown[], + ) => Promise; +}; + +class HardwareWallet extends RailgunWallet { + private connector: ExternalSignerConnector | undefined; + + private readonly storedSpendingPublicKey: SpendingPublicKey; + + constructor( + id: string, + db: Database, + viewingKeyPair: ViewingKeyPair, + spendingPublicKey: SpendingPublicKey, + creationBlockNumbers: Optional, + prover: Prover, + ) { + super(id, db, viewingKeyPair, spendingPublicKey, creationBlockNumbers, prover); + this.storedSpendingPublicKey = spendingPublicKey; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async getSpendingKeyPair(_encryptionKey: string): Promise { + return { + privateKey: new Uint8Array(32), + pubkey: this.storedSpendingPublicKey, + }; + } + + setConnector(connector: ExternalSignerConnector) { + this.connector = connector; + } + + async requestBatchApproval( + requests: readonly unknown[], + ): Promise> { + return this.connector?.requestBatchApproval?.(requests); + } + + async sign(publicInputs: PublicInputsRailgun, subSession: string): Promise { + if (!isDefined(this.connector)) { + throw new Error('External signer connector not initialized.'); + } + const expectedHash = poseidon([ + publicInputs.merkleRoot, + publicInputs.boundParamsHash, + ...publicInputs.nullifiers, + ...publicInputs.commitmentsOut, + ]); + return this.connector.sign( + expectedHash, + publicInputs, + subSession.length ? subSession : undefined, + ); + } + + private static generateHardwareID(shareableViewingKey: string): string { + return sha256(shareableViewingKey); + } + + private static async getHardwareViewingKeyPair(viewingPrivateKey: string): Promise { + const vpk = ByteUtils.hexStringToBytes(viewingPrivateKey); + return { + privateKey: vpk, + pubkey: await getPublicViewingKey(vpk), + }; + } + + private static async createHardwareWallet( + id: string, + db: Database, + shareableViewingKey: string, + creationBlockNumbers: Optional, + prover: Prover, + ) { + const { viewingPrivateKey, spendingPublicKey } = + AbstractWallet.getKeysFromShareableViewingKey(shareableViewingKey); + const viewingKeyPair: ViewingKeyPair = await HardwareWallet.getHardwareViewingKeyPair( + viewingPrivateKey, + ); + return new HardwareWallet( + id, + db, + viewingKeyPair, + spendingPublicKey, + creationBlockNumbers, + prover, + ); + } + + static async fromShareableViewingKey( + db: Database, + encryptionKey: string, + shareableViewingKey: string, + creationBlockNumbers: Optional, + prover: Prover, + ): Promise { + const id = HardwareWallet.generateHardwareID(shareableViewingKey); + await AbstractWallet.write(db, id, encryptionKey, { + shareableViewingKey, + creationBlockNumbers, + }); + return this.createHardwareWallet(id, db, shareableViewingKey, creationBlockNumbers, prover); + } + + static async loadExisting( + db: Database, + encryptionKey: string, + id: string, + prover: Prover, + ): Promise { + const { shareableViewingKey, creationBlockNumbers } = (await AbstractWallet.read( + db, + id, + encryptionKey, + )) as ViewOnlyWalletData; + if (!shareableViewingKey) { + throw new Error( + 'Incorrect wallet type: Hardware wallet requires stored shareableViewingKey.', + ); + } + + return this.createHardwareWallet(id, db, shareableViewingKey, creationBlockNumbers, prover); } } -export { HardwareWallet }; +export { HardwareWallet }; \ No newline at end of file diff --git a/src/wallet/index.ts b/src/wallet/index.ts index 94b31099..f3070d25 100644 --- a/src/wallet/index.ts +++ b/src/wallet/index.ts @@ -1,4 +1,5 @@ // Note: we purposefully do not export everything, in order to reduce the number of public APIs export * from './abstract-wallet'; +export * from './hardware-wallet'; export * from './railgun-wallet'; export * from './view-only-wallet';