From 74665341dfe611d3475f8777b39462d0076692ea Mon Sep 17 00:00:00 2001 From: Daniel Peng Date: Fri, 5 Jun 2026 11:54:46 -0400 Subject: [PATCH 1/2] feat: external signer callback for multisig akm wallet gen Ticket: WCN-685 --- modules/sdk-core/src/bitgo/wallet/iWallets.ts | 22 ++ modules/sdk-core/src/bitgo/wallet/wallets.ts | 163 ++++++++++++- .../bitgo/wallet/walletsExternalSigner.ts | 221 ++++++++++++++++++ 3 files changed, 405 insertions(+), 1 deletion(-) create mode 100644 modules/sdk-core/test/unit/bitgo/wallet/walletsExternalSigner.ts diff --git a/modules/sdk-core/src/bitgo/wallet/iWallets.ts b/modules/sdk-core/src/bitgo/wallet/iWallets.ts index ac82fc56bd..5aecb03edc 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallets.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallets.ts @@ -63,6 +63,19 @@ export interface GenerateSMCMpcWalletOptions extends GenerateBaseMpcWalletOption coldDerivationSeed?: string; } +export interface CreateKeychainCallbackParams { + source: 'user' | 'backup'; + coin: string; +} + +export interface CreateKeychainCallbackResult { + pub: string; + type: string; + source: string; +} + +export type CreateKeychainCallback = (params: CreateKeychainCallbackParams) => Promise; + export interface GenerateWalletOptions { label?: string; passphrase?: string; @@ -95,6 +108,14 @@ export interface GenerateWalletOptions { /** Optional WebAuthn PRF-based encryption info. When provided, the user private key is additionally encrypted with the PRF-derived passphrase so the server can store a WebAuthn-protected copy. */ webauthnInfo?: WebauthnKeyEncryptionInfo; encryptionVersion?: EncryptionVersion; + /** Delegates user/backup key creation to an external signer (onchain multisig only). */ + createKeychainCallback?: CreateKeychainCallback; +} + +export interface GenerateWalletWithExternalSignerOptions + extends Omit { + label: string; + createKeychainCallback: CreateKeychainCallback; } export const GenerateLightningWalletOptionsCodec = t.intersection( @@ -281,6 +302,7 @@ export interface IWallets { generateWallet( params?: GenerateWalletOptions ): Promise; + generateWalletWithExternalSigner(params: GenerateWalletWithExternalSignerOptions): Promise; listShares(params?: Record): Promise; getShare(params?: { walletShareId?: string }): Promise; updateShare(params?: UpdateShareOptions): Promise; diff --git a/modules/sdk-core/src/bitgo/wallet/wallets.ts b/modules/sdk-core/src/bitgo/wallet/wallets.ts index 2a6916707b..9d1c12c899 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallets.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallets.ts @@ -12,7 +12,7 @@ import * as common from '../../common'; import { IBaseCoin, KeychainsTriplet, SupplementGenerateWalletOptions } from '../baseCoin'; import { BitGoBase } from '../bitgoBase'; import { getSharedSecret } from '../ecdh'; -import { AddKeychainOptions, Keychain, KeyIndices } from '../keychain'; +import { AddKeychainOptions, Keychain, KeyIndices, KeyType } from '../keychain'; import { decodeOrElse, promiseProps, RequestTracer } from '../utils'; import { AcceptShareOptions, @@ -31,6 +31,7 @@ import { GenerateMpcWalletOptions, GenerateSMCMpcWalletOptions, GenerateWalletOptions, + GenerateWalletWithExternalSignerOptions, GetWalletByAddressOptions, GetWalletOptions, GoAccountWalletWithUserKeychain, @@ -360,6 +361,10 @@ export class Wallets implements IWallets { throw new Error('missing required string parameter label'); } + if (params.createKeychainCallback) { + return this.generateWalletWithExternalSigner(params as GenerateWalletWithExternalSignerOptions); + } + const { type = 'hot', label, passphrase, enterprise, isDistributedCustody, evmKeyRingReferenceWalletId } = params; const isTss = params.multisigType === 'tss' && this.baseCoin.supportsTss(); const canEncrypt = !!passphrase && typeof passphrase === 'string'; @@ -718,6 +723,162 @@ export class Wallets implements IWallets { } } + /** + * Generate an onchain multisig wallet using an external signer for user and backup key creation. + * 1. Calls createKeychainCallback for user and backup keys + * 2. Uploads keychains via keychains().add() + * 3. Creates the BitGo key on the service + * 4. Creates the wallet on BitGo with the 3 public keys + * @param params + */ + async generateWalletWithExternalSigner( + params: GenerateWalletWithExternalSignerOptions + ): Promise { + if (!_.isFunction(params.createKeychainCallback)) { + throw new Error('missing required function parameter createKeychainCallback'); + } + + const multisigType = params.multisigType ?? this.baseCoin.getDefaultMultisigType(); + if (multisigType !== 'onchain') { + throw new Error('external signer wallet generation is only supported for onchain multisig wallets'); + } + + const conflictingParams = ['passphrase', 'userKey', 'backupXpub', 'backupXpubProvider'] as const; + for (const key of conflictingParams) { + if (!_.isUndefined(params[key])) { + throw new Error(`createKeychainCallback cannot be used with ${key}`); + } + } + + const { label, createKeychainCallback, type = 'hot', enterprise, isDistributedCustody } = params; + + if (type === 'custodial') { + throw new Error('external signer wallet generation is not supported for custodial onchain wallets'); + } + + if (!_.isUndefined(params.webauthnInfo)) { + throw new Error('webauthnInfo is not supported for external signer wallet generation'); + } + + if (!_.isUndefined(params.passcodeEncryptionCode)) { + throw new Error('passcodeEncryptionCode is not supported for external signer wallet generation'); + } + + if (isDistributedCustody) { + if (!enterprise) { + throw new Error('must provide enterprise when creating distributed custody wallet'); + } + if (type !== 'cold') { + throw new Error('distributed custody wallets must be type: cold'); + } + } + + if (params.gasPrice && params.eip1559) { + throw new Error('can not use both eip1559 and gasPrice values'); + } + + const walletParams: SupplementGenerateWalletOptions = { + label, + m: 2, + n: 3, + keys: [], + type, + }; + + if (!_.isUndefined(enterprise)) { + if (!_.isString(enterprise)) { + throw new Error('invalid enterprise argument, expecting string'); + } + walletParams.enterprise = enterprise; + } + + if (!_.isUndefined(params.disableTransactionNotifications)) { + if (!_.isBoolean(params.disableTransactionNotifications)) { + throw new Error('invalid disableTransactionNotifications argument, expecting boolean'); + } + walletParams.disableTransactionNotifications = params.disableTransactionNotifications; + } + + if (!_.isUndefined(params.gasPrice)) { + const gasPriceBN = new BigNumber(params.gasPrice); + if (gasPriceBN.isNaN()) { + throw new Error('invalid gas price argument, expecting number or number as string'); + } + walletParams.gasPrice = gasPriceBN.toString(); + } + + if (!_.isUndefined(params.eip1559) && !_.isEmpty(params.eip1559)) { + const maxFeePerGasBN = new BigNumber(params.eip1559.maxFeePerGas); + if (maxFeePerGasBN.isNaN()) { + throw new Error('invalid max fee argument, expecting number or number as string'); + } + const maxPriorityFeePerGasBN = new BigNumber(params.eip1559.maxPriorityFeePerGas); + if (maxPriorityFeePerGasBN.isNaN()) { + throw new Error('invalid priority fee argument, expecting number or number as string'); + } + walletParams.eip1559 = { + maxFeePerGas: maxFeePerGasBN.toString(), + maxPriorityFeePerGas: maxPriorityFeePerGasBN.toString(), + }; + } + + if (!_.isUndefined(params.walletVersion)) { + if (!_.isNumber(params.walletVersion)) { + throw new Error('invalid walletVersion provided, expecting number'); + } + walletParams.walletVersion = params.walletVersion; + } + + const reqId = new RequestTracer(); + const coin = this.baseCoin.getChain(); + + const createAndUploadKeychain = async (source: 'user' | 'backup'): Promise => { + const keychainFromCallback = await createKeychainCallback({ source, coin }); + if (keychainFromCallback.source !== source) { + throw new Error(`createKeychainCallback returned source ${keychainFromCallback.source}, expected ${source}`); + } + return this.baseCoin.keychains().add({ + pub: keychainFromCallback.pub, + keyType: keychainFromCallback.type as KeyType, + source: keychainFromCallback.source, + reqId, + }); + }; + + const { userKeychain, backupKeychain, bitgoKeychain }: KeychainsTriplet = await promiseProps({ + userKeychain: createAndUploadKeychain('user'), + backupKeychain: createAndUploadKeychain('backup'), + bitgoKeychain: this.baseCoin + .keychains() + .createBitGo({ enterprise, reqId, isDistributedCustody: params.isDistributedCustody }), + }); + + walletParams.keys = [userKeychain.id, backupKeychain.id, bitgoKeychain.id]; + + const keychains = { + userKeychain, + backupKeychain, + bitgoKeychain, + }; + + if (_.includes(['xrp', 'xlm', 'cspr'], this.baseCoin.getFamily()) && !_.isUndefined(params.rootPrivateKey)) { + walletParams.rootPrivateKey = params.rootPrivateKey; + } + + const finalWalletParams = await this.baseCoin.supplementGenerateWallet(walletParams, keychains); + + this.bitgo.setRequestTracer(reqId); + const newWallet = await this.bitgo.post(this.baseCoin.url('/wallet/add')).send(finalWalletParams).result(); + + return { + wallet: new Wallet(this.bitgo, this.baseCoin, newWallet), + userKeychain, + backupKeychain, + bitgoKeychain, + responseType: 'WalletWithKeychains', + }; + } + /** * List the user's wallet shares * @param params diff --git a/modules/sdk-core/test/unit/bitgo/wallet/walletsExternalSigner.ts b/modules/sdk-core/test/unit/bitgo/wallet/walletsExternalSigner.ts new file mode 100644 index 0000000000..cae275df70 --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/wallet/walletsExternalSigner.ts @@ -0,0 +1,221 @@ +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import 'should'; + +import { Wallets } from '../../../../src/bitgo/wallet/wallets'; +import { CreateKeychainCallback } from '../../../../src/bitgo/wallet/iWallets'; + +describe('Wallets - external signer onchain wallet generation', function () { + let wallets: Wallets; + let mockBitGo: any; + let mockBaseCoin: any; + let mockKeychains: any; + let createKeychainCallback: sinon.SinonStub, ReturnType>; + let sendStub: sinon.SinonStub; + + const userPub = + 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8'; + const backupPub = + 'xpub661MyMwAqRbcGczjuMoRm6dXaLDEhW1u34gKenbeYqAix21mdUKJyuyu5F1rzYGVxyL6tmgBUAEPrEz92mBXjByMRiJdba9wpnN37RLLAXa'; + const bitgoPub = + 'xpub661MyMwAqRbcEYS8w7XLSVeEsBXy79zSzH1J8vCdxAZningWLdN3zgtU6LBpB85b3D2yc8sfvZU521AAwdZafEz7mnzBBsz4wKY5fTtTQBm'; + + beforeEach(function () { + createKeychainCallback = sinon.stub(); + createKeychainCallback.withArgs({ source: 'user', coin: 'tbtc' }).resolves({ + pub: userPub, + type: 'tbtc', + source: 'user', + }); + createKeychainCallback.withArgs({ source: 'backup', coin: 'tbtc' }).resolves({ + pub: backupPub, + type: 'tbtc', + source: 'backup', + }); + + mockKeychains = { + add: sinon.stub().callsFake(async (params: { pub: string; source: string }) => ({ + id: `${params.source}-key-id`, + pub: params.pub, + source: params.source, + })), + createBitGo: sinon.stub().resolves({ id: 'bitgo-key-id', pub: bitgoPub }), + }; + + const mockWalletData = { id: 'wallet-id', keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'] }; + + sendStub = sinon.stub().returns({ + result: sinon.stub().resolves(mockWalletData), + }); + mockBitGo = { + post: sinon.stub().returns({ send: sendStub }), + setRequestTracer: sinon.stub(), + }; + + mockBaseCoin = { + isEVM: sinon.stub().returns(false), + supportsTss: sinon.stub().returns(true), + getFamily: sinon.stub().returns('btc'), + getChain: sinon.stub().returns('tbtc'), + getDefaultMultisigType: sinon.stub().returns('onchain'), + keychains: sinon.stub().returns(mockKeychains), + url: sinon.stub().returns('/api/v2/tbtc/wallet/add'), + getConfig: sinon.stub().returns({ features: [] }), + supplementGenerateWallet: sinon.stub().callsFake((walletParams: unknown) => Promise.resolve(walletParams)), + }; + + wallets = new Wallets(mockBitGo, mockBaseCoin); + }); + + afterEach(function () { + sinon.restore(); + }); + + describe('generateWalletWithExternalSigner', function () { + it('should create user and backup keys via callback and create wallet', async function () { + const result = await wallets.generateWalletWithExternalSigner({ + label: 'External Signer Wallet', + enterprise: 'enterprise-id', + createKeychainCallback, + }); + + assert.strictEqual(createKeychainCallback.callCount, 2); + assert.strictEqual(mockKeychains.add.callCount, 2); + assert.strictEqual(mockKeychains.createBitGo.calledOnce, true); + assert.strictEqual(mockBitGo.post.calledOnce, true); + + const addUserParams = mockKeychains.add.getCall(0).args[0]; + addUserParams.should.have.property('pub', userPub); + addUserParams.should.have.property('keyType', 'tbtc'); + addUserParams.should.have.property('source', 'user'); + + const walletBody = sendStub.firstCall.args[0]; + walletBody.keys.should.deepEqual(['user-key-id', 'backup-key-id', 'bitgo-key-id']); + walletBody.label.should.equal('External Signer Wallet'); + walletBody.enterprise.should.equal('enterprise-id'); + + result.responseType.should.equal('WalletWithKeychains'); + assert.strictEqual(result.userKeychain.pub, userPub); + assert.strictEqual(result.backupKeychain.pub, backupPub); + assert.strictEqual(result.bitgoKeychain.pub, bitgoPub); + }); + + it('should reject when callback source does not match requested source', async function () { + createKeychainCallback.withArgs({ source: 'user', coin: 'tbtc' }).resolves({ + pub: userPub, + type: 'tbtc', + source: 'backup', + }); + + await wallets + .generateWalletWithExternalSigner({ + label: 'External Signer Wallet', + createKeychainCallback, + }) + .should.be.rejectedWith('createKeychainCallback returned source backup, expected user'); + }); + + it('should reject TSS multisig type', async function () { + await wallets + .generateWalletWithExternalSigner({ + label: 'TSS Wallet', + multisigType: 'tss', + createKeychainCallback, + }) + .should.be.rejectedWith('external signer wallet generation is only supported for onchain multisig wallets'); + }); + + it('should reject custodial wallet type', async function () { + await wallets + .generateWalletWithExternalSigner({ + label: 'Custodial Wallet', + type: 'custodial', + createKeychainCallback, + }) + .should.be.rejectedWith('external signer wallet generation is not supported for custodial onchain wallets'); + }); + + it('should reject passcodeEncryptionCode', async function () { + await wallets + .generateWalletWithExternalSigner({ + label: 'Wallet', + passcodeEncryptionCode: 'some-code', + createKeychainCallback, + }) + .should.be.rejectedWith('passcodeEncryptionCode is not supported for external signer wallet generation'); + }); + + it('should reject webauthnInfo', async function () { + await wallets + .generateWalletWithExternalSigner({ + label: 'Wallet', + webauthnInfo: { otpDeviceId: 'dev-id', prfSalt: 'salt', passphrase: 'pass' } as any, + createKeychainCallback, + }) + .should.be.rejectedWith('webauthnInfo is not supported for external signer wallet generation'); + }); + + it('should reject isDistributedCustody without enterprise', async function () { + await wallets + .generateWalletWithExternalSigner({ + label: 'DC Wallet', + type: 'cold', + isDistributedCustody: true, + createKeychainCallback, + }) + .should.be.rejectedWith('must provide enterprise when creating distributed custody wallet'); + }); + + it('should reject isDistributedCustody with non-cold type', async function () { + await wallets + .generateWalletWithExternalSigner({ + label: 'DC Wallet', + type: 'hot', + isDistributedCustody: true, + enterprise: 'enterprise-id', + createKeychainCallback, + }) + .should.be.rejectedWith('distributed custody wallets must be type: cold'); + }); + }); + + describe('generateWallet with createKeychainCallback', function () { + it('should delegate to generateWalletWithExternalSigner', async function () { + const generateWalletWithExternalSignerStub = sinon.stub(wallets, 'generateWalletWithExternalSigner').resolves({ + responseType: 'WalletWithKeychains', + wallet: {} as any, + userKeychain: { id: 'user-key-id', pub: userPub, type: 'independent' } as any, + backupKeychain: { id: 'backup-key-id', pub: backupPub, type: 'independent' } as any, + bitgoKeychain: { id: 'bitgo-key-id', pub: bitgoPub, type: 'independent' } as any, + }); + + await wallets.generateWallet({ + label: 'Delegated Wallet', + createKeychainCallback, + }); + + assert.strictEqual(generateWalletWithExternalSignerStub.calledOnce, true); + generateWalletWithExternalSignerStub.firstCall.args[0].label.should.equal('Delegated Wallet'); + }); + + it('should reject when createKeychainCallback is combined with passphrase', async function () { + await wallets + .generateWallet({ + label: 'Invalid Wallet', + passphrase: 'secret', + createKeychainCallback, + }) + .should.be.rejectedWith('createKeychainCallback cannot be used with passphrase'); + }); + + it('should reject when createKeychainCallback is combined with userKey', async function () { + await wallets + .generateWallet({ + label: 'Invalid Wallet', + userKey: 'xpub...', + createKeychainCallback, + }) + .should.be.rejectedWith('createKeychainCallback cannot be used with userKey'); + }); + }); +}); From 87d04a789b5e0bf44ad7d6b64e860873237f4c30 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Fri, 5 Jun 2026 18:57:45 +0000 Subject: [PATCH 2/2] fix: fixes for external signer callback Ticket: WCN-685 --- modules/sdk-core/src/bitgo/wallet/iWallets.ts | 6 +- modules/sdk-core/src/bitgo/wallet/wallets.ts | 43 ++++-- .../bitgo/wallet/walletsExternalSigner.ts | 127 ++++++++++++++++-- 3 files changed, 152 insertions(+), 24 deletions(-) diff --git a/modules/sdk-core/src/bitgo/wallet/iWallets.ts b/modules/sdk-core/src/bitgo/wallet/iWallets.ts index 5aecb03edc..aee366b40b 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallets.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallets.ts @@ -70,8 +70,8 @@ export interface CreateKeychainCallbackParams { export interface CreateKeychainCallbackResult { pub: string; - type: string; - source: string; + type: 'independent'; + source: 'user' | 'backup'; } export type CreateKeychainCallback = (params: CreateKeychainCallbackParams) => Promise; @@ -116,6 +116,8 @@ export interface GenerateWalletWithExternalSignerOptions extends Omit { label: string; createKeychainCallback: CreateKeychainCallback; + /** Optional user-key signatures over backup/bitgo pubs. Omit when the external signer cannot produce them (equivalent to a cold wallet). */ + keySignatures?: { backup: string; bitgo: string }; } export const GenerateLightningWalletOptionsCodec = t.intersection( diff --git a/modules/sdk-core/src/bitgo/wallet/wallets.ts b/modules/sdk-core/src/bitgo/wallet/wallets.ts index 9d1c12c899..4f7faf4551 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallets.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallets.ts @@ -12,7 +12,7 @@ import * as common from '../../common'; import { IBaseCoin, KeychainsTriplet, SupplementGenerateWalletOptions } from '../baseCoin'; import { BitGoBase } from '../bitgoBase'; import { getSharedSecret } from '../ecdh'; -import { AddKeychainOptions, Keychain, KeyIndices, KeyType } from '../keychain'; +import { AddKeychainOptions, Keychain, KeyIndices } from '../keychain'; import { decodeOrElse, promiseProps, RequestTracer } from '../utils'; import { AcceptShareOptions, @@ -743,8 +743,9 @@ export class Wallets implements IWallets { throw new Error('external signer wallet generation is only supported for onchain multisig wallets'); } - const conflictingParams = ['passphrase', 'userKey', 'backupXpub', 'backupXpubProvider'] as const; - for (const key of conflictingParams) { + // these belong to the passphrase-based path and are incompatible with createKeychainCallback + const passphrasePathParams = ['passphrase', 'userKey', 'backupXpub', 'backupXpubProvider'] as const; + for (const key of passphrasePathParams) { if (!_.isUndefined(params[key])) { throw new Error(`createKeychainCallback cannot be used with ${key}`); } @@ -833,16 +834,30 @@ export class Wallets implements IWallets { const coin = this.baseCoin.getChain(); const createAndUploadKeychain = async (source: 'user' | 'backup'): Promise => { - const keychainFromCallback = await createKeychainCallback({ source, coin }); - if (keychainFromCallback.source !== source) { - throw new Error(`createKeychainCallback returned source ${keychainFromCallback.source}, expected ${source}`); + try { + const keychainFromCallback = await createKeychainCallback({ source, coin }); + if (keychainFromCallback.source !== source) { + throw new Error(`createKeychainCallback returned source ${keychainFromCallback.source}, expected ${source}`); + } + if (keychainFromCallback.type !== 'independent') { + throw new Error( + `createKeychainCallback returned invalid type ${keychainFromCallback.type}, expected 'independent' for onchain multisig` + ); + } + if (!this.baseCoin.isValidPub(keychainFromCallback.pub)) { + throw new Error(`createKeychainCallback returned invalid pub for ${source} key on ${coin}`); + } + return this.baseCoin.keychains().add({ + pub: keychainFromCallback.pub, + keyType: keychainFromCallback.type, + source: keychainFromCallback.source, + reqId, + }); + } catch (error) { + throw new Error( + `Failed to create ${source} keychain: ${error instanceof Error ? error.message : String(error)}` + ); } - return this.baseCoin.keychains().add({ - pub: keychainFromCallback.pub, - keyType: keychainFromCallback.type as KeyType, - source: keychainFromCallback.source, - reqId, - }); }; const { userKeychain, backupKeychain, bitgoKeychain }: KeychainsTriplet = await promiseProps({ @@ -855,6 +870,10 @@ export class Wallets implements IWallets { walletParams.keys = [userKeychain.id, backupKeychain.id, bitgoKeychain.id]; + if (params.keySignatures) { + walletParams.keySignatures = params.keySignatures; + } + const keychains = { userKeychain, backupKeychain, diff --git a/modules/sdk-core/test/unit/bitgo/wallet/walletsExternalSigner.ts b/modules/sdk-core/test/unit/bitgo/wallet/walletsExternalSigner.ts index cae275df70..428a197918 100644 --- a/modules/sdk-core/test/unit/bitgo/wallet/walletsExternalSigner.ts +++ b/modules/sdk-core/test/unit/bitgo/wallet/walletsExternalSigner.ts @@ -4,6 +4,7 @@ import 'should'; import { Wallets } from '../../../../src/bitgo/wallet/wallets'; import { CreateKeychainCallback } from '../../../../src/bitgo/wallet/iWallets'; +import { Wallet } from '../../../../src/bitgo/wallet/wallet'; describe('Wallets - external signer onchain wallet generation', function () { let wallets: Wallets; @@ -24,12 +25,12 @@ describe('Wallets - external signer onchain wallet generation', function () { createKeychainCallback = sinon.stub(); createKeychainCallback.withArgs({ source: 'user', coin: 'tbtc' }).resolves({ pub: userPub, - type: 'tbtc', + type: 'independent', source: 'user', }); createKeychainCallback.withArgs({ source: 'backup', coin: 'tbtc' }).resolves({ pub: backupPub, - type: 'tbtc', + type: 'independent', source: 'backup', }); @@ -62,6 +63,7 @@ describe('Wallets - external signer onchain wallet generation', function () { url: sinon.stub().returns('/api/v2/tbtc/wallet/add'), getConfig: sinon.stub().returns({ features: [] }), supplementGenerateWallet: sinon.stub().callsFake((walletParams: unknown) => Promise.resolve(walletParams)), + isValidPub: sinon.stub().returns(true), }; wallets = new Wallets(mockBitGo, mockBaseCoin); @@ -86,7 +88,7 @@ describe('Wallets - external signer onchain wallet generation', function () { const addUserParams = mockKeychains.add.getCall(0).args[0]; addUserParams.should.have.property('pub', userPub); - addUserParams.should.have.property('keyType', 'tbtc'); + addUserParams.should.have.property('keyType', 'independent'); addUserParams.should.have.property('source', 'user'); const walletBody = sendStub.firstCall.args[0]; @@ -103,7 +105,7 @@ describe('Wallets - external signer onchain wallet generation', function () { it('should reject when callback source does not match requested source', async function () { createKeychainCallback.withArgs({ source: 'user', coin: 'tbtc' }).resolves({ pub: userPub, - type: 'tbtc', + type: 'independent', source: 'backup', }); @@ -112,7 +114,32 @@ describe('Wallets - external signer onchain wallet generation', function () { label: 'External Signer Wallet', createKeychainCallback, }) - .should.be.rejectedWith('createKeychainCallback returned source backup, expected user'); + .should.be.rejectedWith( + 'Failed to create user keychain: createKeychainCallback returned source backup, expected user' + ); + }); + + it('should reject invalid type from callback', async function () { + const badTypeCallback = sinon.stub(); + badTypeCallback.withArgs({ source: 'user', coin: 'tbtc' }).resolves({ + pub: userPub, + type: 'tss', + source: 'user', + }); + badTypeCallback.withArgs({ source: 'backup', coin: 'tbtc' }).resolves({ + pub: backupPub, + type: 'independent', + source: 'backup', + }); + + await wallets + .generateWalletWithExternalSigner({ + label: 'External Signer Wallet', + createKeychainCallback: badTypeCallback, + }) + .should.be.rejectedWith( + "Failed to create user keychain: createKeychainCallback returned invalid type tss, expected 'independent' for onchain multisig" + ); }); it('should reject TSS multisig type', async function () { @@ -149,7 +176,7 @@ describe('Wallets - external signer onchain wallet generation', function () { await wallets .generateWalletWithExternalSigner({ label: 'Wallet', - webauthnInfo: { otpDeviceId: 'dev-id', prfSalt: 'salt', passphrase: 'pass' } as any, + webauthnInfo: { otpDeviceId: 'dev-id', prfSalt: 'salt', passphrase: 'pass' }, createKeychainCallback, }) .should.be.rejectedWith('webauthnInfo is not supported for external signer wallet generation'); @@ -177,16 +204,96 @@ describe('Wallets - external signer onchain wallet generation', function () { }) .should.be.rejectedWith('distributed custody wallets must be type: cold'); }); + + it('should reject when callback returns invalid pub for coin', async function () { + // only invalidate user pub so the rejection source is deterministic + mockBaseCoin.isValidPub.callsFake((pub: string) => pub !== userPub); + + await wallets + .generateWalletWithExternalSigner({ + label: 'External Signer Wallet', + createKeychainCallback, + }) + .should.be.rejectedWith( + 'Failed to create user keychain: createKeychainCallback returned invalid pub for user key on tbtc' + ); + }); + + it('should wrap error when callback throws', async function () { + createKeychainCallback.withArgs({ source: 'user', coin: 'tbtc' }).rejects(new Error('HSM unreachable')); + + await wallets + .generateWalletWithExternalSigner({ + label: 'External Signer Wallet', + createKeychainCallback, + }) + .should.be.rejectedWith('Failed to create user keychain: HSM unreachable'); + }); + + it('should reject with backup keychain error when backup callback throws', async function () { + createKeychainCallback.withArgs({ source: 'backup', coin: 'tbtc' }).rejects(new Error('HSM unreachable')); + + await wallets + .generateWalletWithExternalSigner({ + label: 'External Signer Wallet', + createKeychainCallback, + }) + .should.be.rejectedWith('Failed to create backup keychain: HSM unreachable'); + + assert.strictEqual(mockBitGo.post.callCount, 0); + }); + + it('should wrap non-Error thrown by callback', async function () { + createKeychainCallback + .withArgs({ source: 'user', coin: 'tbtc' }) + .returns(Promise.reject('plain string rejection')); + + await wallets + .generateWalletWithExternalSigner({ + label: 'External Signer Wallet', + createKeychainCallback, + }) + .should.be.rejectedWith('Failed to create user keychain: plain string rejection'); + }); + + it('should forward keySignatures to wallet params when provided', async function () { + const keySignatures = { + backup: 'deadbeef01', + bitgo: 'deadbeef02', + }; + + await wallets.generateWalletWithExternalSigner({ + label: 'External Signer Wallet', + enterprise: 'enterprise-id', + createKeychainCallback, + keySignatures, + }); + + const walletBody = sendStub.firstCall.args[0]; + walletBody.should.have.property('keySignatures'); + walletBody.keySignatures.should.deepEqual(keySignatures); + }); + + it('should not include keySignatures in wallet params when not provided', async function () { + await wallets.generateWalletWithExternalSigner({ + label: 'External Signer Wallet', + enterprise: 'enterprise-id', + createKeychainCallback, + }); + + const walletBody = sendStub.firstCall.args[0]; + walletBody.should.not.have.property('keySignatures'); + }); }); describe('generateWallet with createKeychainCallback', function () { it('should delegate to generateWalletWithExternalSigner', async function () { const generateWalletWithExternalSignerStub = sinon.stub(wallets, 'generateWalletWithExternalSigner').resolves({ responseType: 'WalletWithKeychains', - wallet: {} as any, - userKeychain: { id: 'user-key-id', pub: userPub, type: 'independent' } as any, - backupKeychain: { id: 'backup-key-id', pub: backupPub, type: 'independent' } as any, - bitgoKeychain: { id: 'bitgo-key-id', pub: bitgoPub, type: 'independent' } as any, + wallet: sinon.createStubInstance(Wallet), + userKeychain: { id: 'user-key-id', pub: userPub, type: 'independent' as const }, + backupKeychain: { id: 'backup-key-id', pub: backupPub, type: 'independent' as const }, + bitgoKeychain: { id: 'bitgo-key-id', pub: bitgoPub, type: 'independent' as const }, }); await wallets.generateWallet({