diff --git a/modules/sdk-coin-eth/src/hteth.ts b/modules/sdk-coin-eth/src/hteth.ts index 2077bc855b..aad7a5f103 100644 --- a/modules/sdk-coin-eth/src/hteth.ts +++ b/modules/sdk-coin-eth/src/hteth.ts @@ -1,6 +1,20 @@ -import { BaseCoin, BitGoBase } from '@bitgo/sdk-core'; +import { BaseCoin, BitGoBase, getIsUnsignedSweep, Util } from '@bitgo/sdk-core'; import { BaseCoin as StaticsBaseCoin } from '@bitgo/statics'; import { Eth } from './eth'; +import { bip32 } from '@bitgo/secp256k1'; +import { + KeyPair as KeyPairLib, + OfflineVaultTxInfo, + optionalDeps, + RecoverOptions, + RecoveryInfo, + SignedTransaction, + SignTransactionOptions, + TransactionBuilder, + TransferBuilder, +} from '@bitgo/abstract-eth'; +import { BigNumber } from 'bignumber.js'; +import _ from 'lodash'; export class Hteth extends Eth { protected constructor(bitgo: BitGoBase, staticsCoin?: Readonly) { @@ -10,4 +24,214 @@ export class Hteth extends Eth { static createInstance(bitgo: BitGoBase, staticsCoin?: Readonly): BaseCoin { return new Hteth(bitgo, staticsCoin); } + + protected async recoverEthLike(params: RecoverOptions): Promise { + // bitgoFeeAddress is only defined when it is a evm cross chain recovery + // as we use fee from this wrong chain address for the recovery txn on the correct chain. + if (params.bitgoFeeAddress) { + return this.recoverEthLikeforEvmBasedRecovery(params); + } + + this.validateRecoveryParams(params); + const isUnsignedSweep = params.isUnsignedSweep ?? getIsUnsignedSweep(params); + + // Clean up whitespace from entered values + let userKey = params.userKey.replace(/\s/g, ''); + const backupKey = params.backupKey.replace(/\s/g, ''); + const gasLimit = new optionalDeps.ethUtil.BN(this.setGasLimit(params.gasLimit)); + const gasPrice = params.eip1559 + ? new optionalDeps.ethUtil.BN(params.eip1559.maxFeePerGas) + : new optionalDeps.ethUtil.BN(this.setGasPrice(params.gasPrice)); + + if (!userKey.startsWith('xpub') && !userKey.startsWith('xprv')) { + try { + userKey = this.bitgo.decrypt({ + input: userKey, + password: params.walletPassphrase, + }); + } catch (e) { + throw new Error(`Error decrypting user keychain: ${e.message}`); + } + } + let backupKeyAddress; + let backupSigningKey; + if (isUnsignedSweep) { + const backupHDNode = bip32.fromBase58(backupKey); + backupSigningKey = backupHDNode.publicKey; + backupKeyAddress = `0x${optionalDeps.ethUtil.publicToAddress(backupSigningKey, true).toString('hex')}`; + } else { + // Decrypt backup private key and get address + let backupPrv; + + try { + backupPrv = this.bitgo.decrypt({ + input: backupKey, + password: params.walletPassphrase, + }); + } catch (e) { + throw new Error(`Error decrypting backup keychain: ${e.message}`); + } + + const keyPair = new KeyPairLib({ prv: backupPrv }); + backupSigningKey = keyPair.getKeys().prv; + if (!backupSigningKey) { + throw new Error('no private key'); + } + backupKeyAddress = keyPair.getAddress(); + } + + const backupKeyNonce = await this.getAddressNonce(backupKeyAddress, params.apiKey); + // get balance of backupKey to ensure funds are available to pay fees + const backupKeyBalance = await this.queryAddressBalance(backupKeyAddress, params.apiKey); + const totalGasNeeded = gasPrice.mul(gasLimit); + + const weiToGwei = 10 ** 9; + if (backupKeyBalance.lt(totalGasNeeded)) { + throw new Error( + `Backup key address ${backupKeyAddress} has balance ${(backupKeyBalance / weiToGwei).toString()} Gwei.` + + `This address must have a balance of at least ${(totalGasNeeded / weiToGwei).toString()}` + + ` Gwei to perform recoveries. Try sending some funds to this address then retry.` + ); + } + + // get balance of wallet + const txAmount = await this.queryAddressBalance(params.walletContractAddress, params.apiKey); + if (new BigNumber(txAmount).isLessThanOrEqualTo(0)) { + throw new Error('Wallet does not have enough funds to recover'); + } + + // build recipients object + const recipients = [ + { + address: params.recoveryDestination, + amount: txAmount.toString(10), + }, + ]; + + // Get sequence ID using contract call + // we need to wait between making two explorer api calls to avoid getting banned + await new Promise((resolve) => setTimeout(resolve, 1000)); + const sequenceId = await this.querySequenceId(params.walletContractAddress, params.apiKey); + + let operationHash, signature; + // Get operation hash and sign it + if (!isUnsignedSweep) { + operationHash = this.getOperationSha3ForExecuteAndConfirm(recipients, this.getDefaultExpireTime(), sequenceId); + signature = Util.ethSignMsgHash(operationHash, Util.xprvToEthPrivateKey(userKey)); + + try { + Util.ecRecoverEthAddress(operationHash, signature); + } catch (e) { + throw new Error('Invalid signature'); + } + } + + const txInfo = { + recipient: recipients[0], + expireTime: this.getDefaultExpireTime(), + contractSequenceId: sequenceId, + operationHash: operationHash, + signature: signature, + gasLimit: gasLimit.toString(10), + }; + + const txBuilder = this.getTransactionBuilder() as TransactionBuilder; + txBuilder.counter(backupKeyNonce); + txBuilder.contract(params.walletContractAddress); + let txFee; + if (params.eip1559) { + txFee = { + eip1559: { + maxPriorityFeePerGas: params.eip1559.maxPriorityFeePerGas, + maxFeePerGas: params.eip1559.maxFeePerGas, + }, + }; + } else { + txFee = { fee: gasPrice.toString() }; + } + txBuilder.fee({ + ...txFee, + gasLimit: gasLimit.toString(), + }); + const transferBuilder = txBuilder.transfer() as TransferBuilder; + transferBuilder + .coin(this.staticsCoin?.name as string) + .amount(recipients[0].amount) + .contractSequenceId(sequenceId) + .expirationTime(this.getDefaultExpireTime()) + .to(params.recoveryDestination); + + const tx = await txBuilder.build(); + if (isUnsignedSweep) { + const response: OfflineVaultTxInfo = { + txHex: tx.toBroadcastFormat(), + userKey, + backupKey, + coin: this.getChain(), + gasPrice: optionalDeps.ethUtil.bufferToInt(gasPrice).toFixed(), + gasLimit, + recipients: [txInfo.recipient], + walletContractAddress: tx.toJson().to, + amount: txInfo.recipient.amount, + backupKeyNonce, + eip1559: params.eip1559, + }; + _.extend(response, txInfo); + response.nextContractSequenceId = response.contractSequenceId; + return response; + } + + txBuilder + .transfer() + .coin(this.staticsCoin?.name as string) + .key(new KeyPairLib({ prv: userKey }).getKeys().prv as string); + txBuilder.sign({ key: backupSigningKey }); + + const signedTx = await txBuilder.build(); + + return { + id: signedTx.toJson().id, + tx: signedTx.toBroadcastFormat(), + }; + } + + async signTransaction(params: SignTransactionOptions): Promise { + // Normally the SDK provides the first signature for an EthLike tx, but occasionally it provides the second and final one. + if (params.isLastSignature) { + // In this case when we're doing the second (final) signature, the logic is different. + return await this.signFinalEthLike(params); + } + const txBuilder = this.getTransactionBuilder(); + txBuilder.from(params.txPrebuild.txHex); + txBuilder + .transfer() + .coin(this.staticsCoin?.name as string) + .key(new KeyPairLib({ prv: params.prv }).getKeys().prv!); + if (params.walletVersion) { + txBuilder.walletVersion(params.walletVersion); + } + const transaction = await txBuilder.build(); + + // In case of tx with contract data from a custodial wallet, we are running into an issue + // as halfSigned is not having the data field. So, we are adding the data field to the halfSigned tx + let recipients = params.txPrebuild.recipients || params.recipients; + if (recipients === undefined) { + recipients = transaction.outputs.map((output) => ({ address: output.address, amount: output.value })); + } + + const txParams = { + eip1559: params.txPrebuild.eip1559, + txHex: transaction.toBroadcastFormat(), + recipients: recipients, + expiration: params.txPrebuild.expireTime, + hopTransaction: params.txPrebuild.hopTransaction, + custodianTransactionId: params.custodianTransactionId, + expireTime: params.expireTime, + contractSequenceId: params.txPrebuild.nextContractSequenceId as number, + sequenceId: params.sequenceId, + ...(params.txPrebuild.isBatch ? { isBatch: params.txPrebuild.isBatch } : {}), + }; + + return { halfSigned: txParams }; + } } diff --git a/modules/sdk-coin-eth/src/lib/transferBuilder.ts b/modules/sdk-coin-eth/src/lib/transferBuilder.ts index 502bca7c9a..6a0afcfe15 100644 --- a/modules/sdk-coin-eth/src/lib/transferBuilder.ts +++ b/modules/sdk-coin-eth/src/lib/transferBuilder.ts @@ -17,6 +17,11 @@ export class TransferBuilder extends EthTransferBuilder { * @returns the string prefix */ protected getNativeOperationHashPrefix(): string { + // TODO: if testnet, return '560048' + // else, return 'ETHER' + if (this._chainId === '560048') { + return '560048'; + } return 'ETHER'; } } diff --git a/modules/sdk-coin-eth/test/unit/eth.ts b/modules/sdk-coin-eth/test/unit/eth.ts index 1befc93298..8ea17dd941 100644 --- a/modules/sdk-coin-eth/test/unit/eth.ts +++ b/modules/sdk-coin-eth/test/unit/eth.ts @@ -965,6 +965,40 @@ describe('ETH:', function () { basecoin = bitgo.coin('hteth') as Hteth; }); + it('should construct a recovery transaction without BitGo', async function () { + const basecoin = bitgo.coin('hteth'); + const recovery = await (basecoin as any).recover({ + userKey: + '{"iv":"aqsg7l4QYJuQ1MgJ4TYJQg==","v":1,"iter":10000,"ks":256,"ts":64,"mode"\n' + + ':"ccm","adata":"","cipher":"aes","salt":"I9terSX4xiI=","ct":"hqg1i8pZHtjo8+\n' + + 'kjIu5AsO782ddwQ5CGerz7zbEoSbFkVcMzQtrf6BYD+XpaYUUw8c/hSR3vlwvquGNoFDCXna355\n' + + 'pQUYLLY7GDgDD33oznFj3HrP5y2acudAStuPkxz5Eu34WUnYac15Tts9oFaCO8vMgWgbYo="}', + backupKey: + '{"iv":"8zW3vgZRkHLKxNj9SXNm8g==","v":1,"iter":10000,"ks":256,"ts":64,"mode"\n' + + ':"ccm","adata":"","cipher":"aes","salt":"T8R4dMWbU2s=","ct":"J3eXZKbdIrqvXd\n' + + '3h/0enOQe2orKZPCI7fYqkMyPOQRiFVp2b9k790lM2arJ9lt4yM1aTAcRIlJ9ypVMdHppZreGZ0\n' + + 'TOxtXg3I3yF8BHPdv0QrlR/ok6m67YHsqoY23OqnRiuAjR93DlWcHDDXrz7XF+xVmwdCcI="}', + walletContractAddress: '0xda7d06fb9c0369e824ed6ebd6b1c07fcd3b7ad1c', + walletPassphrase: 'kLghyj07=b5cBY;^q7Z1', + recoveryDestination: '0xDb2Dc32d8ade2bc922b4A46714C87282B1BD9d65', + apiKey: 'RBXDGS4EY2HM3A2MC89FCU2YTHCDX6QUIW', + eip1559: { + maxFeePerGas: 20000000000, + maxPriorityFeePerGas: 10000000000, + }, + replayProtectionOptions: { + chain: '560048', + hardfork: 'london', + }, + }); + + // id and tx will always be different because of expireTime + should.exist(recovery); + console.log('recovery', recovery); + recovery.should.have.property('id'); + recovery.should.have.property('tx'); + }); + it('should generate an unsigned sweep without derivation seed', async function () { nock(baseUrl) .get('/api') diff --git a/modules/statics/src/coins.ts b/modules/statics/src/coins.ts index cb076d9d8a..58d003f244 100644 --- a/modules/statics/src/coins.ts +++ b/modules/statics/src/coins.ts @@ -348,6 +348,7 @@ export const coins = CoinMap.fromCoins([ CoinFeature.STUCK_TRANSACTION_MANAGEMENT_TSS, CoinFeature.EIP1559, CoinFeature.ERC20_BULK_TRANSACTION, + CoinFeature.USES_NON_PACKED_ENCODING_FOR_TXDATA, ] ), account( diff --git a/modules/statics/src/networks.ts b/modules/statics/src/networks.ts index 9fbb08819f..d811d53f15 100644 --- a/modules/statics/src/networks.ts +++ b/modules/statics/src/networks.ts @@ -627,8 +627,8 @@ class Hoodi extends Testnet implements EthereumNetwork { // https://chainlist.org/chain/560048 chainId = 560048; batcherContractAddress = '0x3e1e5d78e44f15593b3b61ed278f12c27f0ff33e'; - forwarderFactoryAddress = '0xffa397285ce46fb78c588a9e993286aac68c37cd'; - forwarderImplementationAddress = '0x059ffafdc6ef594230de44f824e2bd0a51ca5ded'; + forwarderFactoryAddress = '0x7441f20a59be97011842404b9aefd8d85fd81aa6'; + forwarderImplementationAddress = '0x0e2874d6824fae4f61e446012317a9b86384bd8e'; nativeCoinOperationHashPrefix = 'ETHER'; tokenOperationHashPrefix = 'ERC20'; } diff --git a/modules/statics/test/unit/resources/amsTokenConfig.ts b/modules/statics/test/unit/resources/amsTokenConfig.ts index d0f10f441d..ec09800dd1 100644 --- a/modules/statics/test/unit/resources/amsTokenConfig.ts +++ b/modules/statics/test/unit/resources/amsTokenConfig.ts @@ -659,8 +659,8 @@ export const amsTokenConfigWithCustomToken = { blockExplorerUrl: 'https://hoodi.etherscan.io/block/', chainId: 560048, batcherContractAddress: '0x3e1e5d78e44f15593b3b61ed278f12c27f0ff33e', - forwarderFactoryAddress: '0xffa397285ce46fb78c588a9e993286aac68c37cd', - forwarderImplementationAddress: '0x059ffafdc6ef594230de44f824e2bd0a51ca5ded', + forwarderFactoryAddress: '0x7441f20a59be97011842404b9aefd8d85fd81aa6', + forwarderImplementationAddress: '0x0e2874d6824fae4f61e446012317a9b86384bd8e', nativeCoinOperationHashPrefix: 'ETHER', tokenOperationHashPrefix: 'ERC20', },