diff --git a/src/__tests__/api/advancedWalletManager/recoveryMultisigTransaction.test.ts b/src/__tests__/api/advancedWalletManager/recoveryMultisigTransaction.test.ts index 5e405e5f..ed39d2c4 100644 --- a/src/__tests__/api/advancedWalletManager/recoveryMultisigTransaction.test.ts +++ b/src/__tests__/api/advancedWalletManager/recoveryMultisigTransaction.test.ts @@ -264,7 +264,7 @@ describe('UTXO recovery — external signing mode', () => { backupNock.done(); }); - it('should return 500 if user sign call fails', async () => { + it('surfaces key-provider signing failures as BitgoApiResponseError', async () => { nock(keyProviderUrl) .post('/sign', (body) => body.source === 'user') .reply(500, { message: 'HSM error' }); @@ -278,6 +278,488 @@ describe('UTXO recovery — external signing mode', () => { coin, }); - response.status.should.equal(500); + response.body.error.should.equal('BitGoApiResponseError'); + response.body.details.should.eql({ keySource: 'user' }); + }); + + it('keyToSign=user: calls user key provider only and returns a half-signed tx', async () => { + nock(keyProviderUrl) + .post('/sign', { + pub: userPub, + source: 'user', + signablePayload: unsignedTxHex, + algorithm: 'ecdsa', + }) + .reply(200, { signature: halfSignedTxHex }); + + const response = await agent.post(`/api/${coin}/multisig/recovery`).send({ + userPub, + backupPub, + bitgoPub, + unsignedSweepPrebuildTx: { txHex: unsignedTxHex }, + walletContractAddress: '', + coin, + keyToSign: 'user', + }); + + response.status.should.equal(200); + response.body.should.have.property('txHex', halfSignedTxHex); + // Backup key provider must NOT be called + nock.pendingMocks().should.have.length(0); + }); + + it('keyToSign=backup: calls backup key provider with half-signed tx and returns the full-signed tx', async () => { + nock(keyProviderUrl) + .post('/sign', { + pub: backupPub, + source: 'backup', + signablePayload: halfSignedTxHex, + algorithm: 'ecdsa', + }) + .reply(200, { signature: fullSignedTxHex }); + + const response = await agent.post(`/api/${coin}/multisig/recovery`).send({ + userPub, + backupPub, + bitgoPub, + unsignedSweepPrebuildTx: { txHex: unsignedTxHex }, + walletContractAddress: '', + coin, + keyToSign: 'backup', + halfSignedTransaction: { txHex: halfSignedTxHex }, + }); + + response.status.should.equal(200); + response.body.should.have.property('txHex', fullSignedTxHex); + }); +}); + +describe('EVM recovery — external signing mode', () => { + let agent: request.SuperAgentTest; + + const keyProviderUrl = 'http://key-provider.invalid'; + const coin = 'teth'; + const userPub = + 'xpub661MyMwAqRbcF3g1sUm7T5pN8ViCr9bS6XiQbq7dVXFdPEGYfhGgjjV2AFxTYVWik29y7NHmCZjWYDkt4RGw57HNYpHnoHeeqJV6s8hwcsV'; + const backupPub = + 'xpub661MyMwAqRbcEywGPF6Pg1FDUtHGyxsn7nph8dcy8GFLKvQ8hSCKgUm8sNbJhegDbmLtMpMnGZtrqfRXCjeDtfJ2UGDSzNTkRuvAQ5KNPcH'; + const bitgoPub = + 'xpub661MyMwAqRbcGcBurxn9ptqqKGmMhnKa8D7TeZkaWpfQNTeG4qKEJ67eb6Hy58kZBwPHqjUt5iApUwvFVk9ffQYaV42RRom2p7yU5bcCwpq'; + const unsignedTxHex = '0xunsigned'; + const halfSignedTxHex = '0xhalfsigned'; + const fullSignedTxHex = '0xfullsigned'; + + const config: AdvancedWalletManagerConfig = { + appMode: AppMode.ADVANCED_WALLET_MANAGER, + signingMode: SigningMode.EXTERNAL, + port: 0, + bind: 'localhost', + timeout: 60000, + httpLoggerFile: '', + tlsMode: TlsMode.DISABLED, + clientCertAllowSelfSigned: true, + keyProviderUrl, + recoveryMode: true, + }; + + const evmCoinStub = { + getFamily: () => CoinFamily.ETH, + getFullName: () => 'Test Ethereum', + isEVM: () => true, + } as unknown as BaseCoin; + + beforeEach(() => { + nock.disableNetConnect(); + nock.enableNetConnect('127.0.0.1'); + + const bitgo = new BitGo({ env: 'test', accessToken: 'test_token' }); + + sinon.stub(middleware, 'prepareBitGo').callsFake(() => (req, res, next) => { + (req as BitGoRequest).bitgo = bitgo; + (req as BitGoRequest).config = config; + next(); + }); + + sinon.stub(coinFactory, 'getCoin').resolves(evmCoinStub); + + agent = request.agent(expressApp(config)); + }); + + afterEach(() => { + nock.cleanAll(); + sinon.restore(); + }); + + it('keyToSign=user: calls user key provider and returns a flat half-signed tx', async () => { + const userSignNock = nock(keyProviderUrl) + .post('/sign', { + pub: userPub, + source: 'user', + signablePayload: unsignedTxHex, + algorithm: 'ecdsa', + }) + .reply(200, { signature: halfSignedTxHex }); + + const response = await agent.post(`/api/${coin}/multisig/recovery`).send({ + userPub, + backupPub, + bitgoPub, + unsignedSweepPrebuildTx: { txHex: unsignedTxHex }, + walletContractAddress: '0xcontract', + coin, + keyToSign: 'user', + }); + + response.status.should.equal(200); + response.body.should.have.property('txHex', halfSignedTxHex); + userSignNock.done(); + }); + + it('keyToSign=backup with a rich EVM half-signed object (no top-level txHex): returns 400', async () => { + // No key-provider nock: the guard must reject before any sign call. + const response = await agent.post(`/api/${coin}/multisig/recovery`).send({ + userPub, + backupPub, + bitgoPub, + unsignedSweepPrebuildTx: { txHex: unsignedTxHex }, + walletContractAddress: '0xcontract', + coin, + keyToSign: 'backup', + halfSignedTransaction: { halfSigned: { txHex: halfSignedTxHex } }, + }); + + response.status.should.equal(400); + response.body.details.should.containEql('External backup signing for EVM coins'); + nock.pendingMocks().should.have.length(0); + }); + + it('keyToSign=backup with a flat halfSignedTransaction.txHex: calls backup key provider', async () => { + const backupSignNock = nock(keyProviderUrl) + .post('/sign', { + pub: backupPub, + source: 'backup', + signablePayload: halfSignedTxHex, + algorithm: 'ecdsa', + }) + .reply(200, { signature: fullSignedTxHex }); + + const response = await agent.post(`/api/${coin}/multisig/recovery`).send({ + userPub, + backupPub, + bitgoPub, + unsignedSweepPrebuildTx: { txHex: unsignedTxHex }, + walletContractAddress: '0xcontract', + coin, + keyToSign: 'backup', + halfSignedTransaction: { txHex: halfSignedTxHex }, + }); + + response.status.should.equal(200); + response.body.should.have.property('txHex', fullSignedTxHex); + backupSignNock.done(); + }); +}); + +describe('UTXO recovery — local signing with keyToSign', () => { + let agent: request.SuperAgentTest; + let signTransactionStub: sinon.SinonStub; + + const coin = 'tbtc'; + const userPub = + 'xpub661MyMwAqRbcF3g1sUm7T5pN8ViCr9bS6XiQbq7dVXFdPEGYfhGgjjV2AFxTYVWik29y7NHmCZjWYDkt4RGw57HNYpHnoHeeqJV6s8hwcsV'; + const backupPub = + 'xpub661MyMwAqRbcEywGPF6Pg1FDUtHGyxsn7nph8dcy8GFLKvQ8hSCKgUm8sNbJhegDbmLtMpMnGZtrqfRXCjeDtfJ2UGDSzNTkRuvAQ5KNPcH'; + const bitgoPub = + 'xpub661MyMwAqRbcGcBurxn9ptqqKGmMhnKa8D7TeZkaWpfQNTeG4qKEJ67eb6Hy58kZBwPHqjUt5iApUwvFVk9ffQYaV42RRom2p7yU5bcCwpq'; + const userPrv = + 'xprv9s21ZrQH143K2ZbYmTE75wsdaTsiSgsajJnooSi1wBieWRwQ89xSBwAYK1VJR795Y8XFCCXYHHs4sk2Heg6dkX3CHMBq5bw8DwBWByWx883'; + const backupPrv = + 'xprv9s21ZrQH143K2VroHDZPJsJUvrSnaW9vkZu6LFDMZviMT84z9tt58gSf25PzAMJC9pb1qRUBiYcsgcKWTDhwmwazsDAvzzDB5qrE3XDfawH'; + const halfSignedTxHex = 'half-signed-utxo-tx-hex'; + const fullSignedTxHex = 'full-signed-utxo-tx-hex'; + const unsignedSweepPrebuildTx = { + txHex: + '70736274ff01005e0100000001edd7a583fef5aabf265e6dca24452581a3cca2671a1fa6b4e404bccb6ff4c83b0000000000ffffffff01780f0000000000002200202120dcf53e62a4cc9d3843993aa2258bd14fbf911a4ea4cf4f3ac840f4170279000000000001012ba00f00000000000022002008da4d49c618c6a00dc86a962f9c452dc0151653d2630470dcf8375a9f6496a5', + txInfo: { unspents: [{ id: 'deadbeef:0', address: 'tb1q...', value: 4000 }] }, + feeInfo: {}, + coin: 'tbtc', + }; + + const config: AdvancedWalletManagerConfig = { + appMode: AppMode.ADVANCED_WALLET_MANAGER, + signingMode: SigningMode.LOCAL, + port: 0, + bind: 'localhost', + timeout: 60000, + httpLoggerFile: '', + tlsMode: TlsMode.DISABLED, + clientCertAllowSelfSigned: true, + keyProviderUrl: 'key-provider.example.com', + recoveryMode: true, + }; + + beforeEach(() => { + nock.disableNetConnect(); + nock.enableNetConnect('127.0.0.1'); + + const bitgo = new BitGo({ env: 'test', accessToken: 'test_token' }); + + sinon.stub(middleware, 'prepareBitGo').callsFake(() => (req, res, next) => { + (req as BitGoRequest).bitgo = bitgo; + (req as BitGoRequest).config = config; + next(); + }); + + signTransactionStub = sinon.stub(); + const utxoCoinStub = { + getFamily: () => CoinFamily.BTC, + isEVM: () => false, + signTransaction: signTransactionStub, + } as unknown as BaseCoin; + sinon.stub(coinFactory, 'getCoin').resolves(utxoCoinStub); + + const retrieveStub = sinon.stub(keyProviderUtils, 'retrieveKeyProviderPrvKey'); + retrieveStub.withArgs({ pub: userPub, source: 'user', cfg: config }).resolves(userPrv); + retrieveStub.withArgs({ pub: backupPub, source: 'backup', cfg: config }).resolves(backupPrv); + + agent = request.agent(expressApp(config)); + }); + + afterEach(() => { + nock.cleanAll(); + sinon.restore(); + }); + + it('keyToSign=user: fetches only user prv and returns half-signed tx', async () => { + signTransactionStub.resolves({ txHex: halfSignedTxHex }); + + const response = await agent.post(`/api/${coin}/multisig/recovery`).send({ + userPub, + backupPub, + bitgoPub, + unsignedSweepPrebuildTx, + walletContractAddress: '', + coin, + keyToSign: 'user', + }); + + response.status.should.equal(200); + response.body.txHex.should.equal(halfSignedTxHex); + (keyProviderUtils.retrieveKeyProviderPrvKey as sinon.SinonStub) + .calledWith(sinon.match({ source: 'user' })) + .should.be.true(); + (keyProviderUtils.retrieveKeyProviderPrvKey as sinon.SinonStub) + .calledWith(sinon.match({ source: 'backup' })) + .should.be.false(); + signTransactionStub.calledWith(sinon.match({ isLastSignature: false })).should.be.true(); + }); + + it('keyToSign=backup: fetches only backup prv and signs with halfSignedTransaction.txHex', async () => { + signTransactionStub.resolves({ txHex: fullSignedTxHex }); + + const response = await agent.post(`/api/${coin}/multisig/recovery`).send({ + userPub, + backupPub, + bitgoPub, + unsignedSweepPrebuildTx, + walletContractAddress: '', + coin, + keyToSign: 'backup', + halfSignedTransaction: { txHex: halfSignedTxHex }, + }); + + response.status.should.equal(200); + response.body.txHex.should.equal(fullSignedTxHex); + (keyProviderUtils.retrieveKeyProviderPrvKey as sinon.SinonStub) + .calledWith(sinon.match({ source: 'backup' })) + .should.be.true(); + (keyProviderUtils.retrieveKeyProviderPrvKey as sinon.SinonStub) + .calledWith(sinon.match({ source: 'user' })) + .should.be.false(); + signTransactionStub + .calledWith( + sinon.match({ + isLastSignature: true, + txPrebuild: sinon.match({ txHex: halfSignedTxHex }), + }), + ) + .should.be.true(); + }); + + it('keyToSign=backup without halfSignedTransaction: returns 400', async () => { + const response = await agent.post(`/api/${coin}/multisig/recovery`).send({ + userPub, + backupPub, + bitgoPub, + unsignedSweepPrebuildTx, + walletContractAddress: '', + coin, + keyToSign: 'backup', + // halfSignedTransaction deliberately omitted + }); + + response.status.should.equal(400); + response.body.details.should.containEql('halfSignedTransaction is required'); + }); +}); + +describe('EVM recovery — local signing with keyToSign (two-phase)', () => { + let agent: request.SuperAgentTest; + let signTransactionStub: sinon.SinonStub; + + const coin = 'teth'; + const userPub = + 'xpub661MyMwAqRbcF3g1sUm7T5pN8ViCr9bS6XiQbq7dVXFdPEGYfhGgjjV2AFxTYVWik29y7NHmCZjWYDkt4RGw57HNYpHnoHeeqJV6s8hwcsV'; + const backupPub = + 'xpub661MyMwAqRbcEywGPF6Pg1FDUtHGyxsn7nph8dcy8GFLKvQ8hSCKgUm8sNbJhegDbmLtMpMnGZtrqfRXCjeDtfJ2UGDSzNTkRuvAQ5KNPcH'; + const bitgoPub = + 'xpub661MyMwAqRbcGcBurxn9ptqqKGmMhnKa8D7TeZkaWpfQNTeG4qKEJ67eb6Hy58kZBwPHqjUt5iApUwvFVk9ffQYaV42RRom2p7yU5bcCwpq'; + const userPrv = + 'xprv9s21ZrQH143K2ZbYmTE75wsdaTsiSgsajJnooSi1wBieWRwQ89xSBwAYK1VJR795Y8XFCCXYHHs4sk2Heg6dkX3CHMBq5bw8DwBWByWx883'; + const backupPrv = + 'xprv9s21ZrQH143K2VroHDZPJsJUvrSnaW9vkZu6LFDMZviMT84z9tt58gSf25PzAMJC9pb1qRUBiYcsgcKWTDhwmwazsDAvzzDB5qrE3XDfawH'; + const halfSignedTxHex = 'half-signed-evm-tx-hex'; + const fullSignedTxHex = 'full-signed-evm-tx-hex'; + const recipients = [{ address: '0xrecipient', amount: '1000' }]; + + const config: AdvancedWalletManagerConfig = { + appMode: AppMode.ADVANCED_WALLET_MANAGER, + signingMode: SigningMode.LOCAL, + port: 0, + bind: 'localhost', + timeout: 60000, + httpLoggerFile: '', + tlsMode: TlsMode.DISABLED, + clientCertAllowSelfSigned: true, + keyProviderUrl: 'key-provider.example.com', + recoveryMode: true, + }; + + beforeEach(() => { + nock.disableNetConnect(); + nock.enableNetConnect('127.0.0.1'); + + const bitgo = new BitGo({ env: 'test', accessToken: 'test_token' }); + + sinon.stub(middleware, 'prepareBitGo').callsFake(() => (req, res, next) => { + (req as BitGoRequest).bitgo = bitgo; + (req as BitGoRequest).config = config; + next(); + }); + + signTransactionStub = sinon.stub(); + const evmCoinStub = { + getFamily: () => CoinFamily.ETH, + isEVM: () => true, + signTransaction: signTransactionStub, + } as unknown as BaseCoin; + sinon.stub(coinFactory, 'getCoin').resolves(evmCoinStub); + + const retrieveStub = sinon.stub(keyProviderUtils, 'retrieveKeyProviderPrvKey'); + retrieveStub.withArgs({ pub: userPub, source: 'user', cfg: config }).resolves(userPrv); + retrieveStub.withArgs({ pub: backupPub, source: 'backup', cfg: config }).resolves(backupPrv); + + agent = request.agent(expressApp(config)); + }); + + afterEach(() => { + nock.cleanAll(); + sinon.restore(); + }); + + it('keyToSign=user: returns a rich EVM half-signed tx (with halfSigned object)', async () => { + signTransactionStub.resolves({ + halfSigned: { + txHex: halfSignedTxHex, + recipients, + expireTime: 123, + backupKeyNonce: 1, + signature: '0xsig', + }, + }); + + const response = await agent.post(`/api/${coin}/multisig/recovery`).send({ + userPub, + backupPub, + bitgoPub, + unsignedSweepPrebuildTx: { recipients, nextContractSequenceId: 1 }, + walletContractAddress: '0xcontract', + coin, + keyToSign: 'user', + }); + + response.status.should.equal(200); + response.body.should.have.property('halfSigned'); + response.body.halfSigned.should.have.property('txHex', halfSignedTxHex); + response.body.halfSigned.should.have.property('recipients'); + (keyProviderUtils.retrieveKeyProviderPrvKey as sinon.SinonStub) + .calledWith(sinon.match({ source: 'user' })) + .should.be.true(); + (keyProviderUtils.retrieveKeyProviderPrvKey as sinon.SinonStub) + .calledWith(sinon.match({ source: 'backup' })) + .should.be.false(); + signTransactionStub.calledWith(sinon.match({ isLastSignature: false })).should.be.true(); + }); + + it('keyToSign=backup: consumes the rich EVM half-signed tx and returns the full-signed tx', async () => { + signTransactionStub.resolves({ txHex: fullSignedTxHex }); + + const halfSignedTransaction = { + halfSigned: { + txHex: halfSignedTxHex, + recipients, + expireTime: 123, + backupKeyNonce: 1, + }, + recipients, + }; + + const response = await agent.post(`/api/${coin}/multisig/recovery`).send({ + userPub, + backupPub, + bitgoPub, + unsignedSweepPrebuildTx: { recipients, nextContractSequenceId: 1 }, + walletContractAddress: '0xcontract', + coin, + keyToSign: 'backup', + halfSignedTransaction, + }); + + response.status.should.equal(200); + response.body.should.have.property('txHex', fullSignedTxHex); + (keyProviderUtils.retrieveKeyProviderPrvKey as sinon.SinonStub) + .calledWith(sinon.match({ source: 'backup' })) + .should.be.true(); + (keyProviderUtils.retrieveKeyProviderPrvKey as sinon.SinonStub) + .calledWith(sinon.match({ source: 'user' })) + .should.be.false(); + signTransactionStub.calledWith(sinon.match({ isLastSignature: true })).should.be.true(); + signTransactionStub + .calledWith( + sinon.match({ + txPrebuild: sinon.match({ + txHex: halfSignedTxHex, + halfSigned: halfSignedTransaction.halfSigned, + }), + }), + ) + .should.be.true(); + }); + + it('keyToSign=backup with a malformed EVM half-signed tx (no halfSigned.txHex): returns 400', async () => { + const response = await agent.post(`/api/${coin}/multisig/recovery`).send({ + userPub, + backupPub, + bitgoPub, + unsignedSweepPrebuildTx: { recipients, nextContractSequenceId: 1 }, + walletContractAddress: '0xcontract', + coin, + keyToSign: 'backup', + halfSignedTransaction: { halfSigned: {} }, + }); + + response.status.should.equal(400); + response.body.details.should.containEql('EVM half-signed recovery tx'); + signTransactionStub.called.should.be.false(); }); }); diff --git a/src/__tests__/api/master/multisigRecoveryUtils.test.ts b/src/__tests__/api/master/multisigRecoveryUtils.test.ts index cd7aec5b..9f552fc4 100644 --- a/src/__tests__/api/master/multisigRecoveryUtils.test.ts +++ b/src/__tests__/api/master/multisigRecoveryUtils.test.ts @@ -72,6 +72,39 @@ describe('multisigRecoveryUtils', () => { result.should.eql({ jobId, status: 'pending' }); bridgeNock.done(); }); + + it('defaults to the user source when sources is omitted', async () => { + const jobId = 'job-123'; + const bridgeNock = nock(bridgeUrl) + .post(`/api/${coin}/multisig/recovery`) + .matchHeader('X-OSO-Source', KeySource.USER) + .reply(202, { jobId }); + + const result = await submitMultisigRecoveryJob(makeAsyncReq(), coin, recoveryBody); + assert(result); + result.should.eql({ jobId, status: 'pending' }); + bridgeNock.done(); + }); + + it('accepts multiple sources when explicitly passed', async () => { + const jobId = 'job-456'; + const bridgeNock = nock(bridgeUrl) + .post(`/api/${coin}/multisig/recovery`, (body) => { + body.should.eql(recoveryBody); + return true; + }) + .matchHeader('X-OSO-Source', `${KeySource.USER},${KeySource.BACKUP}`) + .matchHeader('X-OSO-Operation', 'multisig_recovery') + .reply(202, { jobId }); + + const result = await submitMultisigRecoveryJob(makeAsyncReq(), coin, recoveryBody, [ + KeySource.USER, + KeySource.BACKUP, + ]); + assert(result); + result.should.eql({ jobId, status: 'pending' }); + bridgeNock.done(); + }); }); describe('parseSignedRecoveryTransaction', () => { diff --git a/src/__tests__/api/master/recoveryConsolidationsWallet.test.ts b/src/__tests__/api/master/recoveryConsolidationsWallet.test.ts index f0b357ed..63bbb911 100644 --- a/src/__tests__/api/master/recoveryConsolidationsWallet.test.ts +++ b/src/__tests__/api/master/recoveryConsolidationsWallet.test.ts @@ -9,7 +9,10 @@ import { BitGoAPITestHarness, DEFAULT_ASYNC_MODE_CONFIG, makeMasterExpressTestConfig, + makeSplitAwmMasterExpressConfig, nockAsyncMultisigRecoveryJob, + nockAsyncRecoveryJobBypass, + nockSplitAwmMultisigRecovery, } from './testUtils'; describe('POST /api/v1/:coin/advancedwallet/recoveryconsolidations', () => { @@ -735,4 +738,74 @@ describe('POST /api/v1/:coin/advancedwallet/recoveryconsolidations', () => { ); }); }); + + describe('Split AWM (separate user and backup AWMs)', () => { + const halfSignedTxHex = 'half-signed-trx-tx'; + const fullSignedTxHex = 'signed-trx-tx'; + + const trxConsolidationRequest = { + multisigType: 'onchain' as const, + userPub: mockUserPub, + backupPub: mockBackupPub, + bitgoPub: mockBitgoPub, + tokenContractAddress: trxTokenContractAddress, + startingScanIndex: 1, + endingScanIndex: 3, + }; + + function nockTrxTwoAddressConsolidationScan() { + const tronBalanceWithToken = { + data: [{ balance: 200_000_000, trc20: [{ [trxTokenContractAddress]: '1000000' }] }], + }; + nock(tronBase).get(`/v1/accounts/${TRX_ADDR_1}`).reply(200, tronBalanceWithToken); + nock(tronBase).post('/wallet/triggersmartcontract').reply(200, { transaction: TRON_MOCK_TX }); + nock(tronBase).get(`/v1/accounts/${TRX_ADDR_2}`).reply(200, tronBalanceWithToken); + nock(tronBase).post('/wallet/triggersmartcontract').reply(200, { transaction: TRON_MOCK_TX }); + } + + it('calls user AWM with keyToSign=user then backup AWM with keyToSign=backup per tx', async () => { + nockTrxTwoAddressConsolidationScan(); + const { userAwmNock, backupAwmNock } = nockSplitAwmMultisigRecovery({ + coin: 'trx', + halfSignedTxHex, + fullSignedTxHex, + times: 2, + }); + + const response = await request + .agent(expressApp(makeSplitAwmMasterExpressConfig())) + .post('/api/v1/trx/advancedwallet/recoveryconsolidations') + .set('Authorization', `Bearer ${accessToken}`) + .send(trxConsolidationRequest); + + response.status.should.equal(200); + response.body.signedTxs.should.have.length(2); + response.body.signedTxs[0].should.have.property('txHex', fullSignedTxHex); + userAwmNock.done(); + backupAwmNock.done(); + }); + + it('stays sync when async mode is enabled (bridge is not called)', async () => { + nockTrxTwoAddressConsolidationScan(); + const bridgeNock = nockAsyncRecoveryJobBypass('trx'); + const { userAwmNock, backupAwmNock } = nockSplitAwmMultisigRecovery({ + coin: 'trx', + halfSignedTxHex, + fullSignedTxHex, + times: 2, + }); + + const response = await request + .agent(expressApp(makeSplitAwmMasterExpressConfig({ asyncEnabled: true }))) + .post('/api/v1/trx/advancedwallet/recoveryconsolidations') + .set('Authorization', `Bearer ${accessToken}`) + .send(trxConsolidationRequest); + + response.status.should.equal(200); + response.body.signedTxs.should.have.length(2); + userAwmNock.done(); + backupAwmNock.done(); + bridgeNock.isDone().should.be.false(); + }); + }); }); diff --git a/src/__tests__/api/master/recoveryWallet.test.ts b/src/__tests__/api/master/recoveryWallet.test.ts index c54649a4..f3290849 100644 --- a/src/__tests__/api/master/recoveryWallet.test.ts +++ b/src/__tests__/api/master/recoveryWallet.test.ts @@ -8,7 +8,10 @@ import { BitGoAPITestHarness, DEFAULT_ASYNC_MODE_CONFIG, makeMasterExpressTestConfig, + makeSplitAwmMasterExpressConfig, nockAsyncMultisigRecoveryJob, + nockAsyncRecoveryJobBypass, + nockSplitAwmMultisigRecovery, } from './testUtils'; describe('Recovery Tests', () => { @@ -817,3 +820,201 @@ describe('Recovery Tests', () => { }); }); }); + +describe('Split AWM recovery (separate user and backup AWMs)', () => { + const accessToken = 'test-token'; + const coin = 'tbtc'; + const userPub = + 'xpub661MyMwAqRbcEtjU21VjQhGDdg5noG6kCGjcpc4EZwnLUxr9Pi56i14Eek8CQqcuGVnXQf3Zy47Uizr5WHDbZ3GumXEFXpwFLHWGbKrWWcg'; + const backupPub = + 'xpub661MyMwAqRbcEnTrcp222pRm7G1ZAbDD3KxXT2XEKRe3jnnvydqnyssewd2eUxgeWr1c1ffHcqqRKB8j3Lw9VR4dvrAhTov4kPKZF5rs6Vr'; + const bitgoPub = + 'xpub661MyMwAqRbcFNUFGFmDcC3Frgtz4FnJqFdCGbzLva2hf5i3ZJuQdsGc3z5FXCVqR9NQ6h2zTyGcQkfFtsLT5St621Fcu1C22kCKhbo4kQy'; + + const halfSignedTxHex = 'half-signed-utxo-tx-hex'; + const fullSignedTxHex = + '01000000000101edd7a583fef5aabf265e6dca24452581a3cca2671a1fa6b4e404bccb6ff4c83b0000000000ffffffff01780f0000000000002200202120dcf53e62a4cc9d3843993aa2258bd14fbf911a4ea4cf4f3ac840f41702790400473044022043a9256810ef47ce36a092305c0b1ef675bce53e46418eea8cacbf1643e541d90220450766e048b841dac658d0a2ba992628bfe131dff078c3a574cadf67b4946647014730440220360045a15e459ed44aa3e52b86dd6a16dddaf319821f4dcc15627686f377edd102205cb3d5feab1a773c518d43422801e01dd1bc586bb09f6a9ed23a1fc0cfeeb5310169522103a1c425fd9b169e6ab5ed3de596acb777ccae0cda3d91256238b5e739a3f14aae210222a76697605c890dc4365132f9ae0d351952a1aad7eecf78d9923766dbe74a1e21033b21c0758ffbd446204914fa1d1c5921e9f82c2671dac89737666aa9375973e953ae00000000'; + + const utxoRecoveryRequest = { + multiSigRecoveryParams: { userPub, backupPub, bitgoPub, walletContractAddress: '' }, + recoveryDestinationAddress: 'tb1qprdy6jwxrrr2qrwgd2tzl8z99hqp29jn6f3sguxulqm448myj6jsy2nwsu', + coin, + apiKey: 'key', + coinSpecificParams: { utxoRecoveryOptions: { scan: 1 } }, + }; + + function nockUtxoRecoveryScan() { + const blockchairBase = 'https://api.blockchair.com'; + const addrWithFunds = 'tb1qs5efv9zqhrc4sne7zphmsxea3cg9m262v6phsqn5dfdwed8ykx4s4wj67d'; + + nock(blockchairBase) + .get(`/bitcoin/testnet/dashboards/address/${addrWithFunds}?key=key`) + .reply(200, { + data: { [addrWithFunds]: { address: { transaction_count: 1, balance: 4000 } } }, + }); + nock(blockchairBase) + .get(`/bitcoin/testnet/dashboards/addresses/${addrWithFunds}?key=key`) + .reply(200, { + data: { + utxo: [ + { + transaction_hash: '3bc8f46fcbbc04e4b4a61f1a67a2cca381254524ca6d5e26bfaaf5fe83a5d7ed', + index: 0, + recipient: addrWithFunds, + value: 4000, + block_id: 100, + spending_transaction_hash: null, + spending_index: null, + address: addrWithFunds, + }, + ], + }, + }); + nock(blockchairBase) + .persist() + .get(/\/bitcoin\/testnet\/dashboards\/address\/[^?]+\?key=key/) + .reply(function (uri) { + const match = uri.match(/\/dashboards\/address\/([^?]+)\?/); + const addr = match ? decodeURIComponent(match[1]) : 'unknown'; + return [200, { data: { [addr]: { address: { transaction_count: 0, balance: 0 } } } }]; + }); + nock('https://mempool.space').get('/api/v1/fees/recommended').reply(200, { + fastestFee: 20, + halfHourFee: 10, + hourFee: 5, + }); + } + + before(() => { + nock.disableNetConnect(); + nock.enableNetConnect('127.0.0.1'); + }); + + afterEach(() => { + nock.cleanAll(); + BitGoAPITestHarness.clearConstantsCache(); + }); + + it('calls user AWM with keyToSign=user then backup AWM with keyToSign=backup for UTXO recovery', async () => { + nockUtxoRecoveryScan(); + const { userAwmNock, backupAwmNock } = nockSplitAwmMultisigRecovery({ + coin, + halfSignedTxHex, + fullSignedTxHex, + }); + + const response = await request + .agent(expressApp(makeSplitAwmMasterExpressConfig())) + .post(`/api/v1/${coin}/advancedwallet/recovery`) + .set('Authorization', `Bearer ${accessToken}`) + .send(utxoRecoveryRequest); + + response.status.should.equal(200); + response.body.should.have.property('txHex', fullSignedTxHex); + userAwmNock.done(); + backupAwmNock.done(); + }); + + it('uses the sync split-AWM two-phase path even when async mode is enabled', async () => { + nockUtxoRecoveryScan(); + const bridgeNock = nockAsyncRecoveryJobBypass(coin); + const { userAwmNock, backupAwmNock } = nockSplitAwmMultisigRecovery({ + coin, + halfSignedTxHex, + fullSignedTxHex, + validateBackupBody: (body) => body.keyToSign === 'backup', + }); + + const response = await request + .agent(expressApp(makeSplitAwmMasterExpressConfig({ asyncEnabled: true }))) + .post(`/api/v1/${coin}/advancedwallet/recovery`) + .set('Authorization', `Bearer ${accessToken}`) + .send(utxoRecoveryRequest); + + response.status.should.equal(200); + response.body.should.have.property('txHex', fullSignedTxHex); + userAwmNock.done(); + backupAwmNock.done(); + bridgeNock.isDone().should.be.false(); + }); + + it('forwards the rich EVM half-signed object from user AWM to backup AWM for EVM recovery', async () => { + const ethCoinId = 'hteth'; + const ethUserPub = + 'xpub661MyMwAqRbcFigezGWEYSbCPVuaUmvnp1u7iEpH9YsKU6uYQtPANvudjgAo82QRHXsUieMqKeB1xEj89VUKU1ugtmyAZ3xzNEbHPexxgKK'; + const ethBackupPub = + 'xpub661MyMwAqRbcGbCirzmQsUJT2eidt9tFLw2m77w6FiKco6TKu49CP3GkHF88xGCpvqkP93SYMAarfyWAn8UWevQtNT6pDo8xH7xmf6GqK6e'; + const walletContractAddress = '0x0987654321098765432109876543210987654321'; + const backupKeyAddress = '0x30edc88a77598833f58947638b2ac3d5713d9845'; + const etherscanBase = 'https://api.etherscan.io'; + const chainid = '560048'; // Holesky testnet (hteth) + const apiKey = 'key'; + + // Etherscan nocks mirror the single-AWM EVM recovery test. + nock(etherscanBase) + .get( + `/v2/api?chainid=${chainid}&module=account&action=txlist&address=${backupKeyAddress}&apikey=${apiKey}`, + ) + .twice() + .reply(200, { result: [] }); + nock(etherscanBase) + .get( + `/v2/api?chainid=${chainid}&module=account&action=balance&address=${backupKeyAddress}&apikey=${apiKey}`, + ) + .reply(200, { result: '10000000000000000' }); + nock(etherscanBase) + .get( + `/v2/api?chainid=${chainid}&module=account&action=balance&address=${walletContractAddress}&apikey=${apiKey}`, + ) + .reply(200, { result: '1000000000000000000' }); + nock(etherscanBase) + .get( + `/v2/api?chainid=${chainid}&module=proxy&action=eth_call&to=${walletContractAddress}&data=a0b7967b&tag=latest&apikey=${apiKey}`, + ) + .reply(200, { + result: '0x0000000000000000000000000000000000000000000000000000000000000001', + }); + + const halfSignedObject = { + halfSigned: { + txHex: '0xhalfsigned', + recipients: [{ address: '0xrecipient', amount: '1000' }], + expireTime: 123, + backupKeyNonce: 1, + }, + recipients: [{ address: '0xrecipient', amount: '1000' }], + }; + + const { userAwmNock, backupAwmNock } = nockSplitAwmMultisigRecovery({ + coin: ethCoinId, + halfSignedTxHex: '0xhalfsigned', + fullSignedTxHex: '0xfullsigned', + userHalfSignBody: halfSignedObject, + validateBackupBody: (body) => + body.keyToSign === 'backup' && + body.halfSignedTransaction?.halfSigned?.txHex === '0xhalfsigned', + }); + + const response = await request + .agent(expressApp(makeSplitAwmMasterExpressConfig())) + .post(`/api/v1/${ethCoinId}/advancedwallet/recovery`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + multiSigRecoveryParams: { + userPub: ethUserPub, + backupPub: ethBackupPub, + bitgoPub: '', + walletContractAddress, + }, + recoveryDestinationAddress: '0x1234567890123456789012345678901234567890', + coin: ethCoinId, + apiKey, + coinSpecificParams: { evmRecoveryOptions: {} }, + }); + + response.status.should.equal(200); + response.body.should.have.property('txHex', '0xfullsigned'); + userAwmNock.done(); + backupAwmNock.done(); + }); +}); diff --git a/src/__tests__/api/master/testUtils.ts b/src/__tests__/api/master/testUtils.ts index 666d3078..7e1bb5b6 100644 --- a/src/__tests__/api/master/testUtils.ts +++ b/src/__tests__/api/master/testUtils.ts @@ -83,15 +83,17 @@ export function nockAsyncMultisigRecoveryJob(options: { jobId: string; captureJobBody?: (body: Record) => void; bridgeUrl?: string; + sources?: KeySource[]; }) { const bridgeUrl = options.bridgeUrl ?? ASYNC_TEST_BRIDGE_URL; + const sources = (options.sources ?? [KeySource.USER]).join(','); const bridgeNock = nock(bridgeUrl) .post(`/api/${options.coin}/multisig/recovery`, (body) => { options.captureJobBody?.(body); return true; }) - .matchHeader('X-OSO-Source', KeySource.USER) + .matchHeader('X-OSO-Source', sources) .matchHeader('X-OSO-Operation', 'multisig_recovery') .reply(202, { jobId: options.jobId }); @@ -102,6 +104,68 @@ export function nockAsyncMultisigRecoveryJob(options: { return { bridgeNock, awmRecoveryNock }; } +export const SPLIT_AWM_USER_URL = 'http://user-awm.invalid'; +export const SPLIT_AWM_BACKUP_URL = 'http://backup-awm.invalid'; + +export function makeSplitAwmMasterExpressConfig( + options: { asyncEnabled?: boolean } = {}, +): MasterExpressConfig { + return makeMasterExpressTestConfig(SPLIT_AWM_USER_URL, { + asyncEnabled: options.asyncEnabled, + overrides: { + advancedWalletManagerBackupUrl: SPLIT_AWM_BACKUP_URL, + awmBackupServerCaCert: 'dummy-backup-cert', + recoveryMode: true, + }, + }); +} + +type SplitAwmRecoveryRequestBody = { + keyToSign?: string; + halfSignedTransaction?: { txHex?: string; halfSigned?: { txHex?: string } }; +}; + +/** User half-sign then backup full-sign nocks for sync split-AWM recovery. */ +export function nockSplitAwmMultisigRecovery(options: { + coin: string; + halfSignedTxHex: string; + fullSignedTxHex: string; + times?: number; + userAwmUrl?: string; + backupAwmUrl?: string; + /** Defaults to `{ txHex: halfSignedTxHex }` (UTXO/external). Pass a rich EVM object when needed. */ + userHalfSignBody?: unknown; + validateBackupBody?: (body: SplitAwmRecoveryRequestBody) => boolean; +}) { + const userUrl = options.userAwmUrl ?? SPLIT_AWM_USER_URL; + const backupUrl = options.backupAwmUrl ?? SPLIT_AWM_BACKUP_URL; + const times = options.times ?? 1; + const userHalfSignBody = options.userHalfSignBody ?? { txHex: options.halfSignedTxHex }; + const validateBackup = + options.validateBackupBody ?? + ((body: SplitAwmRecoveryRequestBody) => + body.keyToSign === 'backup' && body.halfSignedTransaction?.txHex === options.halfSignedTxHex); + + const userAwmNock = nock(userUrl) + .post(`/api/${options.coin}/multisig/recovery`, (body) => body.keyToSign === 'user') + .times(times) + .reply(200, userHalfSignBody); + + const backupAwmNock = nock(backupUrl) + .post(`/api/${options.coin}/multisig/recovery`, validateBackup) + .times(times) + .reply(200, { txHex: options.fullSignedTxHex }); + + return { userAwmNock, backupAwmNock }; +} + +/** Bridge job nock that split-AWM sync paths must not reach. */ +export function nockAsyncRecoveryJobBypass(coin: string, bridgeUrl = ASYNC_TEST_BRIDGE_URL) { + return nock(bridgeUrl) + .post(`/api/${coin}/multisig/recovery`) + .reply(202, { jobId: 'should-not-reach-bridge' }); +} + export function makeBridgeJob( overrides: Partial = {}, jobId = 'job-123', diff --git a/src/advancedWalletManager/handlers/multisigRecovery.ts b/src/advancedWalletManager/handlers/multisigRecovery.ts index ea769e15..5e714e51 100644 --- a/src/advancedWalletManager/handlers/multisigRecovery.ts +++ b/src/advancedWalletManager/handlers/multisigRecovery.ts @@ -1,14 +1,20 @@ -import { SignFinalOptions } from '@bitgo-beta/abstract-eth'; import { AbstractUtxoCoin } from '@bitgo-beta/abstract-utxo'; import { + BaseCoin, HalfSignedUtxoTransaction, MethodNotImplementedError, MPCType, + SignedTransaction, TransactionRecipient, } from '@bitgo-beta/sdk-core'; -import { AwmApiSpecRouteRequest } from '../routers/advancedWalletManagerApiSpec'; +import { + AwmApiSpecRouteRequest, + RecoveryMultisigEthLikeHalfSignedCodec, + RecoveryMultisigFlatTxHexCodec, +} from '../routers/advancedWalletManagerApiSpec'; import { AdvancedWalletManagerConfig, EnvironmentName } from '../../initConfig'; import logger from '../../shared/logger'; +import { BadRequestError, BitgoApiResponseError } from '../../shared/errors'; import { isEthLikeCoin, isFormattedOfflineVaultTxInfo, isUtxoCoin } from '../../shared/coinUtils'; import { addEthLikeRecoveryExtras, @@ -28,191 +34,441 @@ import { KeySource } from '../../shared/types'; export async function recoveryMultisigTransaction( req: AwmApiSpecRouteRequest<'v1.multisig.recovery', 'post'>, -): Promise { +): Promise { checkRecoveryMode(req.config as AdvancedWalletManagerConfig); - const { userPub, backupPub, bitgoPub, unsignedSweepPrebuildTx, walletContractAddress, coin } = - req.decoded; + const { + userPub, + backupPub, + bitgoPub, + unsignedSweepPrebuildTx, + walletContractAddress, + coin, + keyToSign, + halfSignedTransaction, + } = req.decoded; + + if (keyToSign === 'backup' && !halfSignedTransaction) { + throw new BadRequestError('halfSignedTransaction is required when keyToSign is "backup"'); + } const bitgo = req.bitgo; const baseCoin = await coinFactory.getCoin(coin, bitgo); if (isExternalSigningEnabledForCoin(req.config, baseCoin)) { + // External signing operates on flat txHex strings. An EVM half-signed tx is a rich object + // (halfSigned.txHex nested), so reject backup signing with such a payload as misconfiguration + // rather than silently passing undefined into the key provider. + if ( + keyToSign === 'backup' && + baseCoin.isEVM() && + (!halfSignedTransaction || + typeof (halfSignedTransaction as { txHex?: unknown }).txHex !== 'string') + ) { + throw new BadRequestError( + 'External backup signing for EVM coins requires halfSignedTransaction.txHex (a flat half-signed tx)', + ); + } const keyProvider = new KeyProviderClient(req.config); return recoverTransactionExternally({ keyProvider, userPub, backupPub, unsignedTxHex: unsignedSweepPrebuildTx.txHex, + keyToSign, + halfSignedTxHex: halfSignedTransaction?.txHex, }); } - //fetch prv and check that pub are valid - const userPrv = await retrieveKeyProviderPrvKey({ - pub: userPub, - source: 'user', - cfg: req.config, - }); - const backupPrv = await retrieveKeyProviderPrvKey({ - pub: backupPub, - source: 'backup', - cfg: req.config, - }); + // Fetch only the key(s) needed for this signing step. + const userPrv = + keyToSign === 'backup' + ? undefined + : await retrieveKeyProviderPrvKey({ pub: userPub, source: 'user', cfg: req.config }); + const backupPrv = + keyToSign === 'user' + ? undefined + : await retrieveKeyProviderPrvKey({ pub: backupPub, source: 'backup', cfg: req.config }); - if (!userPrv || !backupPrv) { - const errorMsg = `Error while recovery wallet, missing prv keys for user or backup on pub keys user=${userPub}, backup=${backupPub}`; + if (keyToSign !== 'backup' && !userPrv) { + const errorMsg = `Error during recovery: missing user prv key for pub=${userPub}`; + logger.error(errorMsg); + throw new Error(errorMsg); + } + if (keyToSign !== 'user' && !backupPrv) { + const errorMsg = `Error during recovery: missing backup prv key for pub=${backupPub}`; logger.error(errorMsg); throw new Error(errorMsg); } - // The signed transaction format depends on the coin type so we do this check as a guard - // If you check the type of coin before and after the "if", you may see "BaseCoin" vs "AbstractEthLikeCoin" if (baseCoin.isEVM()) { - // Every recovery method on every coin family varies one from another so we need to ensure with a guard. - if (isEthLikeCoin(baseCoin)) { - const walletKeys = unsignedSweepPrebuildTx.xpubxWithDerivationPath; - const pubs = [walletKeys?.user?.xpub, walletKeys?.backup?.xpub, walletKeys?.bitgo?.xpub]; - const { gasPrice, gasLimit, maxFeePerGas, maxPriorityFeePerGas } = - DEFAULT_MUSIG_ETH_GAS_PARAMS; - - try { - checkIfNoRecipients({ - recipients: unsignedSweepPrebuildTx.recipients, - coin: req.decoded.coin, - }); - const halfSignedTxBase = await baseCoin.signTransaction({ - isLastSignature: false, - prv: userPrv, - pubs, - keyList: walletKeys, - recipients: unsignedSweepPrebuildTx.recipients ?? [], - expireTime: unsignedSweepPrebuildTx.expireTime, - signingKeyNonce: unsignedSweepPrebuildTx.signingKeyNonce, - gasPrice, - gasLimit, - eip1559: { - maxFeePerGas, - maxPriorityFeePerGas, - }, - replayProtectionOptions: getReplayProtectionOptions( - bitgo.env as EnvironmentName, - unsignedSweepPrebuildTx.replayProtectionOptions, - ), - txPrebuild: { - ...unsignedSweepPrebuildTx, - gasPrice, - gasLimit, - eip1559: { - maxFeePerGas, - maxPriorityFeePerGas, - }, - replayProtectionOptions: getReplayProtectionOptions( - bitgo.env as EnvironmentName, - unsignedSweepPrebuildTx.replayProtectionOptions, - ), - }, - walletContractAddress, - }); - - const halfSignedTx = addEthLikeRecoveryExtras({ - env: bitgo.env as EnvironmentName, - signedTx: halfSignedTxBase as SignedEthLikeRecoveryTx, - transaction: unsignedSweepPrebuildTx, - isLastSignature: false, - replayProtectionOptions: unsignedSweepPrebuildTx.replayProtectionOptions, - }); - - const { halfSigned } = halfSignedTx; - const fullSignedTx = await baseCoin.signTransaction({ - isLastSignature: true, - prv: backupPrv, - pubs, - keyList: walletKeys, - recipients: halfSignedTx.recipients ?? [], - expireTime: halfSigned?.expireTime, - signingKeyNonce: halfSigned?.backupKeyNonce, - gasPrice, - gasLimit, - txPrebuild: { - ...halfSignedTx, - txHex: halfSigned?.txHex, - halfSigned, - recipients: halfSigned?.recipients ?? [], - gasPrice, - gasLimit, - eip1559: { - maxFeePerGas, - maxPriorityFeePerGas, - }, - replayProtectionOptions: getReplayProtectionOptions( - bitgo.env as EnvironmentName, - halfSignedTx?.replayProtectionOptions, - ), - } as unknown as SignFinalOptions, - walletContractAddress, - backupKeyNonce: halfSigned?.backupKeyNonce ?? 0, - }); - - return fullSignedTx; - } catch (error) { - logger.error('error while recovering wallet transaction:', error); - throw error; + return recoverEthLikeMultisigTransaction({ + baseCoin, + env: bitgo.env as EnvironmentName, + userPrv, + backupPrv, + unsignedSweepPrebuildTx, + walletContractAddress, + keyToSign, + halfSignedTransaction, + coin, + }); + } + + if (isUtxoCoin(baseCoin)) { + return recoverUtxoMultisigTransaction({ + utxoCoin: baseCoin, + userPub, + backupPub, + bitgoPub, + userPrv, + backupPrv, + unsignedSweepPrebuildTx, + keyToSign, + halfSignedTransaction, + }); + } + + throw new MethodNotImplementedError('Unsupported coin type for recovery: ' + baseCoin); +} + +async function recoverEthLikeMultisigTransaction({ + baseCoin, + env, + userPrv, + backupPrv, + unsignedSweepPrebuildTx, + walletContractAddress, + keyToSign, + halfSignedTransaction, + coin, +}: { + baseCoin: BaseCoin; + env: EnvironmentName; + userPrv?: string; + backupPrv?: string; + unsignedSweepPrebuildTx: any; + walletContractAddress?: string; + keyToSign?: 'user' | 'backup'; + halfSignedTransaction?: SignedTransaction; + coin: string; +}): Promise { + if (!isEthLikeCoin(baseCoin)) { + throw new MethodNotImplementedError('Unsupported EVM coin family for recovery: ' + coin); + } + + const walletKeys = unsignedSweepPrebuildTx.xpubxWithDerivationPath; + const pubs = [walletKeys?.user?.xpub, walletKeys?.backup?.xpub, walletKeys?.bitgo?.xpub]; + + try { + if (keyToSign === 'backup') { + if (!isSignedEthLikeRecoveryTx(halfSignedTransaction)) { + throw new BadRequestError( + 'halfSignedTransaction must be an EVM half-signed recovery tx when keyToSign is "backup"', + ); } - } else { - const errorMsg = 'Unsupported coin type for recovery: ' + req.decoded.coin; - logger.error(errorMsg); - throw new Error(errorMsg); + return await signEthLikeBackupHalf({ + baseCoin, + backupPrv: backupPrv!, + pubs, + walletKeys, + halfSignedTx: halfSignedTransaction, + walletContractAddress, + env, + }); } - } else if (isUtxoCoin(baseCoin)) { - const utxoCoin = baseCoin as unknown as AbstractUtxoCoin; - if (!isFormattedOfflineVaultTxInfo(unsignedSweepPrebuildTx)) { - throw new MethodNotImplementedError('Unknown recovery transaction format'); - } else if (!bitgoPub) { - throw new Error('Unable to recover without bitgo public key'); + + checkIfNoRecipients({ recipients: unsignedSweepPrebuildTx.recipients, coin }); + const halfSignedTx = await signEthLikeUserHalf({ + baseCoin, + userPrv: userPrv!, + pubs, + walletKeys, + unsignedSweepPrebuildTx, + walletContractAddress, + env, + }); + + // User-only: return half-signed tx for the backup AWM to complete. + if (keyToSign === 'user') { + return halfSignedTx; } - try { - const halfSigned = (await utxoCoin.signTransaction({ - isLastSignature: false, - txPrebuild: { - txHex: unsignedSweepPrebuildTx.txHex, - txInfo: unsignedSweepPrebuildTx.txInfo, - }, - allowNonSegwitSigningWithoutPrevTx: true, - pubs: [userPub, backupPub, bitgoPub], - prv: userPrv, - })) as HalfSignedUtxoTransaction; - return await utxoCoin.signTransaction({ - isLastSignature: true, - txPrebuild: { - txHex: halfSigned.txHex, - txInfo: unsignedSweepPrebuildTx.txInfo, - }, - pubs: [userPub, backupPub, bitgoPub], - prv: backupPrv, + + return await signEthLikeBackupHalf({ + baseCoin, + backupPrv: backupPrv!, + pubs, + walletKeys, + halfSignedTx, + walletContractAddress, + env, + }); + } catch (error) { + logger.error('error while recovering wallet transaction:', error); + throw error; + } +} + +// Signs the first (user) half of an EVM recovery tx and decorates it with recovery extras +// (backupKeyNonce, replayProtectionOptions, etc.) needed by the backup AWM's completion step. +async function signEthLikeUserHalf({ + baseCoin, + userPrv, + pubs, + walletKeys, + unsignedSweepPrebuildTx, + walletContractAddress, + env, +}: { + baseCoin: BaseCoin; + userPrv: string; + pubs: (string | undefined)[]; + walletKeys: unknown; + unsignedSweepPrebuildTx: any; + walletContractAddress?: string; + env: EnvironmentName; +}): Promise { + const { gasPrice, gasLimit, maxFeePerGas, maxPriorityFeePerGas } = DEFAULT_MUSIG_ETH_GAS_PARAMS; + const replayProtectionOptions = getReplayProtectionOptions( + env, + unsignedSweepPrebuildTx.replayProtectionOptions, + ); + + // Cast to BaseCoin for the loose SignTransactionOptions signature; the + // AbstractEthLikeNewCoins overload's stricter txPrebuild types don't fit recovery. + const halfSignedTxBase = await (baseCoin as BaseCoin).signTransaction({ + isLastSignature: false, + prv: userPrv, + pubs, + keyList: walletKeys, + recipients: unsignedSweepPrebuildTx.recipients ?? [], + expireTime: unsignedSweepPrebuildTx.expireTime, + signingKeyNonce: unsignedSweepPrebuildTx.signingKeyNonce, + gasPrice, + gasLimit, + eip1559: { maxFeePerGas, maxPriorityFeePerGas }, + replayProtectionOptions, + txPrebuild: { + ...unsignedSweepPrebuildTx, + gasPrice, + gasLimit, + eip1559: { maxFeePerGas, maxPriorityFeePerGas }, + replayProtectionOptions, + }, + walletContractAddress, + }); + + return addEthLikeRecoveryExtras({ + env, + signedTx: halfSignedTxBase as SignedEthLikeRecoveryTx, + transaction: unsignedSweepPrebuildTx, + isLastSignature: false, + replayProtectionOptions: unsignedSweepPrebuildTx.replayProtectionOptions, + }); +} + +// Completes an EVM recovery tx with the backup key. Shared by the split-AWM backup-only path +// (halfSignedTx comes from the request body) and the single-AWM path (halfSignedTx comes from +// signEthLikeUserHalf above) — both hand off an identical shape to sign. +async function signEthLikeBackupHalf({ + baseCoin, + backupPrv, + pubs, + walletKeys, + halfSignedTx, + walletContractAddress, + env, +}: { + baseCoin: BaseCoin; + backupPrv: string; + pubs: (string | undefined)[]; + walletKeys: unknown; + halfSignedTx: SignedEthLikeRecoveryTx; + walletContractAddress?: string; + env: EnvironmentName; +}): Promise { + const { gasPrice, gasLimit, maxFeePerGas, maxPriorityFeePerGas } = DEFAULT_MUSIG_ETH_GAS_PARAMS; + const { halfSigned } = halfSignedTx; + + return await (baseCoin as BaseCoin).signTransaction({ + isLastSignature: true, + prv: backupPrv, + pubs, + keyList: walletKeys, + recipients: halfSignedTx.recipients ?? [], + expireTime: halfSigned?.expireTime, + signingKeyNonce: halfSigned?.backupKeyNonce, + gasPrice, + gasLimit, + txPrebuild: { + ...(halfSignedTx as Record), + txHex: halfSigned?.txHex, + halfSigned, + recipients: halfSigned?.recipients ?? [], + gasPrice, + gasLimit, + eip1559: { maxFeePerGas, maxPriorityFeePerGas }, + replayProtectionOptions: getReplayProtectionOptions( + env, + halfSignedTx?.replayProtectionOptions, + ), + }, + walletContractAddress, + backupKeyNonce: halfSigned?.backupKeyNonce ?? 0, + }); +} + +async function recoverUtxoMultisigTransaction({ + utxoCoin, + userPub, + backupPub, + bitgoPub, + userPrv, + backupPrv, + unsignedSweepPrebuildTx, + keyToSign, + halfSignedTransaction, +}: { + utxoCoin: AbstractUtxoCoin; + userPub: string; + backupPub: string; + bitgoPub?: string; + userPrv?: string; + backupPrv?: string; + unsignedSweepPrebuildTx: any; + keyToSign?: 'user' | 'backup'; + halfSignedTransaction?: SignedTransaction; +}): Promise { + if (keyToSign !== 'backup' && !isFormattedOfflineVaultTxInfo(unsignedSweepPrebuildTx)) { + throw new MethodNotImplementedError('Unknown recovery transaction format'); + } + if (!bitgoPub) { + throw new Error('Unable to recover without bitgo public key'); + } + + const walletPubs = [userPub, backupPub, bitgoPub] as [string, string, string]; + + try { + if (keyToSign === 'backup') { + if (!isHalfSignedUtxoTransaction(halfSignedTransaction)) { + throw new BadRequestError( + 'halfSignedTransaction must be a UTXO half-signed tx { txHex } when keyToSign is "backup"', + ); + } + if (!unsignedSweepPrebuildTx.txInfo) { + throw new BadRequestError( + 'unsignedSweepPrebuildTx.txInfo is required for backup-only UTXO recovery', + ); + } + return await signUtxoFullTx({ + utxoCoin, + backupPrv: backupPrv!, + walletPubs, + txHex: halfSignedTransaction.txHex, + txInfo: unsignedSweepPrebuildTx.txInfo, }); - } catch (e) { - throw new Error('Something went wrong signing transaction'); } - } else { - throw new MethodNotImplementedError('Unsupported coin type for recovery: ' + baseCoin); + + const halfSigned = await signUtxoHalfTx({ + utxoCoin, + userPrv: userPrv!, + walletPubs, + unsignedSweepPrebuildTx, + }); + + // User-only: return half-signed tx for the backup AWM to complete. + if (keyToSign === 'user') { + return halfSigned; + } + + return await signUtxoFullTx({ + utxoCoin, + backupPrv: backupPrv!, + walletPubs, + txHex: halfSigned.txHex, + txInfo: unsignedSweepPrebuildTx.txInfo, + }); + } catch (error) { + logger.error('error while recovering UTXO recovery transaction:', error); + throw error; } } +async function signUtxoHalfTx({ + utxoCoin, + userPrv, + walletPubs, + unsignedSweepPrebuildTx, +}: { + utxoCoin: AbstractUtxoCoin; + userPrv: string; + walletPubs: [string, string, string]; + unsignedSweepPrebuildTx: any; +}): Promise { + return (await utxoCoin.signTransaction({ + isLastSignature: false, + txPrebuild: { + txHex: unsignedSweepPrebuildTx.txHex, + txInfo: unsignedSweepPrebuildTx.txInfo, + }, + allowNonSegwitSigningWithoutPrevTx: true, + pubs: walletPubs, + prv: userPrv, + })) as HalfSignedUtxoTransaction; +} + +async function signUtxoFullTx({ + utxoCoin, + backupPrv, + walletPubs, + txHex, + txInfo, +}: { + utxoCoin: AbstractUtxoCoin; + backupPrv: string; + walletPubs: [string, string, string]; + txHex: string; + txInfo: any; +}): Promise { + return await utxoCoin.signTransaction({ + isLastSignature: true, + txPrebuild: { txHex, txInfo }, + pubs: walletPubs, + prv: backupPrv, + }); +} + async function recoverTransactionExternally({ keyProvider, userPub, backupPub, unsignedTxHex, + keyToSign, + halfSignedTxHex, }: { keyProvider: KeyProviderClient; userPub: string; backupPub: string; unsignedTxHex: string; + keyToSign?: 'user' | 'backup'; + halfSignedTxHex?: string; }): Promise<{ txHex: string }> { - const errorResponse = (error: any, keySource: string) => ({ - status: error.status || 500, - message: error.message || `Failed to sign recovery transaction for source=${keySource}`, - }); + if (keyToSign === 'backup') { + if (!halfSignedTxHex) { + throw new BadRequestError('halfSignedTxHex is required for backup-only external signing'); + } + try { + const fullSignedRes = await keyProvider.sign({ + pub: backupPub, + source: KeySource.BACKUP, + signablePayload: halfSignedTxHex, + algorithm: MPCType.ECDSA, + }); + return { txHex: fullSignedRes.signature }; + } catch (error: unknown) { + throw signingError(error, KeySource.BACKUP); + } + } /** User Key Signs */ let halfSignedRes: SignResponse; @@ -223,8 +479,13 @@ async function recoverTransactionExternally({ signablePayload: unsignedTxHex, algorithm: MPCType.ECDSA, }); - } catch (error: any) { - throw errorResponse(error, KeySource.USER); + } catch (error: unknown) { + throw signingError(error, KeySource.USER); + } + + // User-only: return half-signed tx for the backup AWM to complete. + if (keyToSign === 'user') { + return { txHex: halfSignedRes.signature }; } /** Backup Key Signs */ @@ -236,11 +497,27 @@ async function recoverTransactionExternally({ algorithm: MPCType.ECDSA, }); return { txHex: fullSignedRes.signature }; - } catch (error: any) { - throw errorResponse(error, KeySource.BACKUP); + } catch (error: unknown) { + throw signingError(error, KeySource.BACKUP); } } +// Wraps a key-provider signing failure so the response handler preserves upstream status/message. +function signingError(error: unknown, keySource: KeySource): BitgoApiResponseError { + const status = + error && + typeof error === 'object' && + 'status' in error && + typeof (error as { status: unknown }).status === 'number' + ? (error as { status: number }).status + : 500; + const message = + error instanceof Error && error.message + ? error.message + : `Failed to sign recovery transaction for source=${keySource}`; + return new BitgoApiResponseError(message, status, { keySource }); +} + function checkIfNoRecipients({ recipients, coin, @@ -254,3 +531,12 @@ function checkIfNoRecipients({ throw new Error(errorMsg); } } + +// Runtime narrows for coin-specific halfSignedTransaction shapes (request is `t.any` at the boundary). +function isHalfSignedUtxoTransaction(value: unknown): value is HalfSignedUtxoTransaction { + return RecoveryMultisigFlatTxHexCodec.is(value); +} + +function isSignedEthLikeRecoveryTx(value: unknown): value is SignedEthLikeRecoveryTx { + return RecoveryMultisigEthLikeHalfSignedCodec.is(value); +} diff --git a/src/advancedWalletManager/routers/advancedWalletManagerApiSpec.ts b/src/advancedWalletManager/routers/advancedWalletManagerApiSpec.ts index 3a07cb7a..8999ffd7 100644 --- a/src/advancedWalletManager/routers/advancedWalletManagerApiSpec.ts +++ b/src/advancedWalletManager/routers/advancedWalletManagerApiSpec.ts @@ -72,18 +72,22 @@ const RecoveryMultisigRequest = { bitgoPub: optional(t.string), unsignedSweepPrebuildTx: t.any, walletContractAddress: optional(t.string), - // When set, only sign with the specified key (user half-sign or backup full-sign). - // When omitted, the endpoint signs with both keys (default single-AWM behavior). keyToSign: optional(t.union([t.literal('user'), t.literal('backup')])), - // Required when keyToSign is 'backup': the half-signed transaction from the user-key phase. + // Required when keyToSign is 'backup'; shape varies by coin. halfSignedTransaction: optional(t.any), }; -// Response type for /multisig/recovery endpoint +/** Top-level txHex — UTXO/external/full-signed EVM responses; UTXO `halfSignedTransaction` on backup. */ +export const RecoveryMultisigFlatTxHexCodec = t.type({ txHex: t.string }); + +/** Nested halfSigned.txHex — EVM user half-sign responses and backup `halfSignedTransaction`. */ +export const RecoveryMultisigEthLikeHalfSignedCodec = t.type({ + halfSigned: t.type({ txHex: t.string }), +}); + +// Coin/phase-specific extras (feeInfo, recipients, etc.) pass through unchecked. const RecoveryMultisigResponse: HttpResponse = { - 200: t.type({ - txHex: t.string, - }), // the full signed tx + 200: t.union([RecoveryMultisigFlatTxHexCodec, RecoveryMultisigEthLikeHalfSignedCodec]), ...ErrorResponses, }; diff --git a/src/masterBitgoExpress/clients/advancedWalletManagerClient.ts b/src/masterBitgoExpress/clients/advancedWalletManagerClient.ts index 2b604a25..a576a31b 100644 --- a/src/masterBitgoExpress/clients/advancedWalletManagerClient.ts +++ b/src/masterBitgoExpress/clients/advancedWalletManagerClient.ts @@ -104,10 +104,9 @@ export interface RecoveryMultisigOptions { bitgoPub?: string; unsignedSweepPrebuildTx: RecoveryMultisigUnsignedSweepTx; walletContractAddress: string; - // When set, only sign with the specified key (user half-sign or backup full-sign). keyToSign?: 'user' | 'backup'; - // Required when keyToSign is 'backup': the half-signed transaction from the user-key phase. - halfSignedTransaction?: any; + // Required when keyToSign is 'backup'. + halfSignedTransaction?: SignedTransaction; } interface SignMpcCommitmentParams { @@ -402,7 +401,44 @@ export class AdvancedWalletManagerClient { } /** - * Recover a multisig transaction + * Recover a multisig transaction using only the user key (first signature). + * Returns the half-signed transaction object to be passed to recoveryMultisig + * on the backup AWM as `halfSignedTransaction`. + */ + async recoveryMultisigUserHalfSign( + params: Omit, + ): Promise { + if (!this.coin) { + throw new Error('Coin must be specified to recover a multisig'); + } + + try { + let request = this.apiClient['v1.multisig.recovery'].post({ + ...params, + coin: this.coin, + keyToSign: 'user', + }); + + if (this.tlsMode === TlsMode.MTLS) { + request = request.agent(this.createHttpsAgent()); + } + logger.info('Recovering multisig (user half-sign) for coin: %s', this.coin); + const res = await request.decodeExpecting(200); + + return res.body as SignedTransaction; + } catch (error) { + logger.error( + 'Failed to recover multisig (user half-sign): %s', + (error as DecodeError).decodedResponse?.body, + ); + throw error; + } + } + + /** + * Recover a multisig transaction. + * When params.keyToSign is 'backup', completes a split recovery started by recoveryMultisigUserHalfSign. + * When keyToSign is omitted, signs with both user and backup keys in a single call (single-AWM mode). */ async recoveryMultisig(params: RecoveryMultisigOptions): Promise { if (!this.coin) { @@ -418,7 +454,7 @@ export class AdvancedWalletManagerClient { logger.info('Recovering multisig for coin: %s', this.coin); const res = await request.decodeExpecting(200); - return res.body; + return res.body as SignedTransaction; } catch (error) { logger.error('Failed to recover multisig: %s', (error as DecodeError).decodedResponse?.body); throw error; diff --git a/src/masterBitgoExpress/handlers/handleRecoveryConsolidations.ts b/src/masterBitgoExpress/handlers/handleRecoveryConsolidations.ts index 8b01657e..f3379753 100644 --- a/src/masterBitgoExpress/handlers/handleRecoveryConsolidations.ts +++ b/src/masterBitgoExpress/handlers/handleRecoveryConsolidations.ts @@ -44,7 +44,9 @@ export async function handleRecoveryConsolidations( const bitgo = req.bitgo; const coin = req.decoded.coin; - const awmClient = req.awmUserClient; + const userClient = req.awmUserClient; + const backupClient = req.awmBackupClient; + const hasSeparateBackupAwm = userClient !== backupClient; const isMPC = req.decoded.multisigType === 'tss'; const asyncEnabled = req.config.asyncModeConfig.enabled; @@ -93,8 +95,8 @@ export async function handleRecoveryConsolidations( logger.info(`Found ${txs.length} unsigned consolidation transactions`); - if (asyncEnabled) { - // Async mode supports a single recovery build only; multi-tx batches remain sync-only. + // Split AWM stays sync: the OSO bridge can't sequence a user half-sign then backup full-sign. + if (asyncEnabled && !hasSeparateBackupAwm) { if (txs.length !== 1) { throw new BadRequestError( `Async mode supports a single consolidation recovery only, but built ${txs.length}`, @@ -114,21 +116,38 @@ export async function handleRecoveryConsolidations( const signedTxs = []; try { for (const tx of txs) { - const signedTx = isMPC - ? await awmClient.recoveryMPC({ - userPub, - backupPub, - apiKey, - unsignedSweepPrebuildTx: tx as MPCTx | RecoveryTxRequest, - coinSpecificParams: {}, - walletContractAddress: '', - }) - : await awmClient.recoveryMultisig({ - userPub, - backupPub, - unsignedSweepPrebuildTx: tx as RecoveryTransaction, - walletContractAddress: '', - }); + let signedTx; + if (isMPC) { + signedTx = await userClient.recoveryMPC({ + userPub, + backupPub, + apiKey, + unsignedSweepPrebuildTx: tx as MPCTx | RecoveryTxRequest, + coinSpecificParams: {}, + walletContractAddress: '', + }); + } else if (hasSeparateBackupAwm) { + // Split AWM flow (sync): user AWM signs first, backup AWM completes the signature. + const recoveryBody = { + userPub, + backupPub, + unsignedSweepPrebuildTx: tx as RecoveryTransaction, + walletContractAddress: '', + }; + const halfSignedTx = await userClient.recoveryMultisigUserHalfSign(recoveryBody); + signedTx = await backupClient.recoveryMultisig({ + ...recoveryBody, + keyToSign: 'backup', + halfSignedTransaction: halfSignedTx, + }); + } else { + signedTx = await userClient.recoveryMultisig({ + userPub, + backupPub, + unsignedSweepPrebuildTx: tx as RecoveryTransaction, + walletContractAddress: '', + }); + } signedTxs.push(signedTx); } diff --git a/src/masterBitgoExpress/handlers/recoveryWallet.ts b/src/masterBitgoExpress/handlers/recoveryWallet.ts index b880777d..ece81704 100644 --- a/src/masterBitgoExpress/handlers/recoveryWallet.ts +++ b/src/masterBitgoExpress/handlers/recoveryWallet.ts @@ -25,10 +25,7 @@ import { getReplayProtectionOptions, } from '../../shared/recoveryUtils'; -import { - AdvancedWalletManagerClient, - RecoveryMultisigUnsignedSweepTx, -} from '../clients/advancedWalletManagerClient'; +import { RecoveryMultisigUnsignedSweepTx } from '../clients/advancedWalletManagerClient'; import { MasterApiSpecRouteRequest, ScriptType2Of3 } from '../routers/masterBitGoExpressApiSpec'; import { CoinSpecificParams, CoinSpecificParamsUnion } from '../routers/recoveryRoute'; import { recoverEddsaWallets } from './recoveryEddsa'; @@ -114,21 +111,35 @@ function validateRecoveryParams( async function recoverMultisigOrSubmitJob( req: MasterApiSpecRouteRequest<'v1.wallet.recovery', 'post'>, - awmClient: AdvancedWalletManagerClient, recoveryBody: MultisigRecoveryBody, ): Promise { + const userClient = req.awmUserClient; + const backupClient = req.awmBackupClient; + const hasSeparateBackupAwm = userClient !== backupClient; + + // Split AWM stays sync: the async bridge can't sequence a user half-sign then backup full-sign. + if (hasSeparateBackupAwm) { + const halfSignedTx = await userClient.recoveryMultisigUserHalfSign(recoveryBody); + return backupClient.recoveryMultisig({ + ...recoveryBody, + keyToSign: 'backup', + halfSignedTransaction: halfSignedTx, + }); + } + + // Single-AWM: async submits one user-source job; falls through to sync when async is off. const asyncResult = await submitMultisigRecoveryJob(req, req.decoded.coin, recoveryBody); if (asyncResult) { return asyncResult; } - return awmClient.recoveryMultisig(recoveryBody); + + return userClient.recoveryMultisig(recoveryBody); } async function handleEthLikeRecovery( req: MasterApiSpecRouteRequest<'v1.wallet.recovery', 'post'>, sdkCoin: BaseCoin, commonRecoveryParams: RecoveryParams, - awmClient: AdvancedWalletManagerClient, params: AdvancedWalletManagerRecoveryParams, env: EnvironmentName, ) { @@ -146,7 +157,7 @@ async function handleEthLikeRecovery( isUnsignedSweep: true, }); - return recoverMultisigOrSubmitJob(req, awmClient, { + return recoverMultisigOrSubmitJob(req, { userPub: params.userPub, backupPub: params.backupPub, unsignedSweepPrebuildTx, @@ -158,7 +169,7 @@ async function handleEddsaRecovery( bitgo: BitGoAPI, sdkCoin: BaseCoin, commonRecoveryParams: RecoveryParams, - awmClient: AdvancedWalletManagerClient, + req: MasterApiSpecRouteRequest<'v1.wallet.recovery', 'post'>, params: AdvancedWalletManagerRecoveryParams, ) { const { recoveryDestination, userKey } = commonRecoveryParams; @@ -189,7 +200,7 @@ async function handleEddsaRecovery( } logger.info('Unsigned sweep tx: ', JSON.stringify(unsignedSweepPrebuildTx, null, 2)); - return await awmClient.recoveryMPC({ + return await req.awmUserClient.recoveryMPC({ userPub: params.userPub, backupPub: params.backupPub, apiKey: params.apiKey, @@ -217,7 +228,6 @@ export type UtxoCoinSpecificRecoveryParams = Pick< async function handleUtxoLikeRecovery( req: MasterApiSpecRouteRequest<'v1.wallet.recovery', 'post'>, sdkCoin: BaseCoin, - awmClient: AdvancedWalletManagerClient, recoveryParams: UtxoCoinSpecificRecoveryParams, ): Promise { const abstractUtxoCoin = sdkCoin as unknown as AbstractUtxoCoin; @@ -228,7 +238,7 @@ async function handleUtxoLikeRecovery( throw new MethodNotImplementedError(`Unknown transaction ${JSON.stringify(recoverTx)} created`); } - return recoverMultisigOrSubmitJob(req, awmClient, { + return recoverMultisigOrSubmitJob(req, { userPub: recoveryParams.userKey, backupPub: recoveryParams.backupKey, bitgoPub: recoveryParams.bitgoKey, @@ -244,7 +254,6 @@ export async function handleRecoveryWallet( const bitgo = req.bitgo; const coin = req.decoded.coin; - const awmClient = req.awmUserClient; const { recoveryDestinationAddress, coinSpecificParams } = req.decoded; const sdkCoin = await coinFactory.getCoin(coin, bitgo); @@ -274,7 +283,7 @@ export async function handleRecoveryWallet( recoveryDestination: recoveryDestinationAddress, apiKey: req.decoded.apiKey || '', }, - awmClient, + req, { userPub: commonKeychain, backupPub: commonKeychain, @@ -315,7 +324,7 @@ export async function handleRecoveryWallet( throw new NotImplementedError(`TSS recovery is not supported for coin: ${coin}.`); } - return recoverEcdsaMPCv2Wallets(bitgo, sdkCoin, awmClient, params); + return recoverEcdsaMPCv2Wallets(bitgo, sdkCoin, req.awmUserClient, params); } else { throw new ValidationError( `TSS recovery is not supported for coin ${coin}. ${coin} is neither eddsa nor ecdsa.`, @@ -359,7 +368,6 @@ export async function handleRecoveryWallet( req, sdkCoin, commonRecoveryParams, - awmClient, { userPub, backupPub, @@ -376,7 +384,7 @@ export async function handleRecoveryWallet( } if (isUtxoCoin(sdkCoin)) { - return handleUtxoLikeRecovery(req, sdkCoin, req.awmUserClient, { + return handleUtxoLikeRecovery(req, sdkCoin, { userKey: userPub, backupKey: backupPub, bitgoKey: bitgoPub, diff --git a/src/masterBitgoExpress/handlers/utils/multisigRecoveryUtils.ts b/src/masterBitgoExpress/handlers/utils/multisigRecoveryUtils.ts index 7c9917e6..8eb411f2 100644 --- a/src/masterBitgoExpress/handlers/utils/multisigRecoveryUtils.ts +++ b/src/masterBitgoExpress/handlers/utils/multisigRecoveryUtils.ts @@ -1,7 +1,7 @@ import { SignedTransaction } from '@bitgo-beta/sdk-core'; import { AsyncJobResponse } from '../../clients/bridgeClient.types'; import { RecoveryMultisigUnsignedSweepTx } from '../../clients/advancedWalletManagerClient'; -import { KeySource, MasterExpressConfig } from '../../../shared/types'; +import { KeySource, MasterExpressConfig, UserOrBackupKey } from '../../../shared/types'; import { BitGoRequest } from '../../../types/request'; import { submitJobViaBridgeClient } from './asyncUtils'; import { parseSignedMultisigTransaction } from './multisigSignUtils'; @@ -20,11 +20,12 @@ export async function submitMultisigRecoveryJob( req: BitGoRequest, coin: string, body: MultisigRecoveryBody, + sources: UserOrBackupKey[] = [KeySource.USER], ): Promise { return submitJobViaBridgeClient(req, { path: `/api/${coin}/multisig/recovery`, body, - sources: [KeySource.USER], + sources, operationType: 'multisig_recovery', }); } diff --git a/src/masterBitgoExpress/workers/asyncJobWorker.ts b/src/masterBitgoExpress/workers/asyncJobWorker.ts index 7677184f..fe00d96f 100644 --- a/src/masterBitgoExpress/workers/asyncJobWorker.ts +++ b/src/masterBitgoExpress/workers/asyncJobWorker.ts @@ -193,17 +193,18 @@ export async function handleMultisigSignOperation( logger.info(`${logPrefix} job ${jobId} complete`); } -/** Completes a `multisig_recovery` job by returning the signed sweep tx from AWM (no WP submit). */ +/** Completes a `multisig_recovery` job with the signed sweep tx from AWM (no WP submit). */ export async function handleMultisigRecoveryOperation( job: BridgeJobResponse, bridge: OsoBridgeClient, _bitgo: BitGoAPI, ): Promise { const logPrefix = '[asyncJobWorker:handleMultisigRecoveryOperation]'; + const { jobId, version } = job; + const signedTx = parseSignedRecoveryTransaction( parseAwmResponseBody(job.awmResponse, 'awmResponse'), ); - const { jobId, version } = job; logger.info(`${logPrefix} job ${jobId} recovered - updating job status to complete`); await bridge.updateJob({ diff --git a/src/shared/errors.ts b/src/shared/errors.ts index ce974273..4625355d 100644 --- a/src/shared/errors.ts +++ b/src/shared/errors.ts @@ -12,6 +12,7 @@ export class BitgoApiResponseError extends Error { constructor(message: string, status = 500, result?: any) { super(message); + this.name = 'ApiResponseError'; this.status = status; this.result = result; }