Skip to content

Commit ff18595

Browse files
committed
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.
1 parent 32688cd commit ff18595

6 files changed

Lines changed: 582 additions & 13 deletions

File tree

‎src/railgun-engine.ts‎

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
UnshieldStoredEvent,
3333
} from './models/event-types';
3434
import { ViewOnlyWallet } from './wallet/view-only-wallet';
35+
import { HardwareWallet, type ExternalSignerConnector } from './wallet/hardware-wallet';
3536
import { AbstractWallet } from './wallet/abstract-wallet';
3637
import WalletInfo from './wallet/wallet-info';
3738
import {
@@ -2115,14 +2116,42 @@ class RailgunEngine extends EventEmitter {
21152116
* @returns id
21162117
*/
21172118
async loadExistingViewOnlyWallet(encryptionKey: string, id: string): Promise<ViewOnlyWallet> {
2118-
if (isDefined(this.wallets[id])) {
2119-
return this.wallets[id] as ViewOnlyWallet;
2119+
const loadedWallet = this.wallets[id];
2120+
if (isDefined(loadedWallet)) {
2121+
if (loadedWallet instanceof ViewOnlyWallet) {
2122+
return loadedWallet;
2123+
}
2124+
this.unloadWallet(id);
21202125
}
21212126
const wallet = await ViewOnlyWallet.loadExisting(this.db, encryptionKey, id, this.prover);
21222127
await this.loadWallet(wallet);
21232128
return wallet;
21242129
}
21252130

2131+
async loadExistingHardwareWallet(
2132+
encryptionKey: string,
2133+
id: string,
2134+
connector: ExternalSignerConnector,
2135+
): Promise<HardwareWallet> {
2136+
const loadedWallet = this.wallets[id];
2137+
if (isDefined(loadedWallet)) {
2138+
if (loadedWallet instanceof HardwareWallet) {
2139+
loadedWallet.setConnector(connector);
2140+
return loadedWallet;
2141+
}
2142+
this.unloadWallet(id);
2143+
}
2144+
const wallet = await HardwareWallet.loadExisting(
2145+
this.db,
2146+
encryptionKey,
2147+
id,
2148+
this.prover,
2149+
);
2150+
wallet.setConnector(connector);
2151+
await this.loadWallet(wallet);
2152+
return wallet;
2153+
}
2154+
21262155
async deleteWallet(id: string) {
21272156
this.unloadWallet(id);
21282157
return AbstractWallet.delete(this.db, id);
@@ -2169,6 +2198,24 @@ class RailgunEngine extends EventEmitter {
21692198
return wallet;
21702199
}
21712200

2201+
async createHardwareWalletFromShareableViewingKey(
2202+
encryptionKey: string,
2203+
shareableViewingKey: string,
2204+
creationBlockNumbers: Optional<number[][]>,
2205+
connector: ExternalSignerConnector,
2206+
): Promise<HardwareWallet> {
2207+
const wallet = await HardwareWallet.fromShareableViewingKey(
2208+
this.db,
2209+
encryptionKey,
2210+
shareableViewingKey,
2211+
creationBlockNumbers,
2212+
this.prover,
2213+
);
2214+
wallet.setConnector(connector);
2215+
await this.loadWallet(wallet);
2216+
return wallet;
2217+
}
2218+
21722219
async getAllShieldCommitments(
21732220
txidVersion: TXIDVersion,
21742221
chain: Chain,

‎src/transaction/__tests__/transaction-erc20.test.ts‎

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { Database } from '../../database/database';
3333
import { AddressData } from '../../key-derivation/bech32';
3434
import { TransactNote } from '../../note/transact-note';
3535
import { Prover, SnarkJSGroth16 } from '../../prover/prover';
36+
import { type ExternalSignerConnector, HardwareWallet } from '../../wallet/hardware-wallet';
3637
import { RailgunWallet } from '../../wallet/railgun-wallet';
3738
import { config } from '../../test/config.test';
3839
import { hashBoundParamsV2, hashBoundParamsV3 } from '../bound-params';
@@ -851,6 +852,96 @@ describe('transaction-erc20', function test() {
851852
expect(signature).to.deep.equal(signEDDSA(privateKey, msg));
852853
});
853854

855+
it('Should request one hardware wallet batch approval and sign with the returned sub-session', async () => {
856+
transactionBatch.addOutput(await makeNote(1n));
857+
858+
const hardwareWallet = await HardwareWallet.fromShareableViewingKey(
859+
db,
860+
testEncryptionKey,
861+
wallet.generateShareableViewingKey(),
862+
undefined,
863+
prover,
864+
);
865+
await hardwareWallet.loadUTXOMerkletree(txidVersion, utxoMerkletree);
866+
await hardwareWallet.decryptBalances(txidVersion, chain, () => {}, false);
867+
await hardwareWallet.refreshPOIsForTXIDVersion(chain, txidVersion, true);
868+
869+
const signedSubSessions: Optional<string>[] = [];
870+
const expectedHashes: bigint[] = [];
871+
let batchApprovalCalls = 0;
872+
let approvedRequestCount = 0;
873+
const connector: ExternalSignerConnector = {
874+
requestBatchApproval: async (requests) => {
875+
batchApprovalCalls += 1;
876+
approvedRequestCount = requests.length;
877+
return 'batch-sub-session';
878+
},
879+
sign: async (expectedHash, _publicInputs, subSession) => {
880+
signedSubSessions.push(subSession);
881+
expectedHashes.push(expectedHash);
882+
return signEDDSA(
883+
(await wallet.getSpendingKeyPair(testEncryptionKey)).privateKey,
884+
expectedHash,
885+
);
886+
},
887+
};
888+
hardwareWallet.setConnector(connector);
889+
890+
const { provedTransactions } = await transactionBatch.generateTransactions(
891+
prover,
892+
hardwareWallet,
893+
txidVersion,
894+
testEncryptionKey,
895+
() => {},
896+
false,
897+
);
898+
899+
expect(provedTransactions).to.have.length(1);
900+
expect(batchApprovalCalls).to.equal(1);
901+
expect(approvedRequestCount).to.equal(1);
902+
expect(expectedHashes).to.have.length(1);
903+
expect(signedSubSessions).to.deep.equal(['batch-sub-session']);
904+
});
905+
906+
it('Should sign hardware wallet transactions without a batch approval sub-session when unsupported', async () => {
907+
transactionBatch.addOutput(await makeNote(1n));
908+
909+
const hardwareWallet = await HardwareWallet.fromShareableViewingKey(
910+
db,
911+
testEncryptionKey,
912+
wallet.generateShareableViewingKey(),
913+
undefined,
914+
prover,
915+
);
916+
await hardwareWallet.loadUTXOMerkletree(txidVersion, utxoMerkletree);
917+
await hardwareWallet.decryptBalances(txidVersion, chain, () => {}, false);
918+
await hardwareWallet.refreshPOIsForTXIDVersion(chain, txidVersion, true);
919+
920+
const signedSubSessions: Optional<string>[] = [];
921+
const connector: ExternalSignerConnector = {
922+
sign: async (expectedHash, _publicInputs, subSession) => {
923+
signedSubSessions.push(subSession);
924+
return signEDDSA(
925+
(await wallet.getSpendingKeyPair(testEncryptionKey)).privateKey,
926+
expectedHash,
927+
);
928+
},
929+
};
930+
hardwareWallet.setConnector(connector);
931+
932+
const { provedTransactions } = await transactionBatch.generateTransactions(
933+
prover,
934+
hardwareWallet,
935+
txidVersion,
936+
testEncryptionKey,
937+
() => {},
938+
false,
939+
);
940+
941+
expect(provedTransactions).to.have.length(1);
942+
expect(signedSubSessions).to.deep.equal([undefined]);
943+
});
944+
854945
it('Should generate validated inputs for transaction batch', async () => {
855946
transactionBatch.addOutput(await makeNote());
856947
const spendingSolutionGroups =

‎src/transaction/transaction-batch.ts‎

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,15 @@ import { stringifySafe } from '../utils/stringify';
1818
import { Chain } from '../models/engine-types';
1919
import { TransactNote } from '../note/transact-note';
2020
import {
21+
PrivateInputsRailgun,
2122
PreTransactionPOIsPerTxidLeafPerList,
23+
PublicInputsRailgun,
2224
TXIDVersion,
2325
TreeBalance,
2426
UnprovedTransactionInputs,
2527
} from '../models';
2628
import { getTokenDataHash } from '../note/note-util';
27-
import { AbstractWallet } from '../wallet';
29+
import { AbstractWallet, HardwareWallet } from '../wallet';
2830
import { BoundParamsStruct } from '../abi/typechain/RailgunSmartWallet';
2931
import { isDefined } from '../utils/is-defined';
3032
import { POI } from '../poi';
@@ -427,9 +429,16 @@ export class TransactionBatch {
427429
data: '0x', // TODO-V3: Add RelayAdapt encoded calldata
428430
};
429431

430-
for (let index = 0; index < transactionDatas.length; index += 1) {
431-
const { transaction, utxos, hasUnshield } = transactionDatas[index];
432+
const generatedRequests: {
433+
transaction: Transaction;
434+
utxos: TXO[];
435+
hasUnshield: boolean;
436+
publicInputs: PublicInputsRailgun;
437+
privateInputs: PrivateInputsRailgun;
438+
boundParams: BoundParamsStruct | PoseidonMerkleVerifier.BoundParamsStruct;
439+
}[] = [];
432440

441+
for (const { transaction, utxos, hasUnshield } of transactionDatas) {
433442
const { publicInputs, privateInputs, boundParams } =
434443
// eslint-disable-next-line no-await-in-loop
435444
await transaction.generateTransactionRequest(
@@ -439,8 +448,36 @@ export class TransactionBatch {
439448
globalBoundParams,
440449
);
441450

451+
generatedRequests.push({
452+
transaction,
453+
utxos,
454+
hasUnshield,
455+
publicInputs,
456+
privateInputs,
457+
boundParams,
458+
});
459+
}
460+
461+
let subSession: Optional<string>;
462+
if (wallet instanceof HardwareWallet) {
463+
subSession = await wallet.requestBatchApproval(generatedRequests);
464+
}
465+
466+
for (let index = 0; index < generatedRequests.length; index += 1) {
467+
const {
468+
transaction,
469+
utxos,
470+
hasUnshield,
471+
publicInputs,
472+
privateInputs,
473+
boundParams,
474+
} = generatedRequests[index];
475+
442476
// eslint-disable-next-line no-await-in-loop
443-
const signature = await wallet.sign(publicInputs, encryptionKey);
477+
const signature = await wallet.sign(
478+
publicInputs,
479+
wallet instanceof HardwareWallet ? (subSession ?? '') : encryptionKey,
480+
);
444481

445482
// Specific types per TXIDVersion
446483
let treeNumber: BigNumberish;

0 commit comments

Comments
 (0)