diff --git a/src/__tests__/api/master/ecdsa.test.ts b/src/__tests__/api/master/ecdsa.test.ts index 11cdadb..79a6074 100644 --- a/src/__tests__/api/master/ecdsa.test.ts +++ b/src/__tests__/api/master/ecdsa.test.ts @@ -6,16 +6,17 @@ import { IRequestTracer, openpgpUtils, RequestTracer, - SignatureShareRecord, - SignatureShareType, - TransactionState, TxRequest, Wallet, } from '@bitgo-beta/sdk-core'; import { BitGoAPI } from '@bitgo-beta/sdk-api'; import { AdvancedWalletManagerClient } from '../../../masterBitgoExpress/clients/advancedWalletManagerClient'; import { signAndSendEcdsaMPCv2FromTxRequest } from '../../../masterBitgoExpress/handlers/ecdsa'; -import { BitGoAPITestHarness } from './testUtils'; +import { + BitGoAPITestHarness, + buildEcdsaMpcv2TxRequest, + nockEcdsaMpcv2SigningFlow, +} from './testUtils'; describe('Ecdsa Signing Handler', () => { let bitgo: BitGoAPI; @@ -65,22 +66,8 @@ describe('Ecdsa Signing Handler', () => { }); it('should successfully sign an ECDSA MPCv2 transaction', async () => { - const txRequest: TxRequest = { - txRequestId: 'test-tx-request-id', - apiVersion: 'full', - enterpriseId: 'test-enterprise-id', - transactions: [], - state: 'pendingUserSignature', - walletId: 'test-wallet-id', - walletType: 'hot', - version: 2, - date: new Date().toISOString(), - userId: 'test-user-id', - intent: {}, - policiesChecked: true, - unsignedTxs: [], - latest: true, - }; + const txRequestData = buildEcdsaMpcv2TxRequest('pendingUserSignature'); + const txRequest = txRequestData as TxRequest; const userPubKey = 'test-user-pub-key'; const bitgoGpgKey = await openpgpUtils.generateGPGKeyPair('secp256k1'); @@ -98,145 +85,15 @@ describe('Ecdsa Signing Handler', () => { }, }); - // Mock sendSignatureShareV2 calls for each round - const round1SignatureShare: SignatureShareRecord = { - from: SignatureShareType.USER, - to: SignatureShareType.BITGO, - share: JSON.stringify({ - type: 'round1Input', - data: { - msg1: { - from: 1, - message: 'round1-message', - }, - }, - }), - }; - - const round1TxRequest: TxRequest = { - ...txRequest, - transactions: [ - { - unsignedTx: { - derivationPath: 'm/0', - signableHex: 'testMessage', - serializedTxHex: 'testMessage', - }, - signatureShares: [round1SignatureShare], - state: 'pendingSignature' as TransactionState, - }, - ], - }; - - const sendSignatureShareV2Round1Nock = nock(bitgoApiUrl) - .post(`/api/v2/wallet/${walletId}/txrequests/test-tx-request-id/transactions/0/sign`) - .matchHeader('any', () => true) - .reply(200, { - ...round1TxRequest, - }); - - const round2SignatureShare: SignatureShareRecord = { - from: SignatureShareType.USER, - to: SignatureShareType.BITGO, - share: JSON.stringify({ - type: 'round2Input', - data: { - msg2: { - from: 1, - to: 3, - encryptedMessage: 'round2-encrypted-message', - signature: 'round2-signature', - }, - msg3: { - from: 1, - to: 3, - encryptedMessage: 'round3-encrypted-message', - signature: 'round3-signature', - }, - }, - }), - }; - - const round2TxRequest: TxRequest = { - ...round1TxRequest, - transactions: [ - { - ...round1TxRequest.transactions![0], - signatureShares: [round1SignatureShare, round2SignatureShare], - }, - ], - }; - - const sendSignatureShareV2Round2Nock = nock(bitgoApiUrl) - .post(`/api/v2/wallet/${walletId}/txrequests/test-tx-request-id/transactions/0/sign`) - .matchHeader('any', () => true) - .reply(200, { - ...round2TxRequest, - }); - - const round3SignatureShare: SignatureShareRecord = { - from: SignatureShareType.USER, - to: SignatureShareType.BITGO, - share: JSON.stringify({ - type: 'round3Input', - data: { - msg4: { - from: 1, - message: 'round4-message', - signature: 'round4-signature', - signatureR: 'round4-signature-r', - }, - }, - }), - }; - - const sendSignatureShareV2Round3Nock = nock(bitgoApiUrl) - .post(`/api/v2/wallet/${walletId}/txrequests/test-tx-request-id/transactions/0/sign`) - .matchHeader('any', () => true) - .reply(200, { - ...round2TxRequest, - transactions: [ - { - ...round2TxRequest.transactions![0], - signatureShares: [round1SignatureShare, round2SignatureShare, round3SignatureShare], - }, - ], - }); - - // Mock sendTxRequest call - const sendTxRequestNock = nock(bitgoApiUrl) - .post(`/api/v2/wallet/${walletId}/txrequests/test-tx-request-id/transactions/0/send`) - .matchHeader('any', () => true) - .reply(200, { - ...txRequest, - state: 'signed', - }); - - // Mock MPCv2 Round 1 signing - const signMpcV2Round1NockAwm = nock(advancedWalletManagerUrl) - .post(`/api/${coin}/mpc/sign/mpcv2round1`) - .reply(200, { - signatureShareRound1: round1SignatureShare, - userGpgPubKey: bitgoGpgKey.publicKey, - encryptedRound1Session: 'encrypted-round1-session', - encryptedUserGpgPrvKey: 'encrypted-user-gpg-prv-key', - encryptedDataKey: 'test-encrypted-data-key', - }); - - // Mock MPCv2 Round 2 signing - const signMpcV2Round2NockAwm = nock(advancedWalletManagerUrl) - .post(`/api/${coin}/mpc/sign/mpcv2round2`) - .reply(200, { - signatureShareRound2: round2SignatureShare, - encryptedRound2Session: 'encrypted-round2-session', - }); - - // Mock MPCv2 Round 3 signing - const signMpcV2Round3NockAwm = nock(advancedWalletManagerUrl) - .post(`/api/${coin}/mpc/sign/mpcv2round3`) - .reply(200, { - signatureShareRound3: round3SignatureShare, - }); + const sendResponse = { ...txRequestData, state: 'signed' }; + const nocks = nockEcdsaMpcv2SigningFlow({ + coin, + bitgoApiUrl, + advancedWalletManagerUrl, + sendResponse, + walletId, + userGpgPubKey: bitgoGpgKey.publicKey, + }); const result = await signAndSendEcdsaMPCv2FromTxRequest( bitgo, @@ -248,17 +105,14 @@ describe('Ecdsa Signing Handler', () => { reqId, ); - result.should.eql({ - ...txRequest, - state: 'signed', - }); + result.should.eql(sendResponse); - sendSignatureShareV2Round1Nock.done(); - sendSignatureShareV2Round2Nock.done(); - sendSignatureShareV2Round3Nock.done(); - sendTxRequestNock.done(); - signMpcV2Round1NockAwm.done(); - signMpcV2Round2NockAwm.done(); - signMpcV2Round3NockAwm.done(); + nocks.round1SignNock.done(); + nocks.round2SignNock.done(); + nocks.round3SignNock.done(); + nocks.sendTxNock.done(); + nocks.awmRound1Nock.done(); + nocks.awmRound2Nock.done(); + nocks.awmRound3Nock.done(); }); }); diff --git a/src/__tests__/api/master/sendMany.test.ts b/src/__tests__/api/master/sendMany.test.ts index 53f4380..2cb8f3f 100644 --- a/src/__tests__/api/master/sendMany.test.ts +++ b/src/__tests__/api/master/sendMany.test.ts @@ -5,17 +5,12 @@ import * as request from 'supertest'; import nock from 'nock'; import { app as expressApp } from '../../../masterBitGoExpressApp'; import { AppMode, MasterExpressConfig, TlsMode } from '../../../shared/types'; -import { - Environments, - openpgpUtils, - SignatureShareRecord, - SignatureShareType, -} from '@bitgo-beta/sdk-core'; +import { Environments, openpgpUtils } from '@bitgo-beta/sdk-core'; import * as utxolib from '@bitgo-beta/utxo-lib'; import { Tbtc } from '@bitgo-beta/sdk-coin-btc'; import { Tsol } from '@bitgo-beta/sdk-coin-sol'; import assert from 'assert'; -import { BitGoAPITestHarness } from './testUtils'; +import { BitGoAPITestHarness, nockEcdsaMpcv2SendManySigningFlow } from './testUtils'; const testWalletId = 'test-wallet-id'; const testBitgoApiUrl = Environments.test.uri; @@ -109,218 +104,6 @@ function nockTssWalletKeychains(coinName: string) { }); } -function buildPendingEcdsaMPCv2TxRequest(walletIdParam: string) { - return { - txRequestId: tssTxRequestId, - apiVersion: 'full', - enterpriseId: 'test-enterprise-id', - transactions: [ - { - state: 'pendingSignature', - unsignedTx: { - derivationPath: 'm/0', - signableHex: 'testMessage', - serializedTxHex: 'testSerializedTxHex', - }, - signatureShares: [] as SignatureShareRecord[], - }, - ], - state: 'pendingUserSignature', - walletId: walletIdParam, - walletType: 'hot', - version: 2, - date: new Date().toISOString(), - userId: 'test-user-id', - intent: {}, - policiesChecked: true, - unsignedTxs: [], - latest: true, - }; -} - -function buildSignedEcdsaMPCv2TxRequest(walletIdParam: string) { - const pending = buildPendingEcdsaMPCv2TxRequest(walletIdParam); - return { - ...pending, - state: 'signed', - transactions: [ - { - ...pending.transactions[0], - state: 'signed', - signedTx: { id: 'test-tx-id', tx: 'signed-transaction' }, - }, - ], - }; -} - -function nockEcdsaMPCv2SigningFlow( - coin: string, - walletIdParam: string, - bitgoApiUrlParam: string, - advancedWalletManagerUrlParam: string, -) { - const round1SignatureShare: SignatureShareRecord = { - from: SignatureShareType.USER, - to: SignatureShareType.BITGO, - share: JSON.stringify({ - type: 'round1Input', - data: { msg1: { from: 1, message: 'round1-message' } }, - }), - }; - const round2SignatureShare: SignatureShareRecord = { - from: SignatureShareType.USER, - to: SignatureShareType.BITGO, - share: JSON.stringify({ - type: 'round2Input', - data: { - msg2: { from: 1, to: 3, encryptedMessage: 'round2-message', signature: 'round2-signature' }, - msg3: { from: 1, to: 3, encryptedMessage: 'round3-message', signature: 'round3-signature' }, - }, - }), - }; - const round3SignatureShare: SignatureShareRecord = { - from: SignatureShareType.USER, - to: SignatureShareType.BITGO, - share: JSON.stringify({ - type: 'round3Input', - data: { - msg4: { - from: 1, - message: 'round4-message', - signature: 'round4-signature', - signatureR: 'round4-signature-r', - }, - }, - }), - }; - - const pendingTxRequest = buildPendingEcdsaMPCv2TxRequest(walletIdParam); - const signedTxRequest = buildSignedEcdsaMPCv2TxRequest(walletIdParam); - - // The SDK fetches the user keychain in handleSendMany (validation) and again inside - // prebuildAndSignTransaction → getKeysForSigning, so use persist(). - nock(bitgoApiUrlParam) - .persist() - .get(`/api/v2/${coin}/key/user-key-id`) - .matchHeader('any', () => true) - .reply(200, { - id: 'user-key-id', - pub: 'xpub_user', - commonKeychain: 'test-common-keychain', - source: 'user', - type: 'tss', - }); - - // pickBitgoPubGpgKeyForSigning fetches the BitGo keychain to resolve the GPG key via - // hsmType → getBitgoMpcGpgPubKey. env:'test' requires this path (no constants fallback). - nock(bitgoApiUrlParam) - .get(`/api/v2/${coin}/key/bitgo-key-id`) - .matchHeader('any', () => true) - .reply(200, { - id: 'bitgo-key-id', - pub: 'xpub_bitgo', - commonKeychain: 'test-common-keychain', - source: 'bitgo', - type: 'tss', - hsmType: 'institutional', - }); - - const createTxRequestNock = nock(bitgoApiUrlParam) - .post(`/api/v2/wallet/${walletIdParam}/txrequests`) - .matchHeader('any', () => true) - .reply(200, pendingTxRequest); - - // getTxRequest is called three times: in prebuildAndSignTransaction, in - // signEcdsaMPCv2TssUsingExternalSigner, and in sendManyTxRequests. - nock(bitgoApiUrlParam) - .persist() - .get(`/api/v2/wallet/${walletIdParam}/txrequests`) - .query(true) - .matchHeader('any', () => true) - .reply(200, { txRequests: [signedTxRequest] }); - - const round1SignNock = nock(bitgoApiUrlParam) - .post(`/api/v2/wallet/${walletIdParam}/txrequests/${tssTxRequestId}/transactions/0/sign`) - .matchHeader('any', () => true) - .reply(200, { - ...pendingTxRequest, - transactions: [ - { ...pendingTxRequest.transactions[0], signatureShares: [round1SignatureShare] }, - ], - }); - - const round2SignNock = nock(bitgoApiUrlParam) - .post(`/api/v2/wallet/${walletIdParam}/txrequests/${tssTxRequestId}/transactions/0/sign`) - .matchHeader('any', () => true) - .reply(200, { - ...pendingTxRequest, - transactions: [ - { - ...pendingTxRequest.transactions[0], - signatureShares: [round1SignatureShare, round2SignatureShare], - }, - ], - }); - - const round3SignNock = nock(bitgoApiUrlParam) - .post(`/api/v2/wallet/${walletIdParam}/txrequests/${tssTxRequestId}/transactions/0/sign`) - .matchHeader('any', () => true) - .reply(200, { - ...pendingTxRequest, - transactions: [ - { - ...pendingTxRequest.transactions[0], - signatureShares: [round1SignatureShare, round2SignatureShare, round3SignatureShare], - }, - ], - }); - - const sendTxNock = nock(bitgoApiUrlParam) - .post(`/api/v2/wallet/${walletIdParam}/txrequests/${tssTxRequestId}/transactions/0/send`) - .matchHeader('any', () => true) - .reply(200, pendingTxRequest); - - const transferNock = nock(bitgoApiUrlParam) - .post(`/api/v2/wallet/${walletIdParam}/txrequests/${tssTxRequestId}/transfers`) - .matchHeader('any', () => true) - .reply(200, { state: 'signed' }); - - const awmRound1Nock = nock(advancedWalletManagerUrlParam) - .post(`/api/${coin}/mpc/sign/mpcv2round1`) - .reply(200, { - signatureShareRound1: round1SignatureShare, - userGpgPubKey: 'user-gpg-pub-key', - encryptedRound1Session: 'encrypted-round1-session', - encryptedUserGpgPrvKey: 'encrypted-user-gpg-prv-key', - encryptedDataKey: 'test-encrypted-data-key', - }); - - const awmRound2Nock = nock(advancedWalletManagerUrlParam) - .post(`/api/${coin}/mpc/sign/mpcv2round2`) - .reply(200, { - signatureShareRound2: round2SignatureShare, - encryptedRound2Session: 'encrypted-round2-session', - }); - - const awmRound3Nock = nock(advancedWalletManagerUrlParam) - .post(`/api/${coin}/mpc/sign/mpcv2round3`) - .reply(200, { - signatureShareRound3: round3SignatureShare, - }); - - return { - createTxRequestNock, - round1SignNock, - round2SignNock, - round3SignNock, - sendTxNock, - transferNock, - awmRound1Nock, - awmRound2Nock, - awmRound3Nock, - }; -} - describe('POST /api/v1/:coin/advancedwallet/:walletId/sendMany', () => { let agent: request.SuperAgentTest; const advancedWalletManagerUrl = 'http://advancedwalletmanager.invalid'; @@ -839,12 +622,13 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/sendMany', () => { multisigTypeVersion: 'MPCv2', }); - const nocks = nockEcdsaMPCv2SigningFlow( + const nocks = nockEcdsaMpcv2SendManySigningFlow({ coin, walletId, bitgoApiUrl, advancedWalletManagerUrl, - ); + txRequestId: tssTxRequestId, + }); const response = await agent .post(`/api/v1/${coin}/advancedwallet/${walletId}/sendMany`) @@ -893,12 +677,13 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/sendMany', () => { multisigTypeVersion: 'MPCv2', }); - const nocks = nockEcdsaMPCv2SigningFlow( + const nocks = nockEcdsaMpcv2SendManySigningFlow({ coin, walletId, bitgoApiUrl, advancedWalletManagerUrl, - ); + txRequestId: tssTxRequestId, + }); const response = await agent .post(`/api/v1/${coin}/advancedwallet/${walletId}/sendMany`) diff --git a/src/__tests__/api/master/signAndSendTxRequest.test.ts b/src/__tests__/api/master/signAndSendTxRequest.test.ts index 202567e..2d71a4e 100644 --- a/src/__tests__/api/master/signAndSendTxRequest.test.ts +++ b/src/__tests__/api/master/signAndSendTxRequest.test.ts @@ -4,46 +4,29 @@ import * as request from 'supertest'; import nock from 'nock'; import { app as expressApp } from '../../../masterBitGoExpressApp'; import { AppMode, MasterExpressConfig, TlsMode } from '../../../shared/types'; +import { Environments } from '@bitgo-beta/sdk-core'; import { - BitGoBase, - Environments, - IBaseCoin, - PendingApproval, - PendingApprovals, - State, - TxRequest, - Type, - Wallet, -} from '@bitgo-beta/sdk-core'; -import { BitGoAPI } from '@bitgo-beta/sdk-api'; -import * as mpcv2 from '../../../masterBitgoExpress/handlers/ecdsa'; -import * as eddsa from '../../../masterBitgoExpress/handlers/eddsa'; -import coinFactory from '../../../shared/coinFactory'; + BitGoAPITestHarness, + buildEcdsaMpcv2TxRequest, + DEFAULT_ECDSA_MPCV2_TX_REQUEST_ID, + DEFAULT_ECDSA_MPCV2_WALLET_ID, + nockEcdsaMpcv2SigningFlow, +} from './testUtils'; + +const walletId = DEFAULT_ECDSA_MPCV2_WALLET_ID; +const txRequestId = DEFAULT_ECDSA_MPCV2_TX_REQUEST_ID; describe('POST /api/v1/:coin/advancedwallet/:walletId/txrequest/:txRequestId/signAndSend', () => { let agent: request.SuperAgentTest; - let bitgo: BitGoBase; - let baseCoin: IBaseCoin; - let wallet: Wallet; const advancedWalletManagerUrl = 'http://advancedwalletmanager.invalid'; const bitgoApiUrl = Environments.test.uri; const accessToken = 'test-token'; - const walletId = 'test-wallet-id'; - const txRequestId = 'test-tx-request-id'; const coin = 'hteth'; // Use hteth for ECDSA testing before(() => { nock.disableNetConnect(); nock.enableNetConnect('127.0.0.1'); - bitgo = new BitGoAPI({ env: 'local' }); - // Mock coinFactory to return bitgo.coin result for testing - sinon - .stub(coinFactory, 'getCoin') - .callsFake((coinName, sdk) => Promise.resolve(sdk.coin(coinName))); - baseCoin = bitgo.coin(coin); - wallet = new Wallet(bitgo, baseCoin, walletId); - const config: MasterExpressConfig = { appMode: AppMode.MASTER_EXPRESS, port: 0, // Let OS assign a free port @@ -66,200 +49,140 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/txrequest/:txRequestId/sig afterEach(() => { nock.cleanAll(); sinon.restore(); + BitGoAPITestHarness.clearConstantsCache(); }); after(() => { nock.enableNetConnect(); }); - describe('ECDSA MPCv2 Sign and Send:', () => { - it('should successfully sign and send ECDSA MPCv2 transaction with user key', async () => { - // Mock wallet get request - const walletGetNock = nock(bitgoApiUrl) - .get(`/api/v2/${coin}/wallet/${walletId}`) - .matchHeader('any', () => true) - .reply(200, { - id: walletId, - type: 'advanced', - keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'], - multisigType: 'tss', - coin: 'hteth', - }); + function nockWalletAndSigningKeychain(coinName: string) { + const walletNock = nock(bitgoApiUrl) + .get(`/api/v2/${coinName}/wallet/${walletId}`) + .matchHeader('any', () => true) + .reply(200, { + id: walletId, + type: 'advanced', + keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'], + multisigType: 'tss', + coin: coinName, + }); - // Mock keychain get request for user key - const keychainGetNock = nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/user-key-id`) - .matchHeader('any', () => true) - .reply(200, { - id: 'user-key-id', - pub: 'xpub_user', - commonKeychain: 'common-keychain-123', - source: 'user', - }); + const keychainNock = nock(bitgoApiUrl) + .get(`/api/v2/${coinName}/key/user-key-id`) + .matchHeader('any', () => true) + .reply(200, { + id: 'user-key-id', + pub: 'xpub_user', + commonKeychain: 'common-keychain-123', + source: 'user', + }); - // Mock getTxRequest - const txRequest: TxRequest = { - txRequestId, - apiVersion: 'full', - enterpriseId: 'test-enterprise-id', - transactions: [ - { - unsignedTx: { - derivationPath: 'm/0', - signableHex: 'testMessage', - serializedTxHex: 'testMessage', - }, - state: 'pendingSignature', - signatureShares: [], - }, - ], - state: 'pendingUserSignature', - walletId, - walletType: 'hot', - version: 2, - date: new Date().toISOString(), - userId: 'test-user-id', - intent: {}, - policiesChecked: true, - unsignedTxs: [], - latest: true, - }; + return { walletNock, keychainNock }; + } + + describe('ECDSA MPCv2 Sign and Send:', () => { + it('should successfully sign and send ECDSA MPCv2 transaction with user key', async () => { + const { walletNock, keychainNock } = nockWalletAndSigningKeychain(coin); const getTxRequestNock = nock(bitgoApiUrl) .get(`/api/v2/wallet/${walletId}/txrequests`) - .query({ txRequestIds: 'test-tx-request-id', latest: true }) + .query({ txRequestIds: txRequestId, latest: true }) .matchHeader('any', () => true) - .reply(200, { - txRequests: [txRequest], - }); - - // Replace the imported function with our stub - const signAndSendStub = sinon.stub(mpcv2, 'signAndSendEcdsaMPCv2FromTxRequest').resolves({ - ...txRequest, - state: 'signed', - transactions: [ - { - ...(txRequest.transactions || [])[0], - signedTx: { - id: 'test-tx-id', - tx: 'signed-transaction-hex', + .reply(200, { txRequests: [buildEcdsaMpcv2TxRequest('pendingUserSignature')] }); + + const signedTxRequest = buildEcdsaMpcv2TxRequest('signed', { + extra: { + transactions: [ + { + unsignedTx: { derivationPath: 'm/0', signableHex: 'testMessage' }, + state: 'signed', + signedTx: { id: 'test-tx-id', tx: 'signed-transaction-hex' }, }, - }, - ], + ], + }, + }); + const nocks = nockEcdsaMpcv2SigningFlow({ + coin, + bitgoApiUrl, + advancedWalletManagerUrl, + sendResponse: signedTxRequest, + includeBitgoKeychainNock: true, }); const response = await agent .post(`/api/v1/${coin}/advancedwallet/${walletId}/txrequest/${txRequestId}/signAndSend`) .set('Authorization', `Bearer ${accessToken}`) - .send({ - source: 'user', - commonKeychain: 'common-keychain-123', - }); + .send({ source: 'user', commonKeychain: 'common-keychain-123' }); response.status.should.equal(200); response.body.should.have.property('txid', 'test-tx-id'); response.body.should.have.property('tx', 'signed-transaction-hex'); - walletGetNock.done(); - keychainGetNock.done(); + walletNock.done(); + keychainNock.done(); getTxRequestNock.done(); - sinon.assert.calledOnce(signAndSendStub); - - sinon.restore(); + nocks.round1SignNock.done(); + nocks.round2SignNock.done(); + nocks.round3SignNock.done(); + nocks.sendTxNock.done(); + nocks.awmRound1Nock.done(); + nocks.awmRound2Nock.done(); + nocks.awmRound3Nock.done(); }); it('should handle pending approval response', async () => { - // Mock wallet get request - const walletGetNock = nock(bitgoApiUrl) - .get(`/api/v2/${coin}/wallet/${walletId}`) - .matchHeader('any', () => true) - .reply(200, { - id: walletId, - type: 'advanced', - keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'], - multisigType: 'tss', - coin: 'hteth', - }); - - // Mock keychain get request - const keychainGetNock = nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/user-key-id`) - .matchHeader('any', () => true) - .reply(200, { - id: 'user-key-id', - pub: 'xpub_user', - commonKeychain: 'common-keychain-123', - source: 'user', - }); - - // Mock getTxRequest - const txRequest: TxRequest = { - txRequestId, - apiVersion: 'full', - enterpriseId: 'test-enterprise-id', - transactions: [], - state: 'pendingUserSignature', - walletId, - walletType: 'hot', - version: 2, - date: new Date().toISOString(), - userId: 'test-user-id', - intent: {}, - policiesChecked: true, - unsignedTxs: [], - latest: true, - }; + const { walletNock, keychainNock } = nockWalletAndSigningKeychain(coin); const getTxRequestNock = nock(bitgoApiUrl) .get(`/api/v2/wallet/${walletId}/txrequests`) - .query({ txRequestIds: 'test-tx-request-id', latest: true }) + .query({ txRequestIds: txRequestId, latest: true }) .matchHeader('any', () => true) - .reply(200, { - txRequests: [txRequest], - }); + .reply(200, { txRequests: [buildEcdsaMpcv2TxRequest('pendingUserSignature')] }); - const signAndSendStub = sinon.stub(mpcv2, 'signAndSendEcdsaMPCv2FromTxRequest').resolves({ - ...txRequest, - state: 'pendingApproval', - pendingApprovalId: 'pending-approval-id', + const pendingApprovalTxRequest = buildEcdsaMpcv2TxRequest('pendingApproval', { + extra: { pendingApprovalId: 'pending-approval-id' }, + }); + const nocks = nockEcdsaMpcv2SigningFlow({ + coin, + bitgoApiUrl, + advancedWalletManagerUrl, + sendResponse: pendingApprovalTxRequest, + includeBitgoKeychainNock: true, }); - const pendingApprovalData = { - id: 'pending-approval-id', - wallet: 'test-wallet-id', - state: 'pending' as State, - creator: 'test-user-id', - info: { - type: 'transactionRequestFull' as Type, - transactionRequestFull: { - ...txRequest, - }, - }, - }; - - const mockPendingApproval = new PendingApproval(bitgo, baseCoin, pendingApprovalData, wallet); - - sinon.stub(PendingApprovals.prototype, 'get').resolves(mockPendingApproval); + const pendingApprovalNock = nock(bitgoApiUrl) + .get(`/api/v2/${coin}/pendingapprovals/pending-approval-id`) + .matchHeader('any', () => true) + .reply(200, { + id: 'pending-approval-id', + wallet: walletId, + state: 'pending', + creator: 'test-user-id', + info: { type: 'transactionRequestFull' }, + }); const response = await agent .post(`/api/v1/${coin}/advancedwallet/${walletId}/txrequest/${txRequestId}/signAndSend`) .set('Authorization', `Bearer ${accessToken}`) - .send({ - source: 'user', - commonKeychain: 'common-keychain-123', - }); + .send({ source: 'user', commonKeychain: 'common-keychain-123' }); response.status.should.equal(200); response.body.should.have.property('pendingApproval'); response.body.should.have.property('txRequest'); response.body.pendingApproval.should.have.property('id', 'pending-approval-id'); - walletGetNock.done(); - keychainGetNock.done(); + walletNock.done(); + keychainNock.done(); getTxRequestNock.done(); - sinon.assert.calledOnce(signAndSendStub); - - sinon.restore(); + nocks.round1SignNock.done(); + nocks.round2SignNock.done(); + nocks.round3SignNock.done(); + nocks.sendTxNock.done(); + nocks.awmRound1Nock.done(); + nocks.awmRound2Nock.done(); + nocks.awmRound3Nock.done(); + pendingApprovalNock.done(); }); }); @@ -267,43 +190,22 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/txrequest/:txRequestId/sig const eddsaCoin = 'tsol'; // Use tsol for EdDSA testing it('should successfully sign and send EdDSA transaction', async () => { - // Mock wallet get request - const walletGetNock = nock(bitgoApiUrl) - .get(`/api/v2/${eddsaCoin}/wallet/${walletId}`) - .matchHeader('any', () => true) - .reply(200, { - id: walletId, - type: 'advanced', - keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'], - multisigType: 'tss', - coin: 'tsol', - }); - - // Mock keychain get request - const keychainGetNock = nock(bitgoApiUrl) - .get(`/api/v2/${eddsaCoin}/key/user-key-id`) - .matchHeader('any', () => true) - .reply(200, { - id: 'user-key-id', - pub: 'xpub_user', - commonKeychain: 'common-keychain-123', - source: 'user', - }); - - // Mock getTxRequest - const txRequest: TxRequest = { + const pending = { txRequestId, apiVersion: 'full', enterpriseId: 'test-enterprise-id', transactions: [ { + state: 'pendingSignature', unsignedTx: { derivationPath: 'm/0', signableHex: 'testMessage', - serializedTxHex: 'testMessage', + serializedTxHex: 'testSerializedTxHex', }, - state: 'pendingSignature', - signatureShares: [], + signatureShares: [ + { share: 'bitgo-to-user-r-share', from: 'bitgo', to: 'user', type: 'r' }, + { share: 'user-to-bitgo-r-share', from: 'user', to: 'bitgo', type: 'r' }, + ], }, ], state: 'pendingUserSignature', @@ -317,49 +219,115 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/txrequest/:txRequestId/sig unsignedTxs: [], latest: true, }; + const signed = { + ...pending, + state: 'signed', + transactions: [ + { + ...pending.transactions[0], + state: 'signed', + signedTx: { id: 'test-tx-id', tx: 'signed-transaction-hex' }, + }, + ], + }; - const getTxRequestNock = nock(bitgoApiUrl) + const { walletNock, keychainNock } = nockWalletAndSigningKeychain(eddsaCoin); + + // pickBitgoPubGpgKeyForSigning resolves the BitGo GPG key from the keychain hsmType + nock(bitgoApiUrl) + .get(`/api/v2/${eddsaCoin}/key/bitgo-key-id`) + .matchHeader('any', () => true) + .reply(200, { + id: 'bitgo-key-id', + pub: 'xpub_bitgo', + commonKeychain: 'common-keychain-123', + source: 'bitgo', + type: 'tss', + hsmType: 'institutional', + }); + + // Three GET /txrequests calls + const handlerGetTxRequestNock = nock(bitgoApiUrl) + .get(`/api/v2/wallet/${walletId}/txrequests`) + .query({ txRequestIds: txRequestId, latest: true }) + .matchHeader('any', () => true) + .reply(200, { txRequests: [pending] }); + + const exchangeCommitmentsNock = nock(bitgoApiUrl) + .post(`/api/v2/wallet/${walletId}/txrequests/${txRequestId}/transactions/0/commit`) + .matchHeader('any', () => true) + .reply(200, { commitmentShare: { share: 'bitgo-commitment-share' } }); + + const offerRShareNock = nock(bitgoApiUrl) + .post(`/api/v2/wallet/${walletId}/txrequests/${txRequestId}/transactions/0/signatureshares`) + .matchHeader('any', () => true) + .reply(200, { share: 'user-to-bitgo-r-share', from: 'bitgo', to: 'user' }); + + const getBitgoRShareNock = nock(bitgoApiUrl) + .get(`/api/v2/wallet/${walletId}/txrequests`) + .query({ txRequestIds: txRequestId, latest: true }) + .matchHeader('any', () => true) + .reply(200, { txRequests: [pending] }); + + const sendGShareNock = nock(bitgoApiUrl) + .post(`/api/v2/wallet/${walletId}/txrequests/${txRequestId}/transactions/0/signatureshares`) + .matchHeader('any', () => true) + .reply(200, { share: 'user-to-bitgo-g-share', from: 'bitgo', to: 'user' }); + + const finalGetTxRequestNock = nock(bitgoApiUrl) .get(`/api/v2/wallet/${walletId}/txrequests`) - .query({ txRequestIds: 'test-tx-request-id', latest: true }) + .query({ txRequestIds: txRequestId, latest: true }) .matchHeader('any', () => true) + .reply(200, { txRequests: [signed] }); + + const signMpcCommitmentNockAwm = nock(advancedWalletManagerUrl) + .post(`/api/${eddsaCoin}/mpc/sign/commitment`) .reply(200, { - txRequests: [txRequest], + userToBitgoCommitment: { share: 'user-commitment-share' }, + encryptedSignerShare: { share: 'encrypted-signer-share' }, + encryptedUserToBitgoRShare: { share: 'encrypted-user-to-bitgo-r-share' }, + encryptedDataKey: 'test-encrypted-data-key', }); - const signAndSendStub = sinon.stub(eddsa, 'handleEddsaSigning').resolves({ - ...txRequest, - state: 'signed', - transactions: [ - { - ...(txRequest.transactions || [])[0], - signedTx: { - id: 'test-tx-id', - tx: 'signed-transaction-hex', - }, + const signMpcRShareNockAwm = nock(advancedWalletManagerUrl) + .post(`/api/${eddsaCoin}/mpc/sign/r`) + .reply(200, { + rShare: { + rShares: [ + { r: 'r-share', R: 'R-share' }, + { r: 'r-share-2', R: 'R-share-2' }, + { r: 'r-share-3', R: 'R-share-3' }, + { r: 'r-share-4', R: 'R-share-4', i: 3, j: 1 }, + ], }, - ], - }); + }); + + const signMpcGShareNockAwm = nock(advancedWalletManagerUrl) + .post(`/api/${eddsaCoin}/mpc/sign/g`) + .reply(200, { gShare: { r: 'r', gamma: 'gamma', i: 1, j: 3, n: 4 } }); const response = await agent .post( `/api/v1/${eddsaCoin}/advancedwallet/${walletId}/txrequest/${txRequestId}/signAndSend`, ) .set('Authorization', `Bearer ${accessToken}`) - .send({ - source: 'user', - commonKeychain: 'common-keychain-123', - }); + .send({ source: 'user', commonKeychain: 'common-keychain-123' }); response.status.should.equal(200); response.body.should.have.property('txid', 'test-tx-id'); response.body.should.have.property('tx', 'signed-transaction-hex'); - walletGetNock.done(); - keychainGetNock.done(); - getTxRequestNock.done(); - sinon.assert.calledOnce(signAndSendStub); - - sinon.restore(); + walletNock.done(); + keychainNock.done(); + handlerGetTxRequestNock.done(); + exchangeCommitmentsNock.done(); + offerRShareNock.done(); + getBitgoRShareNock.done(); + sendGShareNock.done(); + finalGetTxRequestNock.done(); + signMpcCommitmentNockAwm.done(); + signMpcRShareNockAwm.done(); + signMpcGShareNockAwm.done(); }); }); }); diff --git a/src/__tests__/api/master/testUtils.ts b/src/__tests__/api/master/testUtils.ts index 22653dd..4edaacf 100644 --- a/src/__tests__/api/master/testUtils.ts +++ b/src/__tests__/api/master/testUtils.ts @@ -1,4 +1,6 @@ import { BitGoAPI } from '@bitgo-beta/sdk-api'; +import { SignatureShareRecord, SignatureShareType } from '@bitgo-beta/sdk-core'; +import nock from 'nock'; export class BitGoAPITestHarness extends BitGoAPI { static clearConstantsCache(): void { @@ -6,3 +8,311 @@ export class BitGoAPITestHarness extends BitGoAPI { BitGoAPI._constantsExpire = {}; } } + +export const DEFAULT_ECDSA_MPCV2_WALLET_ID = 'test-wallet-id'; +export const DEFAULT_ECDSA_MPCV2_TX_REQUEST_ID = 'test-tx-request-id'; + +export function createEcdsaMpcv2SignatureShares(): { + round1SignatureShare: SignatureShareRecord; + round2SignatureShare: SignatureShareRecord; + round3SignatureShare: SignatureShareRecord; +} { + const round1SignatureShare: SignatureShareRecord = { + from: SignatureShareType.USER, + to: SignatureShareType.BITGO, + share: JSON.stringify({ + type: 'round1Input', + data: { msg1: { from: 1, message: 'round1-message' } }, + }), + }; + const round2SignatureShare: SignatureShareRecord = { + from: SignatureShareType.USER, + to: SignatureShareType.BITGO, + share: JSON.stringify({ + type: 'round2Input', + data: { + msg2: { from: 1, to: 3, encryptedMessage: 'round2-message', signature: 'round2-signature' }, + msg3: { from: 1, to: 3, encryptedMessage: 'round3-message', signature: 'round3-signature' }, + }, + }), + }; + const round3SignatureShare: SignatureShareRecord = { + from: SignatureShareType.USER, + to: SignatureShareType.BITGO, + share: JSON.stringify({ + type: 'round3Input', + data: { + msg4: { + from: 1, + message: 'round4-message', + signature: 'round4-signature', + signatureR: 'round4-signature-r', + }, + }, + }), + }; + return { round1SignatureShare, round2SignatureShare, round3SignatureShare }; +} + +export function buildEcdsaMpcv2TxRequest( + state: string, + options: { + walletId?: string; + txRequestId?: string; + serializedTxHex?: string; + extra?: Record; + } = {}, +) { + const walletId = options.walletId ?? DEFAULT_ECDSA_MPCV2_WALLET_ID; + const txRequestId = options.txRequestId ?? DEFAULT_ECDSA_MPCV2_TX_REQUEST_ID; + const serializedTxHex = options.serializedTxHex ?? 'testMessage'; + + return { + txRequestId, + apiVersion: 'full', + enterpriseId: 'test-enterprise-id', + transactions: [ + { + unsignedTx: { + derivationPath: 'm/0', + signableHex: 'testMessage', + serializedTxHex, + }, + state: 'pendingSignature', + signatureShares: [] as SignatureShareRecord[], + }, + ], + state, + walletId, + walletType: 'hot', + version: 2, + date: new Date().toISOString(), + userId: 'test-user-id', + intent: {}, + policiesChecked: true, + unsignedTxs: [], + latest: true, + ...options.extra, + }; +} + +export function buildSignedEcdsaMpcv2TxRequest( + options: { + walletId?: string; + txRequestId?: string; + serializedTxHex?: string; + signedTxId?: string; + signedTxHex?: string; + } = {}, +) { + const pending = buildEcdsaMpcv2TxRequest('pendingUserSignature', options); + return { + ...pending, + state: 'signed', + transactions: [ + { + ...pending.transactions[0], + state: 'signed', + signedTx: { + id: options.signedTxId ?? 'test-tx-id', + tx: options.signedTxHex ?? 'signed-transaction', + }, + }, + ], + }; +} + +export interface NockEcdsaMpcv2SigningFlowOptions { + coin: string; + bitgoApiUrl: string; + advancedWalletManagerUrl: string; + sendResponse: ReturnType; + walletId?: string; + txRequestId?: string; + userGpgPubKey?: string; + commonKeychain?: string; + /** When true, nocks GET bitgo keychain (required for pickBitgoPubGpgKeyForSigning in test env). */ + includeBitgoKeychainNock?: boolean; + /** Base tx request for BitGo sign round replies. */ + pendingTxRequest?: ReturnType; +} + +/** + * Nocks BitGo sign/send and AWM mpcv2round1/2/3 for ECDSA MPCv2 external signing. + */ +export function nockEcdsaMpcv2SigningFlow(options: NockEcdsaMpcv2SigningFlowOptions) { + const walletId = options.walletId ?? DEFAULT_ECDSA_MPCV2_WALLET_ID; + const txRequestId = options.txRequestId ?? DEFAULT_ECDSA_MPCV2_TX_REQUEST_ID; + const userGpgPubKey = options.userGpgPubKey ?? 'user-gpg-pub-key'; + const commonKeychain = options.commonKeychain ?? 'common-keychain-123'; + + const { round1SignatureShare, round2SignatureShare, round3SignatureShare } = + createEcdsaMpcv2SignatureShares(); + + const pending = + options.pendingTxRequest ?? + buildEcdsaMpcv2TxRequest('pendingUserSignature', { walletId, txRequestId }); + + if (options.includeBitgoKeychainNock) { + nock(options.bitgoApiUrl) + .get(`/api/v2/${options.coin}/key/bitgo-key-id`) + .matchHeader('any', () => true) + .reply(200, { + id: 'bitgo-key-id', + pub: 'xpub_bitgo', + commonKeychain, + source: 'bitgo', + type: 'tss', + hsmType: 'institutional', + }); + } + + const round1SignNock = nock(options.bitgoApiUrl) + .post(`/api/v2/wallet/${walletId}/txrequests/${txRequestId}/transactions/0/sign`) + .matchHeader('any', () => true) + .reply(200, { + ...pending, + transactions: [{ ...pending.transactions[0], signatureShares: [round1SignatureShare] }], + }); + + const round2SignNock = nock(options.bitgoApiUrl) + .post(`/api/v2/wallet/${walletId}/txrequests/${txRequestId}/transactions/0/sign`) + .matchHeader('any', () => true) + .reply(200, { + ...pending, + transactions: [ + { + ...pending.transactions[0], + signatureShares: [round1SignatureShare, round2SignatureShare], + }, + ], + }); + + const round3SignNock = nock(options.bitgoApiUrl) + .post(`/api/v2/wallet/${walletId}/txrequests/${txRequestId}/transactions/0/sign`) + .matchHeader('any', () => true) + .reply(200, { + ...pending, + transactions: [ + { + ...pending.transactions[0], + signatureShares: [round1SignatureShare, round2SignatureShare, round3SignatureShare], + }, + ], + }); + + const sendTxNock = nock(options.bitgoApiUrl) + .post(`/api/v2/wallet/${walletId}/txrequests/${txRequestId}/transactions/0/send`) + .matchHeader('any', () => true) + .reply(200, options.sendResponse); + + const awmRound1Nock = nock(options.advancedWalletManagerUrl) + .post(`/api/${options.coin}/mpc/sign/mpcv2round1`) + .reply(200, { + signatureShareRound1: round1SignatureShare, + userGpgPubKey, + encryptedRound1Session: 'encrypted-round1-session', + encryptedUserGpgPrvKey: 'encrypted-user-gpg-prv-key', + encryptedDataKey: 'test-encrypted-data-key', + }); + + const awmRound2Nock = nock(options.advancedWalletManagerUrl) + .post(`/api/${options.coin}/mpc/sign/mpcv2round2`) + .reply(200, { + signatureShareRound2: round2SignatureShare, + encryptedRound2Session: 'encrypted-round2-session', + }); + + const awmRound3Nock = nock(options.advancedWalletManagerUrl) + .post(`/api/${options.coin}/mpc/sign/mpcv2round3`) + .reply(200, { signatureShareRound3: round3SignatureShare }); + + return { + round1SignNock, + round2SignNock, + round3SignNock, + sendTxNock, + awmRound1Nock, + awmRound2Nock, + awmRound3Nock, + }; +} + +export interface NockEcdsaMpcv2SendManySigningFlowOptions { + coin: string; + walletId: string; + bitgoApiUrl: string; + advancedWalletManagerUrl: string; + txRequestId?: string; + serializedTxHex?: string; + commonKeychain?: string; +} + +/** + * Nocks ECDSA MPCv2 flow for sendMany (create tx request, persist getTxRequest, transfer, etc.). + */ +export function nockEcdsaMpcv2SendManySigningFlow( + options: NockEcdsaMpcv2SendManySigningFlowOptions, +) { + const txRequestId = options.txRequestId ?? DEFAULT_ECDSA_MPCV2_TX_REQUEST_ID; + const serializedTxHex = options.serializedTxHex ?? 'testSerializedTxHex'; + const commonKeychain = options.commonKeychain ?? 'test-common-keychain'; + + const pendingTxRequest = buildEcdsaMpcv2TxRequest('pendingUserSignature', { + walletId: options.walletId, + txRequestId, + serializedTxHex, + }); + const signedTxRequest = buildSignedEcdsaMpcv2TxRequest({ + walletId: options.walletId, + txRequestId, + serializedTxHex, + }); + + nock(options.bitgoApiUrl) + .persist() + .get(`/api/v2/${options.coin}/key/user-key-id`) + .matchHeader('any', () => true) + .reply(200, { + id: 'user-key-id', + pub: 'xpub_user', + commonKeychain, + source: 'user', + type: 'tss', + }); + + const createTxRequestNock = nock(options.bitgoApiUrl) + .post(`/api/v2/wallet/${options.walletId}/txrequests`) + .matchHeader('any', () => true) + .reply(200, pendingTxRequest); + + nock(options.bitgoApiUrl) + .persist() + .get(`/api/v2/wallet/${options.walletId}/txrequests`) + .query(true) + .matchHeader('any', () => true) + .reply(200, { txRequests: [signedTxRequest] }); + + const transferNock = nock(options.bitgoApiUrl) + .post(`/api/v2/wallet/${options.walletId}/txrequests/${txRequestId}/transfers`) + .matchHeader('any', () => true) + .reply(200, { state: 'signed' }); + + const signingNocks = nockEcdsaMpcv2SigningFlow({ + coin: options.coin, + bitgoApiUrl: options.bitgoApiUrl, + advancedWalletManagerUrl: options.advancedWalletManagerUrl, + walletId: options.walletId, + txRequestId, + sendResponse: pendingTxRequest, + pendingTxRequest, + includeBitgoKeychainNock: true, + commonKeychain, + }); + + return { + createTxRequestNock, + transferNock, + ...signingNocks, + }; +}