From 022189691a2f9a1ce2aa5ca84ddcc1042fd25e66 Mon Sep 17 00:00:00 2001 From: Mullapudi Pruthvik Date: Thu, 6 Feb 2025 18:41:44 +0530 Subject: [PATCH] feat(abstract-eth): add method to build and send ccr recovery txn Ticket: COIN-2884 TICKET: COIN-2884 --- .../src/abstractEthLikeNewCoins.ts | 53 ++++++++++++ modules/abstract-eth/src/types.ts | 9 ++ .../test/fixtures/ethlikeCoin.ts | 18 ++++ modules/sdk-coin-ethlike/test/resources.ts | 10 +++ .../sdk-coin-ethlike/test/unit/ethlikeCoin.ts | 85 ++++++++++++++++++- 5 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 modules/abstract-eth/src/types.ts diff --git a/modules/abstract-eth/src/abstractEthLikeNewCoins.ts b/modules/abstract-eth/src/abstractEthLikeNewCoins.ts index 57f0b7ea7c..6eea99868e 100644 --- a/modules/abstract-eth/src/abstractEthLikeNewCoins.ts +++ b/modules/abstract-eth/src/abstractEthLikeNewCoins.ts @@ -68,6 +68,7 @@ import { TransactionBuilder, TransferBuilder, } from './lib'; +import { SendCrossChainRecoveryOptions } from './types'; /** * The prebuilt hop transaction returned from the HSM @@ -1321,6 +1322,58 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin { }; } + async sendCrossChainRecoveryTransaction( + params: SendCrossChainRecoveryOptions + ): Promise<{ coin: string; txHex?: string; txid: string }> { + const buildResponse = await this.buildCrossChainRecoveryTransaction(params.recoveryId); + if (params.walletType === 'cold') { + return buildResponse; + } + if (!params.encryptedPrv) { + throw new Error('missing encryptedPrv'); + } + + let userKeyPrv; + try { + userKeyPrv = this.bitgo.decrypt({ + input: params.encryptedPrv, + password: params.walletPassphrase, + }); + } catch (e) { + throw new Error(`Error decrypting user keychain: ${e.message}`); + } + const keyPair = new KeyPairLib({ prv: userKeyPrv }); + const userSigningKey = keyPair.getKeys().prv; + if (!userSigningKey) { + throw new Error('no private key'); + } + + const txBuilder = this.getTransactionBuilder(params.common) as TransactionBuilder; + const txHex = buildResponse.txHex; + txBuilder.from(txHex); + txBuilder + .transfer() + .coin(this.staticsCoin?.name as string) + .key(userSigningKey); + const tx = await txBuilder.build(); + const res = await this.bitgo + .post(this.bitgo.microservicesUrl(`/api/recovery/v1/crosschain/${params.recoveryId}/sign`)) + .send({ txHex: tx.toBroadcastFormat() }); + return { + coin: this.staticsCoin?.name as string, + txid: res.body.txid, + }; + } + + async buildCrossChainRecoveryTransaction(recoveryId: string): Promise<{ coin: string; txHex: string; txid: string }> { + const res = await this.bitgo.get(this.bitgo.microservicesUrl(`/api/recovery/v1/crosschain/${recoveryId}/buildtx`)); + return { + coin: res.body.coin, + txHex: res.body.txHex, + txid: res.body.txid, + }; + } + /** * Builds a unsigned (for cold, custody wallet) or * half-signed (for hot wallet) evm cross chain recovery transaction with diff --git a/modules/abstract-eth/src/types.ts b/modules/abstract-eth/src/types.ts new file mode 100644 index 0000000000..43671155e6 --- /dev/null +++ b/modules/abstract-eth/src/types.ts @@ -0,0 +1,9 @@ +import EthereumCommon from '@ethereumjs/common'; + +export type SendCrossChainRecoveryOptions = { + recoveryId: string; + walletPassphrase?: string; + encryptedPrv?: string; + walletType: 'hot' | 'cold'; + common?: EthereumCommon; +}; diff --git a/modules/sdk-coin-ethlike/test/fixtures/ethlikeCoin.ts b/modules/sdk-coin-ethlike/test/fixtures/ethlikeCoin.ts index 47fd0f29f1..34aaebb686 100644 --- a/modules/sdk-coin-ethlike/test/fixtures/ethlikeCoin.ts +++ b/modules/sdk-coin-ethlike/test/fixtures/ethlikeCoin.ts @@ -49,3 +49,21 @@ export const getContractCallResponse = { result: '0x0000000000000000000000000000000000000000000000000000000000002a7f', id: 1, }; + +export const ccr = { + hteth: { + txHex: + '0xf9012c02843b9aca0083b8a1a0948f977e912ef500548a0c3be6ddde9899f1199b8180b901043912521500000000000000000000000019645032c7f1533395d44a629462e751084d3e4c000000000000000000000000000000000000000000000000000000003b9aca0000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000005ec67e28000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000000e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008284f38080', + txid: '0x62261142553d7dd3c574a93628391d58fe52b4005db1a230d8b6b99ce3584638', + }, + tarbeth: { + txHex: + '0xf9012e018541314cf000836acfc0948ce59c2d1702844f8eded451aa103961bc37b4e880b9010439125215000000000000000000000000eeaf0f05f37891ab4a21208b105a0687d12c5af7000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000002710000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000830cddff8080', + txid: '0xc6cff6f06e0452f85886c1e4f212a3fe444fe981939f80d8aad98e9a8507e526', + }, +}; +export const encryptedUserKey = + '{"iv":"VFZ3jvXhxo1Z+Yaf2MtZnA==","v":1,"iter":10000,"ks":256,"ts":64,"mode"\n' + + ':"ccm","adata":"","cipher":"aes","salt":"p+fkHuLa/8k=","ct":"hYG7pvljLIgCjZ\n' + + '53PBlCde5KZRmlUKKHLtDMk+HJfuU46hW+x+C9WsIAO4gFPnTCvFVmQ8x7czCtcNFub5AO2otOG\n' + + 'OsX4GE2gXOEmCl1TpWwwNhm7yMUjGJUpgW6ZZgXSXdDitSKi4V/hk78SGSzjFOBSPYRa6I="}\n'; diff --git a/modules/sdk-coin-ethlike/test/resources.ts b/modules/sdk-coin-ethlike/test/resources.ts index f2c39e84bd..539be0175d 100644 --- a/modules/sdk-coin-ethlike/test/resources.ts +++ b/modules/sdk-coin-ethlike/test/resources.ts @@ -1,4 +1,5 @@ import EthereumCommon from '@ethereumjs/common'; +import { coins, EthereumNetwork } from '@bitgo/statics'; import { BN } from 'ethereumjs-util'; export const baseChainCommon = EthereumCommon.custom({ @@ -8,6 +9,15 @@ export const baseChainCommon = EthereumCommon.custom({ defaultHardfork: 'london', }); +export function getCommon(coin: string): EthereumCommon { + return EthereumCommon.custom({ + name: coins.get(coin).name, + networkId: (coins.get(coin).network as EthereumNetwork).chainId, + chainId: (coins.get(coin).network as EthereumNetwork).chainId, + defaultHardfork: 'london', + }); +} + export const WALLET_FACTORY_ADDRESS = '0x809ee567e413543af1caebcdb247f6a67eafc8dd'; export const PRIVATE_KEY_1 = diff --git a/modules/sdk-coin-ethlike/test/unit/ethlikeCoin.ts b/modules/sdk-coin-ethlike/test/unit/ethlikeCoin.ts index ad5373d962..caaf21f964 100644 --- a/modules/sdk-coin-ethlike/test/unit/ethlikeCoin.ts +++ b/modules/sdk-coin-ethlike/test/unit/ethlikeCoin.ts @@ -8,11 +8,94 @@ import nock from 'nock'; import { EthLikeCoin, TethLikeCoin, EthLikeTransactionBuilder } from '../../src'; import { getBuilder } from '../getBuilder'; -import { baseChainCommon } from '../resources'; +import { baseChainCommon, getCommon } from '../resources'; import * as mockData from '../fixtures/ethlikeCoin'; nock.enableNetConnect(); +const coins = [ + { + name: 'hteth', + common: getCommon('hteth'), + }, + { + name: 'tarbeth', + common: getCommon('tarbeth'), + }, +]; + +describe('EthLike coin tests', function () { + let bitgo: TestBitGoAPI; + let basecoin: TethLikeCoin; + coins.forEach((coin) => { + describe(coin.name, function () { + before(function () { + const env = 'test'; + bitgo = TestBitGo.decorate(BitGoAPI, { env }); + bitgo.safeRegister(coin.name, TethLikeCoin.createInstance); + bitgo.initializeTestVars(); + basecoin = bitgo.coin(coin.name) as TethLikeCoin; + }); + + after(function () { + nock.cleanAll(); + }); + + it('should instantiate a coin', function () { + basecoin.should.be.an.instanceof(TethLikeCoin); + }); + it('should reject for missing encryptedPrv for hot wallet', async function () { + const recoveryId = '0x1234567890abcdef'; + nock(bitgo.microservicesUrl(`/api/recovery/v1/crosschain`)).get(`/${recoveryId}/buildtx`).reply(200, { + txHex: mockData.ccr[coin.name].txHex, + }); + const walletPassphrase = TestBitGo.V2.TEST_RECOVERY_PASSCODE as string; + const params = { + recoveryId, + walletPassphrase, + common: coin.common, + }; + await basecoin + .sendCrossChainRecoveryTransaction({ ...params, walletType: 'hot' }) + .should.be.rejectedWith('missing encryptedPrv'); + }); + it('should send cross chain recovery transaction for hot wallet', async function () { + const recoveryId = '0x1234567890abcdef'; + nock(bitgo.microservicesUrl(`/api/recovery/v1/crosschain`)).get(`/${recoveryId}/buildtx`).reply(200, { + txHex: mockData.ccr[coin.name].txHex, + }); + nock(bitgo.microservicesUrl(`/api/recovery/v1/crosschain`)).post(`/${recoveryId}/sign`).reply(200, { + coin: coin.name, + txid: mockData.ccr[coin.name].txid, + }); + const walletPassphrase = TestBitGo.V2.TEST_RECOVERY_PASSCODE as string; + const params = { + recoveryId, + walletPassphrase, + encryptedPrv: mockData.encryptedUserKey, + common: coin.common, + }; + const result = await basecoin.sendCrossChainRecoveryTransaction({ ...params, walletType: 'hot' }); + result.coin.should.equal(coin.name); + result.txid.should.equal(mockData.ccr[coin.name].txid); + }); + + it('should build txn for cross chain recovery for cold wallet', async function () { + const recoveryId = '0x1234567890abcdef'; + nock(bitgo.microservicesUrl(`/api/recovery/v1/crosschain`)).get(`/${recoveryId}/buildtx`).reply(200, { + txHex: mockData.ccr[coin.name].txHex, + }); + const params = { + recoveryId, + common: coin.common, + }; + const result = await basecoin.sendCrossChainRecoveryTransaction({ ...params, walletType: 'cold' }); + assert(result.txHex); + result.txHex.should.equal(mockData.ccr[coin.name].txHex); + }); + }); + }); +}); describe('EthLikeCoin', function () { let bitgo: TestBitGoAPI; const coinName = 'tbaseeth';