diff --git a/src/__tests__/api/master/ecdsa.test.ts b/src/__tests__/api/master/ecdsa.test.ts index a112d73..11cdadb 100644 --- a/src/__tests__/api/master/ecdsa.test.ts +++ b/src/__tests__/api/master/ecdsa.test.ts @@ -2,7 +2,6 @@ import 'should'; import nock from 'nock'; import * as sinon from 'sinon'; import { - EcdsaMPCv2Utils, Environments, IRequestTracer, openpgpUtils, @@ -13,10 +12,10 @@ import { 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 { BitGoAPI } from '@bitgo-beta/sdk-api'; -import { readKey } from 'openpgp'; +import { BitGoAPITestHarness } from './testUtils'; describe('Ecdsa Signing Handler', () => { let bitgo: BitGoAPI; @@ -57,6 +56,7 @@ describe('Ecdsa Signing Handler', () => { afterEach(() => { nock.cleanAll(); sinon.restore(); + BitGoAPITestHarness.clearConstantsCache(); }); after(() => { @@ -84,8 +84,19 @@ describe('Ecdsa Signing Handler', () => { const userPubKey = 'test-user-pub-key'; const bitgoGpgKey = await openpgpUtils.generateGPGKeyPair('secp256k1'); - const pgpKey = await readKey({ armoredKey: bitgoGpgKey.publicKey }); - sinon.stub(EcdsaMPCv2Utils.prototype, 'getBitgoMpcv2PublicGpgKey').resolves(pgpKey); + const bitgoEd25519Key = await openpgpUtils.generateGPGKeyPair('ed25519'); + + nock(bitgoApiUrl) + .persist() + .get('/api/v1/client/constants') + .reply(200, { + constants: { + mpc: { + bitgoMPCv2PublicKey: bitgoGpgKey.publicKey, + bitgoPublicKey: bitgoEd25519Key.publicKey, + }, + }, + }); // Mock sendSignatureShareV2 calls for each round const round1SignatureShare: SignatureShareRecord = { diff --git a/src/__tests__/api/master/eddsa.test.ts b/src/__tests__/api/master/eddsa.test.ts index 565ce8a..eac7bdc 100644 --- a/src/__tests__/api/master/eddsa.test.ts +++ b/src/__tests__/api/master/eddsa.test.ts @@ -3,7 +3,6 @@ import nock from 'nock'; import * as sinon from 'sinon'; import { BitGoBase, - EddsaUtils, Environments, IRequestTracer, openpgpUtils, @@ -14,7 +13,7 @@ import { import { BitGoAPI } from '@bitgo-beta/sdk-api'; import { AdvancedWalletManagerClient as AdvancedWalletManagerClient } from '../../../masterBitgoExpress/clients/advancedWalletManagerClient'; import { handleEddsaSigning } from '../../../masterBitgoExpress/handlers/eddsa'; -import { readKey } from 'openpgp'; +import { BitGoAPITestHarness } from './testUtils'; describe('Eddsa Signing Handler', () => { let bitgo: BitGoBase; @@ -52,6 +51,7 @@ describe('Eddsa Signing Handler', () => { afterEach(() => { nock.cleanAll(); sinon.restore(); + BitGoAPITestHarness.clearConstantsCache(); }); after(() => { @@ -100,10 +100,10 @@ describe('Eddsa Signing Handler', () => { const userPubKey = 'test-user-pub-key'; const bitgoGpgKey = await openpgpUtils.generateGPGKeyPair('ed25519'); - const getGPGKeysStub = sinon.stub().resolves([{ pub: bitgoGpgKey.publicKey }]); - const pgpKey = await readKey({ armoredKey: bitgoGpgKey.publicKey }); - sinon.stub(EddsaUtils.prototype, 'getBitgoPublicGpgKey').resolves(pgpKey); + nock(bitgoApiUrl) + .get('/api/v1/client/constants') + .reply(200, { constants: { mpc: { bitgoPublicKey: bitgoGpgKey.publicKey } } }); // Mock exchangeEddsaCommitments call const exchangeCommitmentsNock = nock(bitgoApiUrl) @@ -226,8 +226,6 @@ describe('Eddsa Signing Handler', () => { }, }); - (bitgo as any).getGPGKeys = getGPGKeysStub; - const result = await handleEddsaSigning(bitgo, wallet, txRequest, awmClient, userPubKey, reqId); result.should.eql({ diff --git a/src/__tests__/api/master/generateWallet.test.ts b/src/__tests__/api/master/generateWallet.test.ts index 5e5aba0..b169c0e 100644 --- a/src/__tests__/api/master/generateWallet.test.ts +++ b/src/__tests__/api/master/generateWallet.test.ts @@ -10,15 +10,7 @@ import { Environments } from '@bitgo-beta/sdk-core'; import { BitGoAPI } from '@bitgo-beta/sdk-api'; import * as middleware from '../../../shared/middleware'; import { BitGoRequest } from '../../../types/request'; - -/** - * This test suite demonstrates how to mock the BitGo SDK's fetchConstants method - * instead of using nock to intercept HTTP requests to the constants endpoint. - * - * By using sinon to stub the fetchConstants method directly, we make the tests more - * focused on behavior rather than implementation details, and less brittle to changes - * in how the constants are fetched. - */ +import { BitGoAPITestHarness } from './testUtils'; function mockWalletResponse(id: string, coinName: string, overrides: Record = {}) { return { @@ -110,6 +102,7 @@ describe('POST /api/v1/:coin/advancedwallet/generate', () => { afterEach(() => { nock.cleanAll(); sinon.restore(); + BitGoAPITestHarness.clearConstantsCache(); }); it('should generate an onchain wallet with separate backup AWM (separate-HSM mode)', async () => { @@ -352,6 +345,12 @@ describe('POST /api/v1/:coin/advancedwallet/generate', () => { const backupAwmUrl = 'http://backup-awm.invalid'; sinon.restore(); + // Register before new BitGoAPI() so the constructor's background fetchConstants() hits the nock; + // use persist() since the background fetch and the handler may both call the endpoint + nock(bitgoApiUrl) + .persist() + .get('/api/v1/client/constants') + .reply(200, { constants: { mpc: { bitgoPublicKey: 'test-bitgo-public-key' } } }); const backupBitgo = new BitGoAPI({ env: 'test' }); const configWithBackup: MasterExpressConfig = { appMode: AppMode.MASTER_EXPRESS, @@ -378,12 +377,6 @@ describe('POST /api/v1/:coin/advancedwallet/generate', () => { const app = expressApp(configWithBackup); const backupAgent = request.agent(app); - sinon.stub(backupBitgo, 'fetchConstants').resolves({ - mpc: { - bitgoPublicKey: 'test-bitgo-public-key', - }, - }); - // User init goes to primary AWM const userInitNock = nock(advancedWalletManagerUrl) .post(`/api/${eddsaCoin}/mpc/key/initialize`, { @@ -667,12 +660,10 @@ describe('POST /api/v1/:coin/advancedwallet/generate', () => { }); it('should generate a TSS MPC v1 wallet by calling the advanced wallet manager service', async () => { - // Mock fetchConstants instead of using nock for URL mocking - sinon.stub(bitgo, 'fetchConstants').resolves({ - mpc: { - bitgoPublicKey: 'test-bitgo-public-key', - }, - }); + nock(bitgoApiUrl) + .persist() + .get('/api/v1/client/constants') + .reply(200, { constants: { mpc: { bitgoPublicKey: 'test-bitgo-public-key' } } }); const userInitNock = nock(advancedWalletManagerUrl) .post(`/api/${eddsaCoin}/mpc/key/initialize`, { @@ -991,7 +982,6 @@ describe('POST /api/v1/:coin/advancedwallet/generate', () => { multisigType: 'tss', }); - // No need to check constantsNock since we're using sinon stub userInitNock.done(); backupInitNock.done(); bitgoAddKeychainNock.done(); @@ -1007,6 +997,12 @@ describe('POST /api/v1/:coin/advancedwallet/generate', () => { const backupAwmUrl = 'http://backup-awm.invalid'; sinon.restore(); + // Register before new BitGoAPI() so the constructor's background fetchConstants() hits the nock; + // use persist() since the background fetch and the handler may both call the endpoint + nock(bitgoApiUrl) + .persist() + .get('/api/v1/client/constants') + .reply(200, { constants: { mpc: { bitgoMPCv2PublicKey: 'test-bitgo-public-key' } } }); const backupBitgo = new BitGoAPI({ env: 'test' }); const configWithBackup: MasterExpressConfig = { appMode: AppMode.MASTER_EXPRESS, @@ -1033,12 +1029,6 @@ describe('POST /api/v1/:coin/advancedwallet/generate', () => { const app = expressApp(configWithBackup); const backupAgent = request.agent(app); - sinon.stub(backupBitgo, 'fetchConstants').resolves({ - mpc: { - bitgoMPCv2PublicKey: 'test-bitgo-public-key', - }, - }); - // Init: user goes to primary AWM, backup goes to backup AWM const userInitNock = nock(advancedWalletManagerUrl) .post(`/api/${ecdsaCoin}/mpcv2/initialize`, { source: 'user' }) @@ -1641,12 +1631,11 @@ describe('POST /api/v1/:coin/advancedwallet/generate', () => { }); it('should generate a TSS MPC v2 wallet by calling the advanced wallet manager service', async () => { - // Mock fetchConstants instead of using nock for URL mocking - sinon.stub(bitgo, 'fetchConstants').resolves({ - mpc: { - bitgoMPCv2PublicKey: 'test-bitgo-public-key', - }, - }); + nock(bitgoApiUrl) + .persist() + .get('/api/v1/client/constants') + .reply(200, { constants: { mpc: { bitgoMPCv2PublicKey: 'test-bitgo-public-key' } } }); + // init round const userInitNock = nock(advancedWalletManagerUrl) .post(`/api/${ecdsaCoin}/mpcv2/initialize`, { @@ -2287,7 +2276,6 @@ describe('POST /api/v1/:coin/advancedwallet/generate', () => { type: 'advanced', }); - // No need to check constantsNock since we're using sinon stub userInitNock.done(); backupInitNock.done(); userRound1Nock.done(); diff --git a/src/__tests__/api/master/sendMany.test.ts b/src/__tests__/api/master/sendMany.test.ts index 1c726b0..53f4380 100644 --- a/src/__tests__/api/master/sendMany.test.ts +++ b/src/__tests__/api/master/sendMany.test.ts @@ -5,16 +5,328 @@ import * as request from 'supertest'; import nock from 'nock'; import { app as expressApp } from '../../../masterBitGoExpressApp'; import { AppMode, MasterExpressConfig, TlsMode } from '../../../shared/types'; -import { Environments, Wallet } from '@bitgo-beta/sdk-core'; +import { + Environments, + openpgpUtils, + SignatureShareRecord, + SignatureShareType, +} 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'; + +const testWalletId = 'test-wallet-id'; +const testBitgoApiUrl = Environments.test.uri; +const tssTxRequestId = 'test-tx-request-id'; + +const TBTC_PREBUILD_PSBT_HEX = utxolib.bitgo + .createPsbtForNetwork({ network: utxolib.networks.testnet }) + .toHex(); + +function buildPendingEdDsaTxRequest(walletIdParam: string) { + return { + txRequestId: tssTxRequestId, + apiVersion: 'full', + enterpriseId: 'test-enterprise-id', + transactions: [ + { + state: 'pendingSignature', + unsignedTx: { + derivationPath: 'm/0', + signableHex: 'testMessage', + serializedTxHex: 'testSerializedTxHex', + }, + signatureShares: [ + { share: 'bitgo-to-user-r-share', from: 'bitgo', to: 'user' }, + { share: 'user-to-bitgo-r-share', from: 'user', to: 'bitgo' }, + ], + }, + ], + state: 'pendingUserSignature', + walletId: walletIdParam, + walletType: 'hot', + version: 2, + date: new Date().toISOString(), + userId: 'test-user-id', + intent: {}, + policiesChecked: true, + unsignedTxs: [], + latest: true, + }; +} + +function buildSignedEdDsaTxRequest(walletIdParam: string) { + const pending = buildPendingEdDsaTxRequest(walletIdParam); + return { + ...pending, + state: 'signed', + transactions: [ + { + ...pending.transactions[0], + state: 'signed', + signedTx: { id: 'test-tx-id', tx: 'signed-transaction' }, + }, + ], + }; +} + +function nockTssWalletKeychains(coinName: string) { + nock(testBitgoApiUrl) + .get(`/api/v2/${coinName}/key/user-key-id`) + .matchHeader('any', () => true) + .times(10) + .reply(200, { + id: 'user-key-id', + pub: 'xpub_user', + commonKeychain: 'test-common-keychain', + source: 'user', + type: 'tss', + }); + nock(testBitgoApiUrl) + .get(`/api/v2/${coinName}/key/backup-key-id`) + .matchHeader('any', () => true) + .times(10) + .reply(200, { + id: 'backup-key-id', + pub: 'xpub_backup', + commonKeychain: 'test-common-keychain', + source: 'backup', + type: 'tss', + }); + nock(testBitgoApiUrl) + .get(`/api/v2/${coinName}/key/bitgo-key-id`) + .matchHeader('any', () => true) + .times(10) + .reply(200, { + id: 'bitgo-key-id', + pub: 'xpub_bitgo', + commonKeychain: 'test-common-keychain', + source: 'bitgo', + type: 'tss', + hsmType: 'institutional', + }); +} + +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'; const bitgoApiUrl = Environments.test.uri; const accessToken = 'test-token'; - const walletId = 'test-wallet-id'; + const walletId = testWalletId; const coin = 'tbtc'; before(() => { @@ -43,6 +355,7 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/sendMany', () => { afterEach(() => { nock.cleanAll(); sinon.restore(); + BitGoAPITestHarness.clearConstantsCache(); }); describe('SendMany Multisig:', () => { @@ -76,15 +389,13 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/sendMany', () => { .matchHeader('any', () => true) .reply(200, { id: 'bitgo-key-id', pub: 'xpub_bitgo' }); - const prebuildStub = sinon.stub(Wallet.prototype, 'prebuildTransaction').resolves({ - txHex: 'prebuilt-tx-hex', - txInfo: { - nP2SHInputs: 1, - nSegwitInputs: 0, - nOutputs: 2, - }, - walletId, - }); + const prebuildBuildNock = nock(bitgoApiUrl) + .post(`/api/v2/${coin}/wallet/${walletId}/tx/build`) + .reply(200, { + txHex: TBTC_PREBUILD_PSBT_HEX, + txInfo: { nP2SHInputs: 1, nSegwitInputs: 0, nOutputs: 2 }, + }); + nock(bitgoApiUrl).get(`/api/v2/${coin}/public/block/latest`).reply(200, { height: 800000 }); const verifyStub = sinon.stub(Tbtc.prototype, 'verifyTransaction').resolves(true); @@ -137,7 +448,7 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/sendMany', () => { response.body.should.have.property('status', 'signed'); walletGetNock.done(); - sinon.assert.calledOnce(prebuildStub); + prebuildBuildNock.done(); sinon.assert.calledOnce(verifyStub); keychainGetNock.done(); signNock.done(); @@ -171,11 +482,13 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/sendMany', () => { .matchHeader('any', () => true) .reply(200, { id: 'bitgo-key-id', pub: 'xpub_bitgo' }); - sinon.stub(Wallet.prototype, 'prebuildTransaction').resolves({ - txHex: 'prebuilt-tx-hex', - txInfo: { nP2SHInputs: 1, nSegwitInputs: 0, nOutputs: 2 }, - walletId, - }); + nock(bitgoApiUrl) + .post(`/api/v2/${coin}/wallet/${walletId}/tx/build`) + .reply(200, { + txHex: TBTC_PREBUILD_PSBT_HEX, + txInfo: { nP2SHInputs: 1, nSegwitInputs: 0, nOutputs: 2 }, + }); + nock(bitgoApiUrl).get(`/api/v2/${coin}/public/block/latest`).reply(200, { height: 800000 }); sinon.stub(Tbtc.prototype, 'verifyTransaction').resolves(true); @@ -239,11 +552,13 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/sendMany', () => { .matchHeader('any', () => true) .reply(200, { id: 'bitgo-key-id', pub: 'xpub_bitgo' }); - sinon.stub(Wallet.prototype, 'prebuildTransaction').resolves({ - txHex: 'prebuilt-tx-hex', - txInfo: { nP2SHInputs: 1, nSegwitInputs: 0, nOutputs: 2 }, - walletId, - }); + nock(bitgoApiUrl) + .post(`/api/v2/${coin}/wallet/${walletId}/tx/build`) + .reply(200, { + txHex: TBTC_PREBUILD_PSBT_HEX, + txInfo: { nP2SHInputs: 1, nSegwitInputs: 0, nOutputs: 2 }, + }); + nock(bitgoApiUrl).get(`/api/v2/${coin}/public/block/latest`).reply(200, { height: 800000 }); sinon.stub(Tbtc.prototype, 'verifyTransaction').resolves(true); @@ -307,15 +622,13 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/sendMany', () => { .matchHeader('any', () => true) .reply(200, { id: 'bitgo-key-id', pub: 'xpub_bitgo' }); - const prebuildStub = sinon.stub(Wallet.prototype, 'prebuildTransaction').resolves({ - txHex: 'prebuilt-tx-hex', - txInfo: { - nP2SHInputs: 1, - nSegwitInputs: 0, - nOutputs: 2, - }, - walletId, - }); + const prebuildBuildNock = nock(bitgoApiUrl) + .post(`/api/v2/${coin}/wallet/${walletId}/tx/build`) + .reply(200, { + txHex: TBTC_PREBUILD_PSBT_HEX, + txInfo: { nP2SHInputs: 1, nSegwitInputs: 0, nOutputs: 2 }, + }); + nock(bitgoApiUrl).get(`/api/v2/${coin}/public/block/latest`).reply(200, { height: 800000 }); const verifyStub = sinon.stub(Tbtc.prototype, 'verifyTransaction').resolves(true); @@ -364,7 +677,7 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/sendMany', () => { response.body.should.have.property('status', 'signed'); walletGetNock.done(); - sinon.assert.calledOnce(prebuildStub); + prebuildBuildNock.done(); sinon.assert.calledOnce(verifyStub); keychainGetNock.done(); signNock.done(); @@ -375,7 +688,15 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/sendMany', () => { describe('SendMany TSS EDDSA:', () => { const coin = 'tsol'; it('should send many transactions using EDDSA TSS signing', async () => { - // Mock wallet get request for TSS wallet + const bitgoGpgKey = await openpgpUtils.generateGPGKeyPair('ed25519'); + const pendingTxRequest = buildPendingEdDsaTxRequest(walletId); + const signedTxRequest = buildSignedEdDsaTxRequest(walletId); + + nock(bitgoApiUrl) + .persist() + .get('/api/v1/client/constants') + .reply(200, { constants: { mpc: { bitgoPublicKey: bitgoGpgKey.publicKey } } }); + const walletGetNock = nock(bitgoApiUrl) .get(`/api/v2/${coin}/wallet/${walletId}`) .matchHeader('any', () => true) @@ -386,45 +707,81 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/sendMany', () => { multisigType: 'tss', }); - // Mock keychain get request for TSS keychain - const keychainGetNock = nock(bitgoApiUrl) - .get(`/api/v2/${coin}/key/user-key-id`) + nockTssWalletKeychains(coin); + sinon.stub(Tsol.prototype, 'verifyTransaction').resolves(true); + + let capturedTxRequestBody: Record | undefined; + const createTxRequestNock = nock(bitgoApiUrl) + .post(`/api/v2/wallet/${walletId}/txrequests`, (body) => { + capturedTxRequestBody = body; + return true; + }) + .matchHeader('any', () => true) + .reply(200, pendingTxRequest); + + const deleteSigSharesNock = nock(bitgoApiUrl) + .delete(`/api/v2/wallet/${walletId}/txrequests/${tssTxRequestId}/signatureshares`) + .matchHeader('any', () => true) + .reply(200, []); + + const exchangeCommitmentsNock = nock(bitgoApiUrl) + .post(`/api/v2/wallet/${walletId}/txrequests/${tssTxRequestId}/transactions/0/commit`) + .matchHeader('any', () => true) + .reply(200, { commitmentShare: { share: 'bitgo-commitment-share' } }); + + const offerRShareNock = nock(bitgoApiUrl) + .post( + `/api/v2/wallet/${walletId}/txrequests/${tssTxRequestId}/transactions/0/signatureshares`, + ) + .matchHeader('any', () => true) + .reply(200, { share: 'user-to-bitgo-r-share', from: 'bitgo', to: 'user' }); + + nock(bitgoApiUrl) + .persist() + .get(`/api/v2/wallet/${walletId}/txrequests`) + .query(true) + .matchHeader('any', () => true) + .reply(200, { txRequests: [signedTxRequest] }); + + const sendGShareNock = nock(bitgoApiUrl) + .post( + `/api/v2/wallet/${walletId}/txrequests/${tssTxRequestId}/transactions/0/signatureshares`, + ) + .matchHeader('any', () => true) + .reply(200, { share: 'user-to-bitgo-g-share', from: 'bitgo', to: 'user' }); + + const transferNock = nock(bitgoApiUrl) + .post(`/api/v2/wallet/${walletId}/txrequests/${tssTxRequestId}/transfers`) .matchHeader('any', () => true) + .reply(200, { state: 'signed' }); + + const signMpcCommitmentNockAwm = nock(advancedWalletManagerUrl) + .post(`/api/${coin}/mpc/sign/commitment`) .reply(200, { - id: 'user-key-id', - pub: 'xpub_user', - commonKeychain: 'test-common-keychain', - source: 'user', - type: 'tss', + userToBitgoCommitment: { share: 'user-commitment-share' }, + encryptedSignerShare: { share: 'encrypted-signer-share' }, + encryptedUserToBitgoRShare: { share: 'encrypted-user-to-bitgo-r-share' }, + encryptedDataKey: 'test-encrypted-data-key', + }); + + const signMpcRShareNockAwm = nock(advancedWalletManagerUrl) + .post(`/api/${coin}/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 sendManyStub = sinon.stub(Wallet.prototype, 'sendMany').resolves({ - txRequest: { - txRequestId: 'test-tx-request-id', - state: 'signed', - apiVersion: 'full', - pendingApprovalId: 'test-pending-approval-id', - transactions: [ - { - state: 'signed', - unsignedTx: { - derivationPath: 'm/0', - signableHex: 'testMessage', - serializedTxHex: 'testSerializedTxHex', - }, - signatureShares: [], - signedTx: { - id: 'test-tx-id', - tx: 'signed-transaction', - }, - }, - ], - }, - txid: 'test-tx-id', - tx: 'signed-transaction', - }); - // Mock multisigType to return 'tss' - const multisigTypeStub = sinon.stub(Wallet.prototype, 'multisigType').returns('tss'); + const signMpcGShareNockAwm = nock(advancedWalletManagerUrl) + .post(`/api/${coin}/mpc/sign/g`) + .reply(200, { + gShare: { r: 'r', gamma: 'gamma', i: 1, j: 3, n: 4 }, + }); const response = await agent .post(`/api/v1/${coin}/advancedwallet/${walletId}/sendMany`) @@ -449,21 +806,28 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/sendMany', () => { response.body.should.have.property('txid', 'test-tx-id'); response.body.should.have.property('tx', 'signed-transaction'); - // Verify that type defaults to 'transfer' for TSS wallets when not provided - const sendManyArgs = sendManyStub.firstCall.args[0] as Record; - sendManyArgs.should.have.property('type', 'transfer'); + capturedTxRequestBody!.should.have.property('intent'); + (capturedTxRequestBody!.intent as Record).should.have.property( + 'intentType', + 'payment', + ); walletGetNock.done(); - keychainGetNock.done(); - sinon.assert.calledOnce(sendManyStub); - sinon.assert.calledOnce(multisigTypeStub); + createTxRequestNock.done(); + deleteSigSharesNock.done(); + exchangeCommitmentsNock.done(); + offerRShareNock.done(); + sendGShareNock.done(); + transferNock.done(); + signMpcCommitmentNockAwm.done(); + signMpcRShareNockAwm.done(); + signMpcGShareNockAwm.done(); }); }); describe('SendMany TSS ECDSA:', () => { const coin = 'hteth'; it('should send many transactions using ECDSA TSS signing', async () => { - // Mock wallet get request for TSS wallet const walletGetNock = nock(bitgoApiUrl) .get(`/api/v2/${coin}/wallet/${walletId}`) .matchHeader('any', () => true) @@ -472,48 +836,15 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/sendMany', () => { type: 'advanced', keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'], multisigType: 'tss', + multisigTypeVersion: 'MPCv2', }); - // Mock keychain get request for TSS keychain - 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: 'test-common-keychain', - source: 'user', - type: 'tss', - }); - - const sendManyStub = sinon.stub(Wallet.prototype, 'sendMany').resolves({ - txRequest: { - txRequestId: 'test-tx-request-id', - state: 'signed', - apiVersion: 'full', - pendingApprovalId: 'test-pending-approval-id', - transactions: [ - { - state: 'signed', - unsignedTx: { - derivationPath: 'm/0', - signableHex: 'testMessage', - serializedTxHex: 'testSerializedTxHex', - }, - signatureShares: [], - signedTx: { - id: 'test-tx-id', - tx: 'signed-transaction', - }, - }, - ], - }, - txid: 'test-tx-id', - tx: 'signed-transaction', - }); - - // Mock multisigType to return 'tss' - const multisigTypeStub = sinon.stub(Wallet.prototype, 'multisigType').returns('tss'); + const nocks = nockEcdsaMPCv2SigningFlow( + coin, + walletId, + bitgoApiUrl, + advancedWalletManagerUrl, + ); const response = await agent .post(`/api/v1/${coin}/advancedwallet/${walletId}/sendMany`) @@ -539,13 +870,18 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/sendMany', () => { response.body.should.have.property('tx', 'signed-transaction'); walletGetNock.done(); - keychainGetNock.done(); - sinon.assert.calledOnce(sendManyStub); - sinon.assert.calledOnce(multisigTypeStub); + nocks.createTxRequestNock.done(); + nocks.round1SignNock.done(); + nocks.round2SignNock.done(); + nocks.round3SignNock.done(); + nocks.sendTxNock.done(); + nocks.transferNock.done(); + nocks.awmRound1Nock.done(); + nocks.awmRound2Nock.done(); + nocks.awmRound3Nock.done(); }); it('should be able to sign a fill nonce transaction', async () => { - // Mock wallet get request for TSS wallet const walletGetNock = nock(bitgoApiUrl) .get(`/api/v2/${coin}/wallet/${walletId}`) .matchHeader('any', () => true) @@ -554,48 +890,15 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/sendMany', () => { type: 'advanced', keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'], multisigType: 'tss', + multisigTypeVersion: 'MPCv2', }); - // Mock keychain get request for TSS keychain - 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: 'test-common-keychain', - source: 'user', - type: 'tss', - }); - - const sendManyStub = sinon.stub(Wallet.prototype, 'sendMany').resolves({ - txRequest: { - txRequestId: 'test-tx-request-id', - state: 'signed', - apiVersion: 'full', - pendingApprovalId: 'test-pending-approval-id', - transactions: [ - { - state: 'signed', - unsignedTx: { - derivationPath: 'm/0', - signableHex: 'testMessage', - serializedTxHex: 'testSerializedTxHex', - }, - signatureShares: [], - signedTx: { - id: 'test-tx-id', - tx: 'signed-transaction', - }, - }, - ], - }, - txid: 'test-tx-id', - tx: 'signed-transaction', - }); - - // Mock multisigType to return 'tss' - const multisigTypeStub = sinon.stub(Wallet.prototype, 'multisigType').returns('tss'); + const nocks = nockEcdsaMPCv2SigningFlow( + coin, + walletId, + bitgoApiUrl, + advancedWalletManagerUrl, + ); const response = await agent .post(`/api/v1/${coin}/advancedwallet/${walletId}/sendMany`) @@ -613,9 +916,15 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/sendMany', () => { response.body.should.have.property('tx', 'signed-transaction'); walletGetNock.done(); - keychainGetNock.done(); - sinon.assert.calledOnce(sendManyStub); - sinon.assert.calledOnce(multisigTypeStub); + nocks.createTxRequestNock.done(); + nocks.round1SignNock.done(); + nocks.round2SignNock.done(); + nocks.round3SignNock.done(); + nocks.sendTxNock.done(); + nocks.transferNock.done(); + nocks.awmRound1Nock.done(); + nocks.awmRound2Nock.done(); + nocks.awmRound3Nock.done(); }); it('should fail when backup key is used for ECDSA TSS signing', async () => { @@ -628,6 +937,7 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/sendMany', () => { type: 'advanced', keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'], multisigType: 'tss', + multisigTypeVersion: 'MPCv2', }); // Mock keychain get request for backup TSS keychain @@ -642,20 +952,6 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/sendMany', () => { type: 'tss', }); - const sendManyStub = sinon.stub(Wallet.prototype, 'sendMany').resolves({ - txRequest: { - txRequestId: 'test-tx-request-id', - state: 'signed', - apiVersion: 'full', - pendingApprovalId: 'test-pending-approval-id', - }, - txid: 'test-tx-id', - tx: 'signed-transaction', - }); - - // Mock multisigType to return 'tss' - const multisigTypeStub = sinon.stub(Wallet.prototype, 'multisigType').returns('tss'); - const response = await agent .post(`/api/v1/${coin}/advancedwallet/${walletId}/sendMany`) .set('Authorization', `Bearer ${accessToken}`) @@ -675,8 +971,6 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/sendMany', () => { walletGetNock.done(); keychainGetNock.done(); - sinon.assert.notCalled(sendManyStub); - sinon.assert.calledOnce(multisigTypeStub); }); }); @@ -768,15 +1062,13 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/sendMany', () => { pub: 'xpub_user', }); - const prebuildStub = sinon.stub(Wallet.prototype, 'prebuildTransaction').resolves({ - txHex: 'prebuilt-tx-hex', - txInfo: { - nP2SHInputs: 1, - nSegwitInputs: 0, - nOutputs: 2, - }, - walletId, - }); + const prebuildBuildNock = nock(bitgoApiUrl) + .post(`/api/v2/${coin}/wallet/${walletId}/tx/build`) + .reply(200, { + txHex: TBTC_PREBUILD_PSBT_HEX, + txInfo: { nP2SHInputs: 1, nSegwitInputs: 0, nOutputs: 2 }, + }); + nock(bitgoApiUrl).get(`/api/v2/${coin}/public/block/latest`).reply(200, { height: 800000 }); // Mock verifyTransaction to return false const verifyStub = sinon.stub(Tbtc.prototype, 'verifyTransaction').resolves(false); @@ -799,7 +1091,7 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/sendMany', () => { walletGetNock.done(); keychainGetNock.done(); - sinon.assert.calledOnce(prebuildStub); + prebuildBuildNock.done(); sinon.assert.calledOnce(verifyStub); }); @@ -823,15 +1115,13 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/sendMany', () => { pub: 'xpub_user', }); - const prebuildStub = sinon.stub(Wallet.prototype, 'prebuildTransaction').resolves({ - txHex: 'prebuilt-tx-hex', - txInfo: { - nP2SHInputs: 1, - nSegwitInputs: 0, - nOutputs: 2, - }, - walletId, - }); + const prebuildBuildNock = nock(bitgoApiUrl) + .post(`/api/v2/${coin}/wallet/${walletId}/tx/build`) + .reply(200, { + txHex: TBTC_PREBUILD_PSBT_HEX, + txInfo: { nP2SHInputs: 1, nSegwitInputs: 0, nOutputs: 2 }, + }); + nock(bitgoApiUrl).get(`/api/v2/${coin}/public/block/latest`).reply(200, { height: 800000 }); // Mock verifyTransaction to throw an error const verifyStub = sinon @@ -856,7 +1146,7 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/sendMany', () => { walletGetNock.done(); keychainGetNock.done(); - sinon.assert.calledOnce(prebuildStub); + prebuildBuildNock.done(); sinon.assert.calledOnce(verifyStub); }); @@ -891,15 +1181,13 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/sendMany', () => { .matchHeader('any', () => true) .reply(200, { id: 'bitgo-key-id', pub: 'xpub_bitgo' }); - const prebuildStub = sinon.stub(Wallet.prototype, 'prebuildTransaction').resolves({ - txHex: 'prebuilt-tx-hex', - txInfo: { - nP2SHInputs: 1, - nSegwitInputs: 0, - nOutputs: 2, - }, - walletId, - }); + const prebuildBuildNock = nock(bitgoApiUrl) + .post(`/api/v2/${coin}/wallet/${walletId}/tx/build`) + .reply(200, { + txHex: TBTC_PREBUILD_PSBT_HEX, + txInfo: { nP2SHInputs: 1, nSegwitInputs: 0, nOutputs: 2 }, + }); + nock(bitgoApiUrl).get(`/api/v2/${coin}/public/block/latest`).reply(200, { height: 800000 }); const verifyStub = sinon.stub(Tbtc.prototype, 'verifyTransaction').resolves(true); @@ -932,7 +1220,7 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/sendMany', () => { walletGetNock.done(); keychainGetNock.done(); - sinon.assert.calledOnce(prebuildStub); + prebuildBuildNock.done(); sinon.assert.calledOnce(verifyStub); signNock.done(); }); @@ -993,8 +1281,6 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/sendMany', () => { commonKeychain: 'test-common-keychain', }); - const multisigTypeStub = sinon.stub(Wallet.prototype, 'multisigType').returns('tss'); - const response = await agent .post(`/api/v1/${coin}/advancedwallet/${walletId}/sendMany`) .set('Authorization', `Bearer ${accessToken}`) @@ -1010,7 +1296,6 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/sendMany', () => { walletGetNock.done(); keychainGetNock.done(); - sinon.assert.calledOnce(multisigTypeStub); }); it('should ignore commonKeychain param for multisig wallet', async () => { @@ -1049,11 +1334,13 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/sendMany', () => { pub: 'xpub_bitgo', }); - const prebuildStub = sinon.stub(Wallet.prototype, 'prebuildTransaction').resolves({ - txHex: 'prebuilt-tx-hex', - txInfo: { nP2SHInputs: 1, nSegwitInputs: 0, nOutputs: 2 }, - walletId, - }); + const prebuildBuildNock = nock(bitgoApiUrl) + .post(`/api/v2/${coin}/wallet/${walletId}/tx/build`) + .reply(200, { + txHex: TBTC_PREBUILD_PSBT_HEX, + txInfo: { nP2SHInputs: 1, nSegwitInputs: 0, nOutputs: 2 }, + }); + nock(bitgoApiUrl).get(`/api/v2/${coin}/public/block/latest`).reply(200, { height: 800000 }); const verifyStub = sinon.stub(Tbtc.prototype, 'verifyTransaction').resolves(true); @@ -1091,7 +1378,7 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/sendMany', () => { keychainGetNock.done(); backupKeychainGetNock.done(); bitgoKeychainGetNock.done(); - sinon.assert.calledOnce(prebuildStub); + prebuildBuildNock.done(); sinon.assert.calledOnce(verifyStub); signNock.done(); submitNock.done(); diff --git a/src/__tests__/api/master/testUtils.ts b/src/__tests__/api/master/testUtils.ts new file mode 100644 index 0000000..22653dd --- /dev/null +++ b/src/__tests__/api/master/testUtils.ts @@ -0,0 +1,8 @@ +import { BitGoAPI } from '@bitgo-beta/sdk-api'; + +export class BitGoAPITestHarness extends BitGoAPI { + static clearConstantsCache(): void { + BitGoAPI._constants = {}; + BitGoAPI._constantsExpire = {}; + } +}