From 596e192c6b7cf22c384d85963796d7a83288348f Mon Sep 17 00:00:00 2001 From: abhi-bitgo Date: Fri, 25 Jul 2025 18:52:02 +0530 Subject: [PATCH] feat(abstract-eth): add recover consolidation for eth ticket: WIN-5700 --- .../src/abstractEthLikeNewCoins.ts | 191 +++++++++++++++++- modules/sdk-coin-eth/test/unit/eth.ts | 109 ++++++++++ modules/statics/src/networks.ts | 8 + .../test/unit/resources/amsTokenConfig.ts | 2 + 4 files changed, 309 insertions(+), 1 deletion(-) diff --git a/modules/abstract-eth/src/abstractEthLikeNewCoins.ts b/modules/abstract-eth/src/abstractEthLikeNewCoins.ts index 8772abfe13..a566b3f05d 100644 --- a/modules/abstract-eth/src/abstractEthLikeNewCoins.ts +++ b/modules/abstract-eth/src/abstractEthLikeNewCoins.ts @@ -19,6 +19,7 @@ import { IWallet, KeyPair, MPCSweepRecoveryOptions, + MPCSweepTxs, MPCTx, MPCTxs, ParsedTransaction, @@ -57,7 +58,7 @@ import { BigNumber } from 'bignumber.js'; import BN from 'bn.js'; import { randomBytes } from 'crypto'; import debugLib from 'debug'; -import { addHexPrefix, bufArrToArr, stripHexPrefix } from 'ethereumjs-util'; +import { addHexPrefix, bufArrToArr, stripHexPrefix, bufferToHex, setLengthLeft, toBuffer } from 'ethereumjs-util'; import Keccak from 'keccak'; import _ from 'lodash'; import secp256k1 from 'secp256k1'; @@ -70,6 +71,7 @@ import { ERC721TransferBuilder, getBufferedByteCode, getCommon, + getCreateForwarderParamsAndTypes, getProxyInitcode, getRawDecoded, getToken, @@ -224,6 +226,11 @@ export type UnsignedSweepTxMPCv2 = { }[]; }; +export type UnsignedBuilConsolidation = { + transactions: MPCSweepTxs[] | UnsignedSweepTxMPCv2[] | RecoveryInfo[] | OfflineVaultTxInfo[]; + lastScanIndex: number; +}; + export type RecoverOptionsWithBytes = { isTss: true; /** @@ -361,6 +368,33 @@ interface EthAddressCoinSpecifics extends AddressCoinSpecific { salt?: string; } +export const DEFAULT_SCAN_FACTOR = 20; +export interface EthConsolidationRecoveryOptions { + coinName?: string; + walletContractAddress?: string; + apiKey?: string; + isTss?: boolean; + userKey?: string; + backupKey?: string; + walletPassphrase?: string; + recoveryDestination?: string; + krsProvider?: string; + gasPrice?: number; + gasLimit?: number; + eip1559?: EIP1559; + replayProtectionOptions?: ReplayProtectionOptions; + bitgoFeeAddress?: string; + bitgoDestinationAddress?: string; + tokenContractAddress?: string; + intendedChain?: string; + common?: EthLikeCommon.default; + derivationSeed?: string; + bitgoKey?: string; + startingScanIndex?: number; + endingScanIndex?: number; + ignoreAddressTypes?: unknown; +} + export interface VerifyEthAddressOptions extends BaseVerifyAddressOptions { baseAddress: string; coinSpecific: EthAddressCoinSpecifics; @@ -1192,6 +1226,161 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin { return this.recoverEthLike(params); } + generateForwarderAddress( + baseAddress: string, + feeAddress: string, + forwarderFactoryAddress: string, + forwarderImplementationAddress: string, + index: number + ): string { + const salt = addHexPrefix(index.toString(16)); + const saltBuffer = setLengthLeft(toBuffer(salt), 32); + + const { createForwarderParams, createForwarderTypes } = getCreateForwarderParamsAndTypes( + baseAddress, + saltBuffer, + feeAddress + ); + + const calculationSalt = bufferToHex(optionalDeps.ethAbi.soliditySHA3(createForwarderTypes, createForwarderParams)); + + const initCode = getProxyInitcode(forwarderImplementationAddress); + return calculateForwarderV1Address(forwarderFactoryAddress, calculationSalt, initCode); + } + + deriveAddressFromPublicKey(commonKeychain: string, index: number): string { + const derivationPath = `m/${index}`; + const pubkeySize = 33; + + const ecdsaMpc = new Ecdsa(); + const derivedPublicKey = Buffer.from(ecdsaMpc.deriveUnhardened(commonKeychain, derivationPath), 'hex') + .subarray(0, pubkeySize) + .toString('hex'); + + const publicKey = Buffer.from(derivedPublicKey, 'hex').slice(0, 66).toString('hex'); + + const keyPair = new KeyPairLib({ pub: publicKey }); + const address = keyPair.getAddress(); + return address; + } + + getConsolidationAddress(params: EthConsolidationRecoveryOptions, index: number): string[] { + const possibleConsolidationAddresses: string[] = []; + if (params.walletContractAddress && params.bitgoFeeAddress) { + const ethNetwork = this.getNetwork(); + const forwarderFactoryAddress = ethNetwork?.walletV4ForwarderFactoryAddress as string; + const forwarderImplementationAddress = ethNetwork?.walletV4ForwarderImplementationAddress as string; + try { + const forwarderAddress = this.generateForwarderAddress( + params.walletContractAddress, + params.bitgoFeeAddress, + forwarderFactoryAddress, + forwarderImplementationAddress, + index + ); + possibleConsolidationAddresses.push(forwarderAddress); + } catch (e) { + console.log(`Failed to generate forwarder address: ${e.message}`); + } + } + + if (params.userKey) { + try { + const derivedAddress = this.deriveAddressFromPublicKey(params.userKey, index); + possibleConsolidationAddresses.push(derivedAddress); + } catch (e) { + console.log(`Failed to generate derived address: ${e}`); + } + } + + if (possibleConsolidationAddresses.length === 0) { + throw new Error( + 'Unable to generate consolidation address. Check that wallet contract address, fee address, or user key is valid.' + ); + } + return possibleConsolidationAddresses; + } + + async recoverConsolidations(params: EthConsolidationRecoveryOptions): Promise { + const isUnsignedSweep = !params.userKey && !params.backupKey && !params.walletPassphrase; + const startIdx = params.startingScanIndex || 1; + const endIdx = params.endingScanIndex || startIdx + DEFAULT_SCAN_FACTOR; + + if (!params.walletContractAddress || params.walletContractAddress === '') { + throw new Error(`Invalid wallet contract address ${params.walletContractAddress}`); + } + + if (!params.bitgoFeeAddress || params.bitgoFeeAddress === '') { + throw new Error(`Invalid fee address ${params.bitgoFeeAddress}`); + } + + if (startIdx < 1 || endIdx <= startIdx || endIdx - startIdx > 10 * DEFAULT_SCAN_FACTOR) { + throw new Error( + `Invalid starting or ending index to scan for addresses. startingScanIndex: ${startIdx}, endingScanIndex: ${endIdx}.` + ); + } + + const consolidatedTransactions: any[] = []; + let lastScanIndex = startIdx; + + for (let i = startIdx; i < endIdx; i++) { + const consolidationAddress = this.getConsolidationAddress(params, i); + for (const address of consolidationAddress) { + const recoverParams = { + apiKey: params.apiKey, + backupKey: params.backupKey || '', + gasLimit: params.gasLimit, + recoveryDestination: params.recoveryDestination || '', + userKey: params.userKey || '', + walletContractAddress: address, + derivationSeed: '', + isTss: params.isTss, + eip1559: { + maxFeePerGas: params.eip1559?.maxFeePerGas || 20, + maxPriorityFeePerGas: params.eip1559?.maxPriorityFeePerGas || 200000, + }, + replayProtectionOptions: { + chain: params.replayProtectionOptions?.chain || 0, + hardfork: params.replayProtectionOptions?.hardfork || 'london', + }, + bitgoKey: '', + ignoreAddressTypes: [], + }; + let recoveryTransaction; + try { + recoveryTransaction = await this.recover(recoverParams); + } catch (e) { + if ( + e.message === 'Did not find address with funds to recover' || + e.message === 'Did not find token account to recover tokens, please check token account' || + e.message === 'Not enough token funds to recover' + ) { + lastScanIndex = i; + continue; + } + throw e; + } + if (isUnsignedSweep) { + consolidatedTransactions.push((recoveryTransaction as MPCSweepTxs).txRequests[0]); + } else { + consolidatedTransactions.push(recoveryTransaction); + } + } + // To avoid rate limit for etherscan + await new Promise((resolve) => setTimeout(resolve, 1000)); + // lastScanIndex = i; + } + + if (consolidatedTransactions.length === 0) { + throw new Error( + `Did not find an address with sufficient funds to recover. Please start the next scan at address index ${ + lastScanIndex + 1 + }.` + ); + } + return { transactions: consolidatedTransactions, lastScanIndex }; + } + /** * Builds a funds recovery transaction without BitGo for non-TSS transaction * @param params diff --git a/modules/sdk-coin-eth/test/unit/eth.ts b/modules/sdk-coin-eth/test/unit/eth.ts index 1befc93298..9e286efa2d 100644 --- a/modules/sdk-coin-eth/test/unit/eth.ts +++ b/modules/sdk-coin-eth/test/unit/eth.ts @@ -10,6 +10,7 @@ import { generateRandomPassword, InvalidAddressError, InvalidAddressVerificationObjectPropertyError, + MPCSweepTxs, TransactionType, UnexpectedAddressError, Wallet, @@ -22,6 +23,7 @@ import { Teth, TransactionBuilder, TransferBuilder, + UnsignedBuilConsolidation, UnsignedSweepTxMPCv2, } from '../../src'; import { EthereumNetwork } from '@bitgo/statics'; @@ -1053,6 +1055,113 @@ describe('ETH:', function () { ); }); }); + + describe('Build Unsigned Consolidation for Self-Custody Cold Wallets (MPCv2)', function () { + const baseUrl = common.Environments.test.etherscanBaseUrl as string; + let bitgo: TestBitGoAPI; + let basecoin: Hteth; + + before(function () { + bitgo = TestBitGo.decorate(BitGoAPI, { env: 'test' }); + basecoin = bitgo.coin('hteth') as Hteth; + }); + + it('should generate an unsigned consolidation', async function () { + nock(baseUrl) + .get('/api') + .query(mockData.getTxListRequest(mockData.getBuildUnsignedSweepForSelfCustodyColdWalletsMPCv2().address)) + .times(2) + .reply(200, mockData.getTxListResponse); + + nock(baseUrl) + .get('/api') + .query(mockData.getBalanceRequest(mockData.getBuildUnsignedSweepForSelfCustodyColdWalletsMPCv2().address)) + .times(2) + .reply(200, mockData.getBalanceResponse); + nock(baseUrl) + .get('/api') + .query( + mockData.getBalanceRequest( + mockData.getBuildUnsignedSweepForSelfCustodyColdWalletsMPCv2().walletContractAddress + ) + ) + .reply(200, mockData.getBalanceResponse); + + nock(baseUrl).get('/api').query(mockData.getContractCallRequest).reply(200, mockData.getContractCallResponse); + + const params = mockData.getBuildUnsignedSweepForSelfCustodyColdWalletsMPCv2(); + const consolidationResult = await (basecoin as AbstractEthLikeNewCoins).recoverConsolidations({ + userKey: params.commonKeyChain, // Box A Data + backupKey: params.commonKeyChain, // Box B Data + derivationSeed: params.derivationSeed, // Key Derivation Seed (optional) + recoveryDestination: params.recoveryDestination, // Destination Address + gasLimit: 200000, // Gas Limit + eip1559: { maxFeePerGas: 20000000000, maxPriorityFeePerGas: 10000000000 }, // Max Fee Per Gas and Max Priority Fee Per Gas + walletContractAddress: params.walletContractAddress, + isTss: true, + replayProtectionOptions: { + chain: '42', + hardfork: 'london', + }, + bitgoFeeAddress: '0x33a42faea3c6e87021347e51700b48aaf49aa1e7', + startingScanIndex: 1, + endingScanIndex: 2, + }); + should.exist(consolidationResult); + const unsignedBuilConsolidation = consolidationResult as UnsignedBuilConsolidation; + unsignedBuilConsolidation.should.have.property('transactions'); + unsignedBuilConsolidation.transactions.should.have.length(2); + + const output = unsignedBuilConsolidation.transactions[0] as MPCSweepTxs; + output.should.have.property('txRequests'); + output.txRequests[0].should.have.property('transactions'); + output.txRequests[0].transactions.should.have.length(1); + output.txRequests[0].should.have.property('walletCoin'); + output.txRequests[0].transactions.should.have.length(1); + output.txRequests[0].transactions[0].should.have.property('unsignedTx'); + output.txRequests[0].transactions[0].unsignedTx.should.have.property('serializedTxHex'); + output.txRequests[0].transactions[0].unsignedTx.should.have.property('signableHex'); + output.txRequests[0].transactions[0].unsignedTx.should.have.property('derivationPath'); + output.txRequests[0].transactions[0].unsignedTx.should.have.property('feeInfo'); + output.txRequests[0].transactions[0].unsignedTx.should.have.property('parsedTx'); + const parsedTx = output.txRequests[0].transactions[0].unsignedTx.parsedTx as { spendAmount: string }; + parsedTx.should.have.property('spendAmount'); + (output.txRequests[0].transactions[0].unsignedTx.parsedTx as { outputs: any[] }).should.have.property( + 'outputs' + ); + }); + + it('should throw an error for invalid address', async function () { + const params = mockData.getBuildUnsignedSweepForSelfCustodyColdWalletsMPCv2(); + params.recoveryDestination = 'invalidAddress'; + + // Ensure userKey and backupKey are the same + params.userKey = + '0234eb39b22fed523ece7c78da29ba1f1de5b64a6e48013e0914de793bc1df0570e779de04758732734d97e54b782c8b336283811af6a2c57bd81438798e1c2446'; + params.backupKey = + '0234eb39b22fed523ece7c78da29ba1f1de5b64a6e48013e0914de793bc1df0570e779de04758732734d97e54b782c8b336283811af6a2c57bd81438798e1c2446'; + + await assert.rejects( + async () => { + await (basecoin as AbstractEthLikeNewCoins).recover({ + recoveryDestination: params.recoveryDestination, // Destination Address + gasLimit: 2000, // Gas Limit + eip1559: { maxFeePerGas: 200, maxPriorityFeePerGas: 10000 }, // Max Fee Per Gas and Max Priority Fee Per Gas + userKey: params.userKey, // Provide the userKey + backupKey: params.backupKey, // Provide the backupKey + walletContractAddress: params.walletContractAddress, // Provide the walletContractAddress + isTss: true, + replayProtectionOptions: { + chain: '42', + hardfork: 'london', + }, + }); + }, + Error, + 'Error: invalid address' + ); + }); + }); }); describe('RecoveryBlockchainExplorerQuery', () => { diff --git a/modules/statics/src/networks.ts b/modules/statics/src/networks.ts index 8dc5491a22..cd7b749b77 100644 --- a/modules/statics/src/networks.ts +++ b/modules/statics/src/networks.ts @@ -122,6 +122,8 @@ export interface EthereumNetwork extends AccountNetwork { readonly forwarderImplementationAddress?: string; readonly nativeCoinOperationHashPrefix?: string; readonly tokenOperationHashPrefix?: string; + readonly walletV4ForwarderFactoryAddress?: string; + readonly walletV4ForwarderImplementationAddress?: string; } export interface TronNetwork extends AccountNetwork { @@ -546,6 +548,8 @@ class Ethereum extends Mainnet implements EthereumNetwork { forwarderImplementationAddress = '0x059ffafdc6ef594230de44f824e2bd0a51ca5ded'; nativeCoinOperationHashPrefix = 'ETHER'; tokenOperationHashPrefix = 'ERC20'; + walletV4ForwarderFactoryAddress = '0x37996e762fa8b671869740c79eb33f625b3bf92a'; + walletV4ForwarderImplementationAddress = '0xd5fe1c1f216b775dfd30638fa7164d41321ef79b'; } class Ethereum2 extends Mainnet implements AccountNetwork { @@ -617,6 +621,8 @@ class Holesky extends Testnet implements EthereumNetwork { forwarderImplementationAddress = '0x059ffafdc6ef594230de44f824e2bd0a51ca5ded'; nativeCoinOperationHashPrefix = 'ETHER'; tokenOperationHashPrefix = 'ERC20'; + walletV4ForwarderFactoryAddress = '0x37996e762fa8b671869740c79eb33f625b3bf92a'; + walletV4ForwarderImplementationAddress = '0xd5fe1c1f216b775dfd30638fa7164d41321ef79b'; } class Hoodi extends Testnet implements EthereumNetwork { @@ -632,6 +638,8 @@ class Hoodi extends Testnet implements EthereumNetwork { forwarderImplementationAddress = '0x7441f20a59be97011842404b9aefd8d85fd81aa6'; nativeCoinOperationHashPrefix = 'ETHER'; tokenOperationHashPrefix = 'ERC20'; + walletV4ForwarderFactoryAddress = '0x37996e762fa8b671869740c79eb33f625b3bf92a'; + walletV4ForwarderImplementationAddress = '0xd5fe1c1f216b775dfd30638fa7164d41321ef79b'; } class EthereumClassic extends Mainnet implements EthereumNetwork { diff --git a/modules/statics/test/unit/resources/amsTokenConfig.ts b/modules/statics/test/unit/resources/amsTokenConfig.ts index c99c3c0c70..473635ad09 100644 --- a/modules/statics/test/unit/resources/amsTokenConfig.ts +++ b/modules/statics/test/unit/resources/amsTokenConfig.ts @@ -663,6 +663,8 @@ export const amsTokenConfigWithCustomToken = { forwarderImplementationAddress: '0x7441f20a59be97011842404b9aefd8d85fd81aa6', nativeCoinOperationHashPrefix: 'ETHER', tokenOperationHashPrefix: 'ERC20', + walletV4ForwarderFactoryAddress: '0x37996e762fa8b671869740c79eb33f625b3bf92a', + walletV4ForwarderImplementationAddress: '0xd5fe1c1f216b775dfd30638fa7164d41321ef79b', }, primaryKeyCurve: 'secp256k1', contractAddress: '0x89a959b9184b4f8c8633646d5dfd049d2ebc983a',