From 1d0352805a316e4ac3741c5ffd520080edbc6fb3 Mon Sep 17 00:00:00 2001 From: Daniel Peng Date: Fri, 26 Jun 2026 09:24:54 -0400 Subject: [PATCH] feat: enhance multisig recovery with split AWM support Ticket: WCN-1082 --- .../recoveryMultisigTransaction.test.ts | 481 ++++++++++++++++++ .../api/master/asyncJobWorker.test.ts | 31 ++ .../api/master/multisigRecoveryUtils.test.ts | 33 ++ .../api/master/recoveryWallet.test.ts | 303 +++++++++++ src/__tests__/api/master/testUtils.ts | 4 +- .../handlers/multisigRecovery.ts | 250 +++++++-- .../routers/advancedWalletManagerApiSpec.ts | 10 +- .../clients/advancedWalletManagerClient.ts | 46 +- .../handlers/handleRecoveryConsolidations.ts | 55 +- .../handlers/recoveryWallet.ts | 41 +- .../handlers/utils/multisigRecoveryUtils.ts | 5 +- .../workers/asyncJobWorker.ts | 10 +- 12 files changed, 1176 insertions(+), 93 deletions(-) diff --git a/src/__tests__/api/advancedWalletManager/recoveryMultisigTransaction.test.ts b/src/__tests__/api/advancedWalletManager/recoveryMultisigTransaction.test.ts index 5e405e5..17fd063 100644 --- a/src/__tests__/api/advancedWalletManager/recoveryMultisigTransaction.test.ts +++ b/src/__tests__/api/advancedWalletManager/recoveryMultisigTransaction.test.ts @@ -280,4 +280,485 @@ describe('UTXO recovery — external signing mode', () => { response.status.should.equal(500); }); + + 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/asyncJobWorker.test.ts b/src/__tests__/api/master/asyncJobWorker.test.ts index ab69c02..e92cfdf 100644 --- a/src/__tests__/api/master/asyncJobWorker.test.ts +++ b/src/__tests__/api/master/asyncJobWorker.test.ts @@ -676,5 +676,36 @@ describe('asyncJobWorker', () => { /expected txHex or halfSigned/, ); }); + + it('uses awmBackupResponse as the final tx for split-AWM two-phase recovery', async () => { + const halfSignedHex = 'half-signed-tx-hex'; + const fullSignedHex = 'full-signed-tx-hex'; + const job = makeRecoveryJob({ + awmResponse: awmOk({ txHex: halfSignedHex }), + awmBackupResponse: awmOk({ txHex: fullSignedHex }), + }); + + const updateNock = nock(BRIDGE_URL) + .patch( + `/job/${job.jobId}`, + (body) => body.status === 'complete' && body.result?.txHex === fullSignedHex, + ) + .reply(204); + + await handleMultisigRecoveryOperation(job, bridge, bitgo); + + updateNock.done(); + }); + + it('throws when awmBackupResponse is present but not a valid signed transaction', async () => { + const job = makeRecoveryJob({ + awmResponse: awmOk({ txHex: 'half-signed-tx-hex' }), + awmBackupResponse: { status: 200, body: { bad: 'shape' } }, + }); + + await handleMultisigRecoveryOperation(job, bridge, bitgo).should.be.rejectedWith( + /expected txHex or halfSigned/, + ); + }); }); }); diff --git a/src/__tests__/api/master/multisigRecoveryUtils.test.ts b/src/__tests__/api/master/multisigRecoveryUtils.test.ts index cd7aec5..62a9fd7 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('submits with user,backup sources for split-AWM recovery', 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/recoveryWallet.test.ts b/src/__tests__/api/master/recoveryWallet.test.ts index c54649a..c14ed6c 100644 --- a/src/__tests__/api/master/recoveryWallet.test.ts +++ b/src/__tests__/api/master/recoveryWallet.test.ts @@ -5,6 +5,7 @@ import sinon from 'sinon'; import { app as expressApp } from '../../../masterBitGoExpressApp'; import { AppMode, MasterExpressConfig, TlsMode } from '../../../shared/types'; import { + ASYNC_TEST_BRIDGE_URL, BitGoAPITestHarness, DEFAULT_ASYNC_MODE_CONFIG, makeMasterExpressTestConfig, @@ -817,3 +818,305 @@ describe('Recovery Tests', () => { }); }); }); + +describe('Split AWM recovery (separate user and backup AWMs)', () => { + const userAwmUrl = 'http://user-awm.invalid'; + const backupAwmUrl = 'http://backup-awm.invalid'; + const accessToken = 'test-token'; + const coin = 'tbtc'; + const userPub = + 'xpub661MyMwAqRbcEtjU21VjQhGDdg5noG6kCGjcpc4EZwnLUxr9Pi56i14Eek8CQqcuGVnXQf3Zy47Uizr5WHDbZ3GumXEFXpwFLHWGbKrWWcg'; + const backupPub = + 'xpub661MyMwAqRbcEnTrcp222pRm7G1ZAbDD3KxXT2XEKRe3jnnvydqnyssewd2eUxgeWr1c1ffHcqqRKB8j3Lw9VR4dvrAhTov4kPKZF5rs6Vr'; + const bitgoPub = + 'xpub661MyMwAqRbcFNUFGFmDcC3Frgtz4FnJqFdCGbzLva2hf5i3ZJuQdsGc3z5FXCVqR9NQ6h2zTyGcQkfFtsLT5St621Fcu1C22kCKhbo4kQy'; + + 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 () => { + const halfSignedTxHex = 'half-signed-utxo-tx-hex'; + const fullSignedTxHex = + '01000000000101edd7a583fef5aabf265e6dca24452581a3cca2671a1fa6b4e404bccb6ff4c83b0000000000ffffffff01780f0000000000002200202120dcf53e62a4cc9d3843993aa2258bd14fbf911a4ea4cf4f3ac840f41702790400473044022043a9256810ef47ce36a092305c0b1ef675bce53e46418eea8cacbf1643e541d90220450766e048b841dac658d0a2ba992628bfe131dff078c3a574cadf67b4946647014730440220360045a15e459ed44aa3e52b86dd6a16dddaf319821f4dcc15627686f377edd102205cb3d5feab1a773c518d43422801e01dd1bc586bb09f6a9ed23a1fc0cfeeb5310169522103a1c425fd9b169e6ab5ed3de596acb777ccae0cda3d91256238b5e739a3f14aae210222a76697605c890dc4365132f9ae0d351952a1aad7eecf78d9923766dbe74a1e21033b21c0758ffbd446204914fa1d1c5921e9f82c2671dac89737666aa9375973e953ae00000000'; + + 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, + }); + + // User AWM: receives keyToSign=user, returns half-signed tx + const userAwmNock = nock(userAwmUrl) + .post(`/api/${coin}/multisig/recovery`, (body) => body.keyToSign === 'user') + .reply(200, { txHex: halfSignedTxHex }); + + // Backup AWM: receives keyToSign=backup and halfSignedTransaction, returns full-signed tx + const backupAwmNock = nock(backupAwmUrl) + .post(`/api/${coin}/multisig/recovery`, (body) => { + return ( + body.keyToSign === 'backup' && + body.halfSignedTransaction !== undefined && + body.halfSignedTransaction.txHex === halfSignedTxHex + ); + }) + .reply(200, { txHex: fullSignedTxHex }); + + const response = await request + .agent( + expressApp( + makeMasterExpressTestConfig(userAwmUrl, { + overrides: { + advancedWalletManagerBackupUrl: backupAwmUrl, + awmBackupServerCaCert: 'dummy-backup-cert', + recoveryMode: true, + }, + }), + ), + ) + .post(`/api/v1/${coin}/advancedwallet/recovery`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + multiSigRecoveryParams: { + userPub, + backupPub, + bitgoPub, + walletContractAddress: '', + }, + recoveryDestinationAddress: + 'tb1qprdy6jwxrrr2qrwgd2tzl8z99hqp29jn6f3sguxulqm448myj6jsy2nwsu', + coin, + apiKey: 'key', + coinSpecificParams: { utxoRecoveryOptions: { scan: 1 } }, + }); + + 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 () => { + const halfSignedTxHex = 'half-signed-utxo-tx-hex'; + const fullSignedTxHex = + '01000000000101edd7a583fef5aabf265e6dca24452581a3cca2671a1fa6b4e404bccb6ff4c83b0000000000ffffffff01780f0000000000002200202120dcf53e62a4cc9d3843993aa2258bd14fbf911a4ea4cf4f3ac840f41702790400473044022043a9256810ef47ce36a092305c0b1ef675bce53e46418eea8cacbf1643e541d90220450766e048b841dac658d0a2ba992628bfe131dff078c3a574cadf67b4946647014730440220360045a15e459ed44aa3e52b86dd6a16dddaf319821f4dcc15627686f377edd102205cb3d5feab1a773c518d43422801e01dd1bc586bb09f6a9ed23a1fc0cfeeb5310169522103a1c425fd9b169e6ab5ed3de596acb777ccae0cda3d91256238b5e739a3f14aae210222a76697605c890dc4365132f9ae0d351952a1aad7eecf78d9923766dbe74a1e21033b21c0758ffbd446204914fa1d1c5921e9f82c2671dac89737666aa9375973e953ae00000000'; + + 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, + }); + + // The bridge can't sequence a two-phase recovery, so split-AWM always signs synchronously: + // user AWM half-signs, backup AWM full-signs with the half-signed tx. The bridge is NOT called. + const bridgeNock = nock(ASYNC_TEST_BRIDGE_URL) + .post(`/api/${coin}/multisig/recovery`) + .reply(202, { jobId: 'should-not-reach-bridge' }); + const userAwmNock = nock(userAwmUrl) + .post(`/api/${coin}/multisig/recovery`, (body) => body.keyToSign === 'user') + .reply(200, { txHex: halfSignedTxHex }); + const backupAwmNock = nock(backupAwmUrl) + .post(`/api/${coin}/multisig/recovery`, (body) => body.keyToSign === 'backup') + .reply(200, { txHex: fullSignedTxHex }); + + const response = await request + .agent( + expressApp( + makeMasterExpressTestConfig(userAwmUrl, { + asyncEnabled: true, + overrides: { + advancedWalletManagerBackupUrl: backupAwmUrl, + awmBackupServerCaCert: 'dummy-backup-cert', + recoveryMode: true, + }, + }), + ), + ) + .post(`/api/v1/${coin}/advancedwallet/recovery`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + multiSigRecoveryParams: { userPub, backupPub, bitgoPub, walletContractAddress: '' }, + recoveryDestinationAddress: + 'tb1qprdy6jwxrrr2qrwgd2tzl8z99hqp29jn6f3sguxulqm448myj6jsy2nwsu', + coin, + apiKey: 'key', + coinSpecificParams: { utxoRecoveryOptions: { scan: 1 } }, + }); + + 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', + }); + + // Rich EVM half-signed object the user AWM returns and the backup AWM must receive verbatim. + const halfSignedObject = { + halfSigned: { + txHex: '0xhalfsigned', + recipients: [{ address: '0xrecipient', amount: '1000' }], + expireTime: 123, + backupKeyNonce: 1, + }, + recipients: [{ address: '0xrecipient', amount: '1000' }], + }; + + const userAwmNock = nock(userAwmUrl) + .post(`/api/${ethCoinId}/multisig/recovery`, (body) => body.keyToSign === 'user') + .reply(200, halfSignedObject); + + const backupAwmNock = nock(backupAwmUrl) + .post(`/api/${ethCoinId}/multisig/recovery`, (body) => { + return ( + body.keyToSign === 'backup' && + body.halfSignedTransaction !== undefined && + body.halfSignedTransaction.halfSigned !== undefined && + body.halfSignedTransaction.halfSigned.txHex === '0xhalfsigned' + ); + }) + .reply(200, { txHex: '0xfullsigned' }); + + const response = await request + .agent( + expressApp( + makeMasterExpressTestConfig(userAwmUrl, { + overrides: { + advancedWalletManagerBackupUrl: backupAwmUrl, + awmBackupServerCaCert: 'dummy-backup-cert', + recoveryMode: true, + }, + }), + ), + ) + .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 666d307..047fb68 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 }); diff --git a/src/advancedWalletManager/handlers/multisigRecovery.ts b/src/advancedWalletManager/handlers/multisigRecovery.ts index ea769e1..e5d974e 100644 --- a/src/advancedWalletManager/handlers/multisigRecovery.ts +++ b/src/advancedWalletManager/handlers/multisigRecovery.ts @@ -1,14 +1,16 @@ -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 { 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,39 +30,69 @@ 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()) { + if ( + !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); } @@ -76,13 +108,54 @@ export async function recoveryMultisigTransaction( DEFAULT_MUSIG_ETH_GAS_PARAMS; try { + if (keyToSign === 'backup') { + if (!isSignedEthLikeRecoveryTx(halfSignedTransaction)) { + throw new BadRequestError( + 'halfSignedTransaction must be an EVM half-signed recovery tx when keyToSign is "backup"', + ); + } + const halfSignedTx = halfSignedTransaction; + const { halfSigned } = halfSignedTx; + // Cast to BaseCoin for the loose SignTransactionOptions signature; the + // AbstractEthLikeNewCoins overload's stricter txPrebuild types don't fit recovery. + 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( + bitgo.env as EnvironmentName, + halfSignedTx?.replayProtectionOptions, + ), + }, + walletContractAddress, + backupKeyNonce: halfSigned?.backupKeyNonce ?? 0, + }); + } + checkIfNoRecipients({ recipients: unsignedSweepPrebuildTx.recipients, coin: req.decoded.coin, }); - const halfSignedTxBase = await baseCoin.signTransaction({ + const halfSignedTxBase = await (baseCoin as BaseCoin).signTransaction({ isLastSignature: false, - prv: userPrv, + prv: userPrv!, pubs, keyList: walletKeys, recipients: unsignedSweepPrebuildTx.recipients ?? [], @@ -122,10 +195,15 @@ export async function recoveryMultisigTransaction( replayProtectionOptions: unsignedSweepPrebuildTx.replayProtectionOptions, }); + // User-only: return half-signed tx for the backup AWM to complete. + if (keyToSign === 'user') { + return halfSignedTx; + } + const { halfSigned } = halfSignedTx; - const fullSignedTx = await baseCoin.signTransaction({ + const fullSignedTx = await (baseCoin as BaseCoin).signTransaction({ isLastSignature: true, - prv: backupPrv, + prv: backupPrv!, pubs, keyList: walletKeys, recipients: halfSignedTx.recipients ?? [], @@ -134,7 +212,7 @@ export async function recoveryMultisigTransaction( gasPrice, gasLimit, txPrebuild: { - ...halfSignedTx, + ...(halfSignedTx as Record), txHex: halfSigned?.txHex, halfSigned, recipients: halfSigned?.recipients ?? [], @@ -148,7 +226,7 @@ export async function recoveryMultisigTransaction( bitgo.env as EnvironmentName, halfSignedTx?.replayProtectionOptions, ), - } as unknown as SignFinalOptions, + }, walletContractAddress, backupKeyNonce: halfSigned?.backupKeyNonce ?? 0, }); @@ -164,13 +242,38 @@ export async function recoveryMultisigTransaction( throw new Error(errorMsg); } } else if (isUtxoCoin(baseCoin)) { - const utxoCoin = baseCoin as unknown as AbstractUtxoCoin; - if (!isFormattedOfflineVaultTxInfo(unsignedSweepPrebuildTx)) { + const utxoCoin = baseCoin as AbstractUtxoCoin; + if (keyToSign !== 'backup' && !isFormattedOfflineVaultTxInfo(unsignedSweepPrebuildTx)) { throw new MethodNotImplementedError('Unknown recovery transaction format'); - } else if (!bitgoPub) { + } + if (!bitgoPub) { throw new Error('Unable to recover without bitgo public key'); } try { + const walletPubs = [userPub, backupPub, bitgoPub] as [string, string, string]; + + 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 utxoCoin.signTransaction({ + isLastSignature: true, + txPrebuild: { + txHex: halfSignedTransaction.txHex, + txInfo: unsignedSweepPrebuildTx.txInfo, + }, + pubs: walletPubs, + prv: backupPrv!, + }); + } + const halfSigned = (await utxoCoin.signTransaction({ isLastSignature: false, txPrebuild: { @@ -178,20 +281,27 @@ export async function recoveryMultisigTransaction( txInfo: unsignedSweepPrebuildTx.txInfo, }, allowNonSegwitSigningWithoutPrevTx: true, - pubs: [userPub, backupPub, bitgoPub], - prv: userPrv, + pubs: walletPubs, + prv: userPrv!, })) as HalfSignedUtxoTransaction; + + // User-only: return half-signed tx for the backup AWM to complete. + if (keyToSign === 'user') { + return halfSigned; + } + return await utxoCoin.signTransaction({ isLastSignature: true, txPrebuild: { txHex: halfSigned.txHex, txInfo: unsignedSweepPrebuildTx.txInfo, }, - pubs: [userPub, backupPub, bitgoPub], - prv: backupPrv, + pubs: walletPubs, + prv: backupPrv!, }); - } catch (e) { - throw new Error('Something went wrong signing transaction'); + } catch (error) { + logger.error('error while recovering UTXO recovery transaction:', error); + throw error; } } else { throw new MethodNotImplementedError('Unsupported coin type for recovery: ' + baseCoin); @@ -203,16 +313,32 @@ async function recoverTransactionExternally({ 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 +349,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 +367,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 +401,26 @@ function checkIfNoRecipients({ throw new Error(errorMsg); } } + +// Runtime narrows for the coin-specific half-signed tx shapes (halfSignedTransaction is `any`). +function isHalfSignedUtxoTransaction(value: unknown): value is HalfSignedUtxoTransaction { + return ( + typeof value === 'object' && + value !== null && + typeof (value as HalfSignedUtxoTransaction).txHex === 'string' + ); +} + +function isSignedEthLikeRecoveryTx(value: unknown): value is SignedEthLikeRecoveryTx { + if (typeof value !== 'object' || value === null) { + return false; + } + const halfSigned = (value as { halfSigned?: unknown }).halfSigned; + // The backup path reads halfSigned.txHex and optionally expireTime/backupKeyNonce/recipients, + // so require halfSigned to be a non-null object with a string txHex. + return ( + typeof halfSigned === 'object' && + halfSigned !== null && + typeof (halfSigned as { txHex?: unknown }).txHex === 'string' + ); +} diff --git a/src/advancedWalletManager/routers/advancedWalletManagerApiSpec.ts b/src/advancedWalletManager/routers/advancedWalletManagerApiSpec.ts index 3a07cb7..b53180a 100644 --- a/src/advancedWalletManager/routers/advancedWalletManagerApiSpec.ts +++ b/src/advancedWalletManager/routers/advancedWalletManagerApiSpec.ts @@ -72,18 +72,14 @@ 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 +// Mirrors the SDK's SignedTransaction union; runtime shape varies by coin/phase (handler narrows). const RecoveryMultisigResponse: HttpResponse = { - 200: t.type({ - txHex: t.string, - }), // the full signed tx + 200: t.any, ...ErrorResponses, }; diff --git a/src/masterBitgoExpress/clients/advancedWalletManagerClient.ts b/src/masterBitgoExpress/clients/advancedWalletManagerClient.ts index 2b604a2..a576a31 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 8b01657..f337975 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 b880777..95ebc16 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,34 @@ function validateRecoveryParams( async function recoverMultisigOrSubmitJob( req: MasterApiSpecRouteRequest<'v1.wallet.recovery', 'post'>, - awmClient: AdvancedWalletManagerClient, recoveryBody: MultisigRecoveryBody, ): Promise { + const userClient = req.awmUserClient; + const backupClient = req.awmBackupClient; + + // Split AWM stays sync: the OSO bridge can't sequence a user half-sign then backup full-sign. + if (userClient !== backupClient) { + 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 +156,7 @@ async function handleEthLikeRecovery( isUnsignedSweep: true, }); - return recoverMultisigOrSubmitJob(req, awmClient, { + return recoverMultisigOrSubmitJob(req, { userPub: params.userPub, backupPub: params.backupPub, unsignedSweepPrebuildTx, @@ -158,7 +168,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 +199,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 +227,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 +237,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 +253,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 +282,7 @@ export async function handleRecoveryWallet( recoveryDestination: recoveryDestinationAddress, apiKey: req.decoded.apiKey || '', }, - awmClient, + req, { userPub: commonKeychain, backupPub: commonKeychain, @@ -315,7 +323,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 +367,6 @@ export async function handleRecoveryWallet( req, sdkCoin, commonRecoveryParams, - awmClient, { userPub, backupPub, @@ -376,7 +383,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 7c9917e..8eb411f 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 7677184..51083b7 100644 --- a/src/masterBitgoExpress/workers/asyncJobWorker.ts +++ b/src/masterBitgoExpress/workers/asyncJobWorker.ts @@ -193,17 +193,21 @@ 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 isSplitRecovery = job.awmBackupResponse !== undefined; + const finalResponse = isSplitRecovery ? job.awmBackupResponse : job.awmResponse; + const responseField = isSplitRecovery ? 'awmBackupResponse' : 'awmResponse'; const signedTx = parseSignedRecoveryTransaction( - parseAwmResponseBody(job.awmResponse, 'awmResponse'), + parseAwmResponseBody(finalResponse, responseField), ); - const { jobId, version } = job; logger.info(`${logPrefix} job ${jobId} recovered - updating job status to complete`); await bridge.updateJob({