diff --git a/masterBitgoExpress.json b/masterBitgoExpress.json index 6f1e3cd5..186dfc24 100644 --- a/masterBitgoExpress.json +++ b/masterBitgoExpress.json @@ -174,7 +174,6 @@ } }, "required": [ - "pubkey", "source" ] } @@ -808,23 +807,107 @@ "coinSpecificParams": { "type": "object", "properties": { - "addressScan": { - "type": "number" + "evmRecoveryOptions": { + "type": "object", + "properties": { + "eip1559": { + "type": "object", + "properties": { + "maxFeePerGas": { + "type": "number" + }, + "maxPriorityFeePerGas": { + "type": "number" + } + }, + "required": [ + "maxFeePerGas", + "maxPriorityFeePerGas" + ] + }, + "gasLimit": { + "type": "number" + }, + "gasPrice": { + "type": "number" + }, + "replayProtectionOptions": { + "type": "object", + "properties": { + "chain": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + "hardfork": { + "type": "string" + } + }, + "required": [ + "chain", + "hardfork" + ] + }, + "scan": { + "type": "number" + } + } }, - "feeRate": { - "type": "number" + "solanaRecoveryOptions": { + "type": "object", + "properties": { + "closeAtaAddress": { + "type": "string" + }, + "durableNonce": { + "type": "object", + "properties": { + "publicKey": { + "type": "string" + }, + "secretKey": { + "type": "string" + } + }, + "required": [ + "publicKey", + "secretKey" + ] + }, + "programId": { + "type": "string" + }, + "recoveryDestinationAtaAddress": { + "type": "string" + }, + "tokenContractAddress": { + "type": "string" + } + } }, - "ignoreAddressTypes": { - "type": "array", - "items": { - "type": "string", - "enum": [ - "p2sh", - "p2shP2wsh", - "p2wsh", - "p2tr", - "p2trMusig2" - ] + "utxoRecoveryOptions": { + "type": "object", + "properties": { + "feeRate": { + "type": "number" + }, + "ignoreAddressTypes": { + "type": "array", + "items": { + "type": "string" + } + }, + "scan": { + "type": "number" + }, + "userKeyPath": { + "type": "string" + } } } } diff --git a/src/__tests__/api/master/recoveryWallet.test.ts b/src/__tests__/api/master/recoveryWallet.test.ts index e6e185b9..6d60416a 100644 --- a/src/__tests__/api/master/recoveryWallet.test.ts +++ b/src/__tests__/api/master/recoveryWallet.test.ts @@ -11,15 +11,11 @@ import { BitGo } from 'bitgo'; import { EnclavedExpressClient } from '../../../api/master/clients/enclavedExpressClient'; import { CoinFamily } from '@bitgo/statics'; -describe('utxo recovery', () => { +describe('Recovery Tests', () => { let agent: request.SuperAgentTest; let mockBitgo: BitGo; - let mockRecover: sinon.SinonStub; - let mockIsValidPub: sinon.SinonStub; let coinStub: sinon.SinonStub; - let mockRecoverResponse: any; const enclavedExpressUrl = 'http://enclaved.invalid'; - const coin = 'tbtc'; const accessToken = 'test-token'; const config: MasterExpressConfig = { appMode: AppMode.MASTER_EXPRESS, @@ -41,39 +37,9 @@ describe('utxo recovery', () => { nock.disableNetConnect(); nock.enableNetConnect('127.0.0.1'); - // Setup mock response - mockRecoverResponse = { - txHex: - '0100000001edd7a583fef5aabf265e6dca24452581a3cca2671a1fa6b4e404bccb6ff4c83b0000000000ffffffff01780f0000000000002200202120dcf53e62a4cc9d3843993aa2258bd14fbf911a4ea4cf4f3ac840f417027900000000', - txInfo: { - unspents: [ - { - id: '3bc8f46fcbbc04e4b4a61f1a67a2cca381254524ca6d5e26bfaaf5fe83a5d7ed:0', - address: 'tb1qprdy6jwxrrr2qrwgd2tzl8z99hqp29jn6f3sguxulqm448myj6jsy2nwsu', - value: 4000, - chain: 20, - index: 0, - valueString: '4000', - }, - ], - }, - feeInfo: {}, - coin: 'tbtc', - }; - - // Create mock methods - mockRecover = sinon.stub().resolves(mockRecoverResponse); - mockIsValidPub = sinon.stub().returns(true); - const mockCoin = { - recover: mockRecover, - isValidPub: mockIsValidPub, - getFamily: sinon.stub().returns(CoinFamily.BTC), - }; - coinStub = sinon.stub().returns(mockCoin); - - // Create mock BitGo instance + // Create mock BitGo instance with base functionality mockBitgo = { - coin: coinStub, + coin: sinon.stub(), _coinFactory: {}, _useAms: false, initCoinFactory: sinon.stub(), @@ -102,6 +68,8 @@ describe('utxo recovery', () => { getAsUser: sinon.stub(), } as unknown as BitGo; + coinStub = mockBitgo.coin as sinon.SinonStub; + // Setup middleware stubs before creating app sinon.stub(middleware, 'prepareBitGo').callsFake(() => (req, res, next) => { (req as BitGoRequest).bitgo = mockBitgo; @@ -109,16 +77,6 @@ describe('utxo recovery', () => { next(); }); - sinon.stub(masterMiddleware, 'validateMasterExpressConfig').callsFake((req, res, next) => { - (req as BitGoRequest).params = { coin }; - (req as BitGoRequest).enclavedExpressClient = new EnclavedExpressClient( - config, - coin, - ); - next(); - return undefined; - }); - // Create app after middleware is stubbed const app = expressApp(config); agent = request.agent(app); @@ -129,68 +87,399 @@ describe('utxo recovery', () => { sinon.restore(); }); - it('should recover a UTXO wallet by calling the enclaved express service', async () => { - const userPub = 'xpub_user'; - const backupPub = 'xpub_backup'; - const bitgoPub = 'xpub_bitgo'; - const recoveryDestination = 'tb1qprdy6jwxrrr2qrwgd2tzl8z99hqp29jn6f3sguxulqm448myj6jsy2nwsu'; - - // Mock the enclaved express recovery call - const recoveryNock = nock(enclavedExpressUrl) - .post(`/api/${coin}/multisig/recovery`, { - userPub, - backupPub, - bitgoPub, - unsignedSweepPrebuildTx: mockRecoverResponse, - walletContractAddress: '', - }) - .reply(200, { + describe('UTXO coin recovery', () => { + let mockRecover: sinon.SinonStub; + let mockIsValidPub: sinon.SinonStub; + let mockRecoverResponse: any; + const coin = 'tbtc'; + + beforeEach(() => { + // Setup mock response for UTXO recovery + mockRecoverResponse = { txHex: - '01000000000101edd7a583fef5aabf265e6dca24452581a3cca2671a1fa6b4e404bccb6ff4c83b0000000000ffffffff01780f0000000000002200202120dcf53e62a4cc9d3843993aa2258bd14fbf911a4ea4cf4f3ac840f41702790400473044022043a9256810ef47ce36a092305c0b1ef675bce53e46418eea8cacbf1643e541d90220450766e048b841dac658d0a2ba992628bfe131dff078c3a574cadf67b4946647014730440220360045a15e459ed44aa3e52b86dd6a16dddaf319821f4dcc15627686f377edd102205cb3d5feab1a773c518d43422801e01dd1bc586bb09f6a9ed23a1fc0cfeeb5310169522103a1c425fd9b169e6ab5ed3de596acb777ccae0cda3d91256238b5e739a3f14aae210222a76697605c890dc4365132f9ae0d351952a1aad7eecf78d9923766dbe74a1e21033b21c0758ffbd446204914fa1d1c5921e9f82c2671dac89737666aa9375973e953ae00000000', + '0100000001edd7a583fef5aabf265e6dca24452581a3cca2671a1fa6b4e404bccb6ff4c83b0000000000ffffffff01780f0000000000002200202120dcf53e62a4cc9d3843993aa2258bd14fbf911a4ea4cf4f3ac840f417027900000000', + txInfo: { + unspents: [ + { + id: '3bc8f46fcbbc04e4b4a61f1a67a2cca381254524ca6d5e26bfaaf5fe83a5d7ed:0', + address: 'tb1qprdy6jwxrrr2qrwgd2tzl8z99hqp29jn6f3sguxulqm448myj6jsy2nwsu', + value: 4000, + chain: 20, + index: 0, + valueString: '4000', + }, + ], + }, + feeInfo: {}, + coin: 'tbtc', + }; + + // Create mock methods + mockRecover = sinon.stub().resolves(mockRecoverResponse); + mockIsValidPub = sinon.stub().returns(true); + const mockCoin = { + recover: mockRecover, + isValidPub: mockIsValidPub, + getFamily: sinon.stub().returns(CoinFamily.BTC), + }; + coinStub.withArgs(coin).returns(mockCoin); + + // Setup coin middleware + sinon.stub(masterMiddleware, 'validateMasterExpressConfig').callsFake((req, res, next) => { + (req as BitGoRequest).params = { coin }; + (req as BitGoRequest).enclavedExpressClient = + new EnclavedExpressClient(config, coin); + next(); + return undefined; }); + }); - const response = await agent - .post(`/api/${coin}/wallet/recovery`) - .set('Authorization', `Bearer ${accessToken}`) - .send({ - multiSigRecoveryParams: { + it('should recover a UTXO wallet by calling the enclaved express service', async () => { + const userPub = 'xpub_user'; + const backupPub = 'xpub_backup'; + const bitgoPub = 'xpub_bitgo'; + const recoveryDestination = 'tb1qprdy6jwxrrr2qrwgd2tzl8z99hqp29jn6f3sguxulqm448myj6jsy2nwsu'; + + // Mock the enclaved express recovery call + const recoveryNock = nock(enclavedExpressUrl) + .post(`/api/${coin}/multisig/recovery`, { userPub, backupPub, bitgoPub, + unsignedSweepPrebuildTx: mockRecoverResponse, walletContractAddress: '', - }, - recoveryDestinationAddress: recoveryDestination, - coin, - apiKey: 'key', - coinSpecificParams: { - addressScan: 1, - }, + }) + .reply(200, { + txHex: + '01000000000101edd7a583fef5aabf265e6dca24452581a3cca2671a1fa6b4e404bccb6ff4c83b0000000000ffffffff01780f0000000000002200202120dcf53e62a4cc9d3843993aa2258bd14fbf911a4ea4cf4f3ac840f41702790400473044022043a9256810ef47ce36a092305c0b1ef675bce53e46418eea8cacbf1643e541d90220450766e048b841dac658d0a2ba992628bfe131dff078c3a574cadf67b4946647014730440220360045a15e459ed44aa3e52b86dd6a16dddaf319821f4dcc15627686f377edd102205cb3d5feab1a773c518d43422801e01dd1bc586bb09f6a9ed23a1fc0cfeeb5310169522103a1c425fd9b169e6ab5ed3de596acb777ccae0cda3d91256238b5e739a3f14aae210222a76697605c890dc4365132f9ae0d351952a1aad7eecf78d9923766dbe74a1e21033b21c0758ffbd446204914fa1d1c5921e9f82c2671dac89737666aa9375973e953ae00000000', + }); + + const response = await agent + .post(`/api/${coin}/wallet/recovery`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + multiSigRecoveryParams: { + userPub, + backupPub, + bitgoPub, + walletContractAddress: '', + }, + recoveryDestinationAddress: recoveryDestination, + coin, + apiKey: 'key', + coinSpecificParams: { + utxoRecoveryOptions: { + scan: 1, + }, + }, + }); + + response.status.should.equal(200); + response.body.should.have.property('txHex'); + response.body.txHex.should.equal( + '01000000000101edd7a583fef5aabf265e6dca24452581a3cca2671a1fa6b4e404bccb6ff4c83b0000000000ffffffff01780f0000000000002200202120dcf53e62a4cc9d3843993aa2258bd14fbf911a4ea4cf4f3ac840f41702790400473044022043a9256810ef47ce36a092305c0b1ef675bce53e46418eea8cacbf1643e541d90220450766e048b841dac658d0a2ba992628bfe131dff078c3a574cadf67b4946647014730440220360045a15e459ed44aa3e52b86dd6a16dddaf319821f4dcc15627686f377edd102205cb3d5feab1a773c518d43422801e01dd1bc586bb09f6a9ed23a1fc0cfeeb5310169522103a1c425fd9b169e6ab5ed3de596acb777ccae0cda3d91256238b5e739a3f14aae210222a76697605c890dc4365132f9ae0d351952a1aad7eecf78d9923766dbe74a1e21033b21c0758ffbd446204914fa1d1c5921e9f82c2671dac89737666aa9375973e953ae00000000', + ); + + // Verify SDK coin method calls + coinStub.calledWith(coin).should.be.true(); + mockIsValidPub.calledWith(userPub).should.be.true(); + mockIsValidPub.calledWith(backupPub).should.be.true(); + mockRecover + .calledWith({ + userKey: userPub, + backupKey: backupPub, + bitgoKey: bitgoPub, + recoveryDestination: recoveryDestination, + apiKey: 'key', + ignoreAddressTypes: [], + scan: 1, + feeRate: undefined, + }) + .should.be.true(); + + // Verify enclaved express call + recoveryNock.done(); + }); + + it('should reject incorrect EVM parameters for a UTXO coin', async () => { + const userPub = 'xpub_user'; + const backupPub = 'xpub_backup'; + const bitgoPub = 'xpub_bitgo'; + const recoveryDestination = 'tb1qprdy6jwxrrr2qrwgd2tzl8z99hqp29jn6f3sguxulqm448myj6jsy2nwsu'; + + const response = await agent + .post(`/api/${coin}/wallet/recovery`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + multiSigRecoveryParams: { + userPub, + backupPub, + bitgoPub, + walletContractAddress: '', + }, + recoveryDestinationAddress: recoveryDestination, + coin, + apiKey: 'key', + coinSpecificParams: { + evmRecoveryOptions: { + gasPrice: 20000000000, + gasLimit: 500000, + }, + }, + }); + + response.status.should.equal(422); + response.body.should.have.property('error'); + response.body.should.have.property('details'); + response.body.details.should.containEql('Invalid parameters provided for UTXO coin recovery'); + }); + + it('should reject incorrect Solana parameters for a UTXO coin', async () => { + const userPub = 'xpub_user'; + const backupPub = 'xpub_backup'; + const bitgoPub = 'xpub_bitgo'; + const recoveryDestination = 'tb1qprdy6jwxrrr2qrwgd2tzl8z99hqp29jn6f3sguxulqm448myj6jsy2nwsu'; + + const response = await agent + .post(`/api/${coin}/wallet/recovery`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + multiSigRecoveryParams: { + userPub, + backupPub, + bitgoPub, + walletContractAddress: '', + }, + recoveryDestinationAddress: recoveryDestination, + coin, + apiKey: 'key', + coinSpecificParams: { + solanaRecoveryOptions: { + tokenContractAddress: 'tokenAddress123', + closeAtaAddress: 'closeAddress123', + recoveryDestinationAtaAddress: 'destAddress123', + programId: 'programId123', + }, + }, + }); + + response.status.should.equal(422); + response.body.should.have.property('error'); + response.body.should.have.property('details'); + response.body.details.should.containEql('Invalid parameters provided for UTXO coin recovery'); + }); + + it('should reject using legacy coinSpecificParams format', async () => { + const userPub = 'xpub_user'; + const backupPub = 'xpub_backup'; + const bitgoPub = 'xpub_bitgo'; + const recoveryDestination = 'tb1qprdy6jwxrrr2qrwgd2tzl8z99hqp29jn6f3sguxulqm448myj6jsy2nwsu'; + + const response = await agent + .post(`/api/${coin}/wallet/recovery`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + multiSigRecoveryParams: { + userPub, + backupPub, + bitgoPub, + walletContractAddress: '', + }, + recoveryDestinationAddress: recoveryDestination, + coin, + apiKey: 'key', + coinSpecificParams: { + addressScan: 1, // Legacy format (not nested under utxo) + }, + }); + + response.status.should.equal(500); + // Since we test that the incorrect format doesn't work anymore + response.body.should.have.property('error'); + }); + }); + + describe('EVM coin recovery', () => { + // Setup mocks for ETH + let ethCoin: any; + const ethCoinId = 'hteth'; + + beforeEach(() => { + ethCoin = { + recover: sinon.stub().resolves({ txHex: 'eth-tx-hex' }), + isValidPub: sinon.stub().returns(true), + getFamily: sinon.stub().returns(CoinFamily.ETH), + }; + coinStub.withArgs(ethCoinId).returns(ethCoin); + + // Setup coin middleware for ETH coin + sinon.stub(masterMiddleware, 'validateMasterExpressConfig').callsFake((req, res, next) => { + (req as BitGoRequest).params = { coin: ethCoinId }; + (req as BitGoRequest).enclavedExpressClient = + new EnclavedExpressClient(config, ethCoinId); + next(); + return undefined; + }); + }); + + it('should reject incorrect UTXO parameters for an ETH coin', async () => { + const userPub = 'xpub_user'; + const backupPub = 'xpub_backup'; + const bitgoPub = 'xpub_bitgo'; + const recoveryDestination = '0x1234567890123456789012345678901234567890'; + const walletContractAddress = '0x0987654321098765432109876543210987654321'; + + const response = await agent + .post(`/api/${ethCoinId}/wallet/recovery`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + multiSigRecoveryParams: { + userPub, + backupPub, + bitgoPub, + walletContractAddress, + }, + recoveryDestinationAddress: recoveryDestination, + coin: ethCoinId, + apiKey: 'key', + coinSpecificParams: { + utxoRecoveryOptions: { + scan: 1, + ignoreAddressTypes: ['p2sh'], + }, + }, + }); + + response.status.should.equal(422); + response.body.should.have.property('error'); + response.body.should.have.property('details'); + response.body.details.should.containEql( + 'Invalid parameters provided for ETH-like coin recovery', + ); + }); + + it('should reject incorrect Solana parameters for an ETH coin', async () => { + const userPub = 'xpub_user'; + const backupPub = 'xpub_backup'; + const bitgoPub = 'xpub_bitgo'; + const recoveryDestination = '0x1234567890123456789012345678901234567890'; + const walletContractAddress = '0x0987654321098765432109876543210987654321'; + + const response = await agent + .post(`/api/${ethCoinId}/wallet/recovery`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + multiSigRecoveryParams: { + userPub, + backupPub, + bitgoPub, + walletContractAddress, + }, + recoveryDestinationAddress: recoveryDestination, + coin: ethCoinId, + apiKey: 'key', + coinSpecificParams: { + solanaRecoveryOptions: { + tokenContractAddress: 'tokenAddress123', + closeAtaAddress: 'closeAddress123', + recoveryDestinationAtaAddress: 'destAddress123', + programId: 'programId123', + }, + }, + }); + + response.status.should.equal(422); + response.body.should.have.property('error'); + response.body.should.have.property('details'); + response.body.details.should.containEql( + 'Invalid parameters provided for ETH-like coin recovery', + ); + }); + }); + + describe('Solana coin recovery', () => { + // Setup mocks for Solana + let solCoin: any; + const solCoinId = 'tsol'; + + beforeEach(() => { + solCoin = { + isValidPub: sinon.stub().returns(true), + getFamily: sinon.stub().returns(CoinFamily.SOL), + getMPCAlgorithm: sinon.stub().returns('eddsa'), + }; + coinStub.withArgs(solCoinId).returns(solCoin); + + // Setup coin middleware for Solana coin + sinon.stub(masterMiddleware, 'validateMasterExpressConfig').callsFake((req, res, next) => { + (req as BitGoRequest).params = { coin: solCoinId }; + (req as BitGoRequest).enclavedExpressClient = + new EnclavedExpressClient(config, solCoinId); + next(); + return undefined; }); + }); + + it('should reject incorrect UTXO parameters for a Solana coin', async () => { + const userPub = 'solana_pubkey'; + const recoveryDestination = 'solanaRecoveryAddress123456789012345678901234'; + + const response = await agent + .post(`/api/${solCoinId}/wallet/recovery`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + isTssRecovery: true, + tssRecoveryParams: { + commonKeychain: userPub, + }, + recoveryDestinationAddress: recoveryDestination, + coin: solCoinId, + apiKey: 'key', + coinSpecificParams: { + utxoRecoveryOptions: { + scan: 1, + ignoreAddressTypes: ['p2sh'], + }, + }, + }); - response.status.should.equal(200); - response.body.should.have.property('txHex'); - response.body.txHex.should.equal( - '01000000000101edd7a583fef5aabf265e6dca24452581a3cca2671a1fa6b4e404bccb6ff4c83b0000000000ffffffff01780f0000000000002200202120dcf53e62a4cc9d3843993aa2258bd14fbf911a4ea4cf4f3ac840f41702790400473044022043a9256810ef47ce36a092305c0b1ef675bce53e46418eea8cacbf1643e541d90220450766e048b841dac658d0a2ba992628bfe131dff078c3a574cadf67b4946647014730440220360045a15e459ed44aa3e52b86dd6a16dddaf319821f4dcc15627686f377edd102205cb3d5feab1a773c518d43422801e01dd1bc586bb09f6a9ed23a1fc0cfeeb5310169522103a1c425fd9b169e6ab5ed3de596acb777ccae0cda3d91256238b5e739a3f14aae210222a76697605c890dc4365132f9ae0d351952a1aad7eecf78d9923766dbe74a1e21033b21c0758ffbd446204914fa1d1c5921e9f82c2671dac89737666aa9375973e953ae00000000', - ); - - // Verify SDK coin method calls - coinStub.calledWith(coin).should.be.true(); - mockIsValidPub.calledWith(userPub).should.be.true(); - mockIsValidPub.calledWith(backupPub).should.be.true(); - mockRecover - .calledWith({ - userKey: userPub, - backupKey: backupPub, - bitgoKey: bitgoPub, - recoveryDestination: recoveryDestination, - apiKey: 'key', - ignoreAddressTypes: [], - scan: 1, - feeRate: undefined, - }) - .should.be.true(); - - // Verify enclaved express call - recoveryNock.done(); + response.status.should.equal(422); + response.body.should.have.property('error'); + response.body.should.have.property('details'); + response.body.details.should.containEql( + 'Invalid parameters provided for Solana coin recovery', + ); + }); + + it('should reject incorrect EVM parameters for a Solana coin', async () => { + const userPub = 'solana_pubkey'; + const recoveryDestination = 'solanaRecoveryAddress123456789012345678901234'; + + const response = await agent + .post(`/api/${solCoinId}/wallet/recovery`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + isTssRecovery: true, + tssRecoveryParams: { + commonKeychain: userPub, + }, + recoveryDestinationAddress: recoveryDestination, + coin: solCoinId, + apiKey: 'key', + coinSpecificParams: { + evmRecoveryOptions: { + gasPrice: 20000000000, + gasLimit: 500000, + }, + }, + }); + + response.status.should.equal(422); + response.body.should.have.property('error'); + response.body.should.have.property('details'); + response.body.details.should.containEql( + 'Invalid parameters provided for Solana coin recovery', + ); + }); }); }); diff --git a/src/api/master/clients/enclavedExpressClient.ts b/src/api/master/clients/enclavedExpressClient.ts index 84ffb9b0..54b6c21a 100644 --- a/src/api/master/clients/enclavedExpressClient.ts +++ b/src/api/master/clients/enclavedExpressClient.ts @@ -1,31 +1,31 @@ +import assert from 'assert'; import https from 'https'; import debug from 'debug'; import superagent from 'superagent'; import { - SignedTransaction, - TransactionPrebuild, - TxRequest, - EncryptedSignerShareRecord, - SignatureShareRecord, - SignShare, + ApiKeyShare, CommitmentShareRecord, + EncryptedSignerShareRecord, GShare, Keychain, - ApiKeyShare, - MPCTx, MPCSweepTxs, + MPCTx, MPCTxs, - MPCUnsignedTx, + SignatureShareRecord, + SignedTransaction, + SignShare, + TransactionPrebuild, + TxRequest, } from '@bitgo/sdk-core'; import { RecoveryTransaction } from '@bitgo/sdk-coin-trx'; -import { superagentRequestFactory, buildApiClient, ApiClient } from '@api-ts/superagent-wrapper'; +import { ApiClient, buildApiClient, superagentRequestFactory } from '@api-ts/superagent-wrapper'; import { OfflineVaultTxInfo, RecoveryInfo, UnsignedSweepTxMPCv2 } from '@bitgo/sdk-coin-eth'; -import assert from 'assert'; import { MasterExpressConfig, TlsMode } from '../../../shared/types'; import { EnclavedApiSpec } from '../../../enclavedBitgoExpress/routers'; import { PingResponseType, VersionResponseType } from '../../../types/health'; +import { extractTransactionRequestInfo } from '../../../shared/transactionUtils'; import { KeyShareType, MpcFinalizeResponseType, @@ -189,42 +189,16 @@ export class EnclavedExpressClient { try { debugLogger('Recovering MPC for coin: %s', this.coin); - // Extract the required information from the sweep tx + // Extract the required information from the sweep tx using our utility function const tx = params.unsignedSweepPrebuildTx; + const { signableHex, derivationPath } = extractTransactionRequestInfo(tx); + const txRequest = { unsignedTx: '', - signableHex: '', - derivationPath: '', + signableHex, + derivationPath, }; - // Handle different tx formats - if ('txRequests' in tx && Array.isArray(tx.txRequests)) { - // MPCTxs format - const firstRequest = tx.txRequests[0]; - if (firstRequest && firstRequest.transactions && firstRequest.transactions[0]) { - const firstTx = firstRequest.transactions[0]; - txRequest.signableHex = firstTx.unsignedTx?.serializedTx || ''; - txRequest.derivationPath = firstTx.unsignedTx?.derivationPath || ''; - } - } else if ('transactions' in tx && Array.isArray(tx.transactions)) { - // RecoveryTxRequest - const firstTransaction = tx.transactions[0] as MPCUnsignedTx; - txRequest.signableHex = firstTransaction.unsignedTx?.serializedTx || ''; - txRequest.derivationPath = firstTransaction.unsignedTx?.derivationPath || ''; - } else if ('signableHex' in tx) { - // MPCTx format - txRequest.signableHex = tx.signableHex || ''; - txRequest.derivationPath = tx.derivationPath || ''; - } else if (Array.isArray(tx) && tx.length > 0) { - // MPCSweepTxs format - const firstTx = tx[0]; - if (firstTx && firstTx.transactions && firstTx.transactions[0]) { - const transaction = firstTx.transactions[0]; - txRequest.signableHex = transaction.unsignedTx?.signableHex || ''; - txRequest.derivationPath = transaction.unsignedTx?.derivationPath || ''; - } - } - let request = this.apiClient['v1.mpc.recovery'].post({ coin: this.coin, commonKeychain: params.userPub, @@ -316,7 +290,6 @@ export class EnclavedExpressClient { } const response = await request.decodeExpecting(200); - console.log(response); return response.body; } catch (error) { const err = error as Error; diff --git a/src/api/master/handlers/recoveryWallet.ts b/src/api/master/handlers/recoveryWallet.ts index 5f93c143..91745a31 100644 --- a/src/api/master/handlers/recoveryWallet.ts +++ b/src/api/master/handlers/recoveryWallet.ts @@ -1,7 +1,8 @@ -import { BaseCoin, BitGoAPI, MethodNotImplementedError } from 'bitgo'; +import { BaseCoin, BitGoAPI, MethodNotImplementedError, MPCRecoveryOptions } from 'bitgo'; import { AbstractEthLikeNewCoins } from '@bitgo/abstract-eth'; import { AbstractUtxoCoin } from '@bitgo/abstract-utxo'; +import { type SolRecoveryOptions } from '@bitgo/sdk-coin-sol'; import assert from 'assert'; @@ -15,11 +16,21 @@ import { DEFAULT_MUSIG_ETH_GAS_PARAMS, getReplayProtectionOptions, } from '../../../shared/recoveryUtils'; + import { EnclavedExpressClient } from '../clients/enclavedExpressClient'; -import { MasterApiSpecRouteRequest } from '../routers/masterApiSpec'; +import { + CoinSpecificParams, + EvmRecoveryOptions, + MasterApiSpecRouteRequest, + ScriptType2Of3, + SolanaRecoveryOptions, + UtxoRecoveryOptions, +} from '../routers/masterApiSpec'; import { recoverEddsaWallets } from './recoverEddsaWallets'; import { EnvironmentName } from '../../../shared/types'; import logger from '../../../logger'; +import { CoinFamily } from '@bitgo/statics'; +import { ValidationError } from '../../../shared/errors'; interface RecoveryParams { userKey: string; @@ -34,10 +45,30 @@ interface EnclavedRecoveryParams { backupPub: string; apiKey: string; unsignedSweepPrebuildTx: any; // TODO: type this properly once we have the SDK types - coinSpecificParams?: Record; + coinSpecificParams?: EvmRecoveryOptions | UtxoRecoveryOptions | SolanaRecoveryOptions; walletContractAddress: string; } +function validateRecoveryParams(sdkCoin: BaseCoin, params?: CoinSpecificParams) { + if (!params) { + return; + } + + if (isUtxoCoin(sdkCoin)) { + if (params.solanaRecoveryOptions || params.evmRecoveryOptions) { + throw new ValidationError('Invalid parameters provided for UTXO coin recovery'); + } + } else if (isEthLikeCoin(sdkCoin)) { + if (params.solanaRecoveryOptions || params.utxoRecoveryOptions) { + throw new ValidationError('Invalid parameters provided for ETH-like coin recovery'); + } + } else if (isEddsaCoin(sdkCoin)) { + if (params.evmRecoveryOptions || params.utxoRecoveryOptions) { + throw new ValidationError('Invalid parameters provided for Solana coin recovery'); + } + } +} + async function handleEthLikeRecovery( sdkCoin: BaseCoin, commonRecoveryParams: RecoveryParams, @@ -78,11 +109,30 @@ async function handleEddsaRecovery( ) { const { recoveryDestination, userKey } = commonRecoveryParams; try { - const unsignedSweepPrebuildTx = await recoverEddsaWallets(bitgo, sdkCoin, { + const options: MPCRecoveryOptions = { bitgoKey: userKey, recoveryDestination, apiKey: params.apiKey, - }); + }; + let unsignedSweepPrebuildTx: Awaited>; + if (sdkCoin.getFamily() === CoinFamily.SOL) { + const solanaParams = params.coinSpecificParams as SolanaRecoveryOptions; + const solanaRecoveryOptions: SolRecoveryOptions = { ...options }; + solanaRecoveryOptions.recoveryDestinationAtaAddress = + solanaParams.recoveryDestinationAtaAddress; + solanaRecoveryOptions.closeAtaAddress = solanaParams.closeAtaAddress; + solanaRecoveryOptions.tokenContractAddress = solanaParams.tokenContractAddress; + solanaRecoveryOptions.programId = solanaParams.programId; + if (solanaParams.durableNonce) { + solanaRecoveryOptions.durableNonce = { + publicKey: solanaParams.durableNonce.publicKey, + secretKey: solanaParams.durableNonce.secretKey, + }; + } + unsignedSweepPrebuildTx = await recoverEddsaWallets(bitgo, sdkCoin, solanaRecoveryOptions); + } else { + unsignedSweepPrebuildTx = await recoverEddsaWallets(bitgo, sdkCoin, options); + } logger.info('Unsigned sweep tx: ', JSON.stringify(unsignedSweepPrebuildTx, null, 2)); return await enclavedExpressClient.recoveryMPC({ @@ -141,6 +191,8 @@ export async function handleRecoveryWalletOnPrem( const { recoveryDestinationAddress, coinSpecificParams } = req.decoded; const sdkCoin = bitgo.coin(coin); + // Validate that we have correct parameters for recovery + validateRecoveryParams(sdkCoin, coinSpecificParams); // Handle TSS recovery if (req.decoded.isTssRecovery) { @@ -168,7 +220,7 @@ export async function handleRecoveryWalletOnPrem( apiKey: '', walletContractAddress: '', unsignedSweepPrebuildTx: undefined, - coinSpecificParams: undefined, + coinSpecificParams: coinSpecificParams?.solanaRecoveryOptions, }, ); } else { @@ -219,7 +271,7 @@ export async function handleRecoveryWalletOnPrem( backupPub, apiKey, unsignedSweepPrebuildTx: undefined, - coinSpecificParams: undefined, + coinSpecificParams: coinSpecificParams?.evmRecoveryOptions, walletContractAddress, }, bitgo.env as EnvironmentName, @@ -234,9 +286,10 @@ export async function handleRecoveryWalletOnPrem( userKey: userPub, backupKey: backupPub, bitgoKey: bitgoPub, - ignoreAddressTypes: coinSpecificParams?.ignoreAddressTypes ?? [], - scan: coinSpecificParams?.addressScan, - feeRate: coinSpecificParams?.feeRate, + ignoreAddressTypes: + (coinSpecificParams?.utxoRecoveryOptions?.ignoreAddressTypes as ScriptType2Of3[]) ?? [], + scan: coinSpecificParams?.utxoRecoveryOptions?.scan, + feeRate: coinSpecificParams?.utxoRecoveryOptions?.feeRate, recoveryDestination: recoveryDestinationAddress, apiKey, }); diff --git a/src/api/master/routers/masterApiSpec.ts b/src/api/master/routers/masterApiSpec.ts index 54323ec1..2451b973 100644 --- a/src/api/master/routers/masterApiSpec.ts +++ b/src/api/master/routers/masterApiSpec.ts @@ -1,9 +1,9 @@ import { apiSpec, - Method as HttpMethod, httpRequest, HttpResponse, httpRoute, + Method as HttpMethod, optional, } from '@api-ts/io-ts-http'; import { Response } from '@api-ts/response'; @@ -15,6 +15,7 @@ import { import express from 'express'; import * as t from 'io-ts'; import { MasterExpressConfig } from '../../../shared/types'; +import * as utxolib from '@bitgo/utxo-lib'; import { prepareBitGo, responseHandler } from '../../../shared/middleware'; import { BitGoRequest } from '../../../types/request'; import { handleGenerateWalletOnPrem } from '../handlers/generateWallet'; @@ -26,6 +27,66 @@ import { handleAccelerate } from '../handlers/handleAccelerate'; import { handleConsolidateUnspents } from '../handlers/handleConsolidateUnspents'; import { handleSignAndSendTxRequest } from '../handlers/handleSignAndSendTxRequest'; import { handleRecoveryConsolidationsOnPrem } from '../handlers/recoveryConsolidationsWallet'; +import { + BadRequestResponse, + InternalServerErrorResponse, + UnprocessableEntityResponse, +} from '../../../shared/errors'; + +export type ScriptType2Of3 = utxolib.bitgo.outputScripts.ScriptType2Of3; + +// Recovery parameter types +export const RecoveryParamTypes = { + // UTXO specific recovery parameters + utxoRecoveryOptions: t.partial({ + ignoreAddressTypes: t.array(t.string), + userKeyPath: t.string, + feeRate: t.number, + scan: optional(t.number), + }), + + // ETH-like specific recovery parameters + ethLikeRecoveryOptions: t.partial({ + gasPrice: t.number, + gasLimit: t.number, + eip1559: t.type({ + maxPriorityFeePerGas: t.number, + maxFeePerGas: t.number, + }), + replayProtectionOptions: t.type({ + chain: t.union([t.string, t.number]), + hardfork: t.string, + }), + scan: optional(t.number), + }), + + // Solana specific recovery parameters + solanaRecoveryOptions: t.partial({ + durableNonce: optional( + t.type({ + publicKey: t.string, + secretKey: t.string, + }), + ), + tokenContractAddress: t.string, + closeAtaAddress: t.string, + recoveryDestinationAtaAddress: t.string, + programId: t.string, + }), +}; + +export type EvmRecoveryOptions = typeof RecoveryParamTypes.ethLikeRecoveryOptions._A; +export type UtxoRecoveryOptions = typeof RecoveryParamTypes.utxoRecoveryOptions._A; +export type SolanaRecoveryOptions = typeof RecoveryParamTypes.solanaRecoveryOptions._A; + +// Combined coin specific parameters +const CoinSpecificParams = t.partial({ + utxoRecoveryOptions: RecoveryParamTypes.utxoRecoveryOptions, + evmRecoveryOptions: RecoveryParamTypes.ethLikeRecoveryOptions, + solanaRecoveryOptions: RecoveryParamTypes.solanaRecoveryOptions, +}); + +export type CoinSpecificParams = t.TypeOf; // Middleware functions export function parseBody(req: express.Request, res: express.Response, next: express.NextFunction) { @@ -37,10 +98,7 @@ export function parseBody(req: express.Request, res: express.Response, next: exp const GenerateWalletResponse: HttpResponse = { // TODO: Get type from public types repo 200: t.any, - 500: t.type({ - error: t.string, - details: t.string, - }), + ...InternalServerErrorResponse, }; // Request type for /generate endpoint @@ -100,10 +158,7 @@ export const SendManyRequest = { export const SendManyResponse: HttpResponse = { // TODO: Get type from public types repo / Wallet Platform 200: t.any, - 500: t.type({ - error: t.string, - details: t.string, - }), + ...InternalServerErrorResponse, }; // Request type for /consolidate endpoint @@ -118,11 +173,8 @@ export const ConsolidateRequest = { // Response type for /consolidate endpoint const ConsolidateResponse: HttpResponse = { 200: t.any, - 400: t.any, // All failed - 500: t.type({ - error: t.string, - details: t.string, - }), + ...BadRequestResponse, // All failed + ...InternalServerErrorResponse, }; // Request type for /accelerate endpoint @@ -143,10 +195,7 @@ const AccelerateResponse: HttpResponse = { txid: t.string, tx: t.string, }), - 500: t.type({ - error: t.string, - details: t.string, - }), + ...InternalServerErrorResponse, }; // Response type for /recovery endpoint @@ -155,10 +204,8 @@ const RecoveryWalletResponse: HttpResponse = { 200: t.type({ txHex: t.string, // the full signed transaction hex }), - 500: t.type({ - error: t.string, - details: t.string, - }), + ...UnprocessableEntityResponse, + ...InternalServerErrorResponse, }; // Request type for /recovery endpoint @@ -179,23 +226,7 @@ const RecoveryWalletRequest = { ), recoveryDestinationAddress: t.string, apiKey: optional(t.string), - coinSpecificParams: optional( - t.partial({ - ignoreAddressTypes: optional( - t.array( - t.union([ - t.literal('p2sh'), - t.literal('p2shP2wsh'), - t.literal('p2wsh'), - t.literal('p2tr'), - t.literal('p2trMusig2'), - ]), - ), - ), - addressScan: optional(t.number), - feeRate: optional(t.number), - }), - ), + coinSpecificParams: optional(CoinSpecificParams), }; const RecoveryConsolidationsWalletRequest = { @@ -249,11 +280,8 @@ const ConsolidateUnspentsResponse: HttpResponse = { tx: t.string, txid: t.string, }), - 400: t.any, - 500: t.type({ - error: t.string, - details: t.string, - }), + ...BadRequestResponse, + ...InternalServerErrorResponse, }; const SignMpcRequest = { @@ -263,10 +291,7 @@ const SignMpcRequest = { const SignMpcResponse: HttpResponse = { 200: t.any, - 500: t.type({ - error: t.string, - details: t.string, - }), + ...InternalServerErrorResponse, }; // API Specification diff --git a/src/shared/errors.ts b/src/shared/errors.ts new file mode 100644 index 00000000..1381596d --- /dev/null +++ b/src/shared/errors.ts @@ -0,0 +1,86 @@ +import * as t from 'io-ts'; +/** + * Custom error classes for specific error types + */ + +/** + * Base custom error class with common setup + */ +export class BitgoExpressError extends Error { + constructor(message: string, name: string) { + super(message); + this.name = name; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +/** + * ValidationError represents a client error due to invalid input parameters + * Should result in a 422 Unprocessable Entity HTTP status code + */ +export class ValidationError extends BitgoExpressError { + constructor(message: string) { + super(message, 'ValidationError'); + } +} + +/** + * NotFoundError represents a resource that could not be found + * Should result in a 404 Not Found HTTP status code + */ +export class NotFoundError extends BitgoExpressError { + constructor(message: string) { + super(message, 'NotFoundError'); + } +} + +/** + * BadRequestError represents a client error due to invalid request format + * Should result in a 400 Bad Request HTTP status code + */ +export class BadRequestError extends BitgoExpressError { + constructor(message: string) { + super(message, 'BadRequestError'); + } +} + +/** + * UnauthorizedError represents an authentication failure + * Should result in a 401 Unauthorized HTTP status code + */ +export class UnauthorizedError extends BitgoExpressError { + constructor(message: string) { + super(message, 'UnauthorizedError'); + } +} + +/** + * ForbiddenError represents an authorization failure + * Should result in a 403 Forbidden HTTP status code + */ +export class ForbiddenError extends BitgoExpressError { + constructor(message: string) { + super(message, 'ForbiddenError'); + } +} + +/** + * ConflictError represents a conflict with the current state of the resource + * Should result in a 409 Conflict HTTP status code + */ +export class ConflictError extends BitgoExpressError { + constructor(message: string) { + super(message, 'ConflictError'); + } +} + +// Define specific HTTP error responses + +// Common error response types +const ErrorResponse = t.type({ + error: t.string, + details: t.string, +}); +export const BadRequestResponse = { 400: ErrorResponse }; +export const UnprocessableEntityResponse = { 422: ErrorResponse }; +export const InternalServerErrorResponse = { 500: ErrorResponse }; diff --git a/src/shared/responseHandler.ts b/src/shared/responseHandler.ts index a00c7406..d958d859 100644 --- a/src/shared/responseHandler.ts +++ b/src/shared/responseHandler.ts @@ -2,6 +2,15 @@ import { Request, Response as ExpressResponse, NextFunction } from 'express'; import { Config } from '../shared/types'; import { BitGoRequest } from '../types/request'; import { EnclavedError } from '../errors'; +import { + BitgoExpressError, + ValidationError, + NotFoundError, + BadRequestError, + UnauthorizedError, + ForbiddenError, + ConflictError, +} from './errors'; // Extend Express Response to include sendEncoded interface EncodedResponse extends ExpressResponse { @@ -38,6 +47,31 @@ export function responseHandler(fn: ServiceFunction 0 && + isMPCTx(tx.transactions[0]) + ); +} + +/** + * Type guard to check if the object is an MPCTx format + * MPCTx format has direct signableHex and derivationPath properties + */ +export function isMPCTx(tx: any): tx is MPCTx { + return tx && 'signableHex' in tx; +} + +/** + * Type guard to check if the object is an MPCSweepTxs format + * MPCSweepTxs is an array of objects with transactions property + */ +export function isMPCSweepTxs(tx: any): tx is MPCSweepTxs { + return ( + 'txRequests' in tx && + Array.isArray(tx.txRequests) && + tx.txRequests.length > 0 && + tx.txRequests[0] && + isRecoveryTxRequest(tx.txRequests[0]) + ); +} + +export function isRecoveryTxRequest(tx: any): tx is RecoveryTxRequest { + return ( + 'walletCoin' in tx && + 'transactions' in tx && + Array.isArray(tx.transactions) && + tx.transactions.length > 0 && + tx.transactions[0] && + isMPCUnsignedTx(tx.transactions[0]) + ); +} + +export function isMPCUnsignedTx(tx: any): tx is MPCUnsignedTx { + return 'unsignedTx' in tx && isMPCTx(tx); +} +/** + * Extracts transaction request information from various transaction formats + * @param tx The transaction object in one of the supported formats + * @returns Object with signableHex and derivationPath extracted from the transaction + */ +export function extractTransactionRequestInfo( + tx: MPCTx | MPCSweepTxs | MPCTxs | RecoveryTxRequest, +): { + signableHex: string; + derivationPath: string; +} { + const txRequest = { + signableHex: '', + derivationPath: '', + }; + + if (isMPCTxs(tx)) { + const transaction = tx.transactions[0]; + txRequest.signableHex = transaction.signableHex || ''; + txRequest.derivationPath = transaction.derivationPath || ''; + } else if (isMPCTx(tx)) { + txRequest.signableHex = tx.signableHex || ''; + txRequest.derivationPath = tx.derivationPath || ''; + } else if (isMPCSweepTxs(tx)) { + const firstRequest = tx.txRequests[0]; + if (firstRequest && firstRequest.transactions && firstRequest.transactions[0]) { + const firstTx = firstRequest.transactions[0]; + txRequest.signableHex = firstTx.unsignedTx?.serializedTx || ''; + txRequest.derivationPath = firstTx.unsignedTx?.derivationPath || ''; + } + } else if (isRecoveryTxRequest(tx)) { + const firstTransaction = tx.transactions[0]; + txRequest.signableHex = firstTransaction.unsignedTx?.serializedTx || ''; + txRequest.derivationPath = firstTransaction.unsignedTx?.derivationPath || ''; + } else { + throw new Error(`Unrecognized transaction ${JSON.stringify(tx)}`); + } + + return txRequest; +}