From 2ace2a1d34688b7496f1c085ae666e00387f639c Mon Sep 17 00:00:00 2001 From: Alia Aamir Date: Thu, 26 Oct 2023 13:07:44 -0400 Subject: [PATCH] feat(sdk-coin-avaxc): add avax token support in recovery method TICKET: WP-847 --- modules/sdk-coin-avaxc/src/avaxc.ts | 91 ++++++++- .../sdk-coin-avaxc/test/resources/avaxc.ts | 123 ++++++++++++ modules/sdk-coin-avaxc/test/unit/avaxc.ts | 182 +++++++++++++++++- 3 files changed, 387 insertions(+), 9 deletions(-) diff --git a/modules/sdk-coin-avaxc/src/avaxc.ts b/modules/sdk-coin-avaxc/src/avaxc.ts index f5f1412174..e1bd5f3ce7 100644 --- a/modules/sdk-coin-avaxc/src/avaxc.ts +++ b/modules/sdk-coin-avaxc/src/avaxc.ts @@ -56,6 +56,31 @@ import { } from './iface'; import { AvaxpLib } from '@bitgo/sdk-coin-avaxp'; +const AVAXC_TOKENS = { + // mainnet tokens + 'avaxc:png': '0x60781c2586d68229fde47564546784ab3faca982', + 'avaxc:xava': '0xd1c3f94de7e5b45fa4edbba472491a9f4b166fc4', + 'avaxc:klo': '0xb27c8941a7df8958a1778c0259f76d1f8b711c35', + 'avaxc:joe': '0x6e84a6216ea6dacc71ee8e6b0a5b7322eebc0fdd', + 'avaxc:qi': '0x8729438eb15e2c8b576fcc6aecda6a148776c0f5', + 'avaxc:usdt': '0x9702230a8ea53601f5cd2dc00fdbc13d4df4a8c7', + 'avaxc:usdc': '0xb97ef9ef8734c71904d8002f8b6bc66dd9c48a6e', + 'avaxc:link': '0x5947bb275c521040051d82396192181b413227a3', + 'avaxc:cai': '0x48f88a3fe843ccb0b5003e70b4192c1d7448bef0', + 'avaxc:usdc-e': '0xa7d7079b0fead91f3e65f86e8915cb59c1a4c664', + 'avaxc:dai-e': '0xd586e7f844cea2f87f50152665bcbc2c279d8d70', + 'avaxc:usdt-e': '0xc7198437980c041c805a1edcba50c1ce5db95118', + 'avaxc:wbtc-e': '0x50b7545627a5162f82a992c33b87adc75187b218', + 'avaxc:weth-e': '0x49d5c2bdffac6ce2bfdb6640f4f80f226bc10bab', + 'avaxc:aave-e': '0x63a72806098bd3d9520cc43356dd78afe5d386d9', + 'avaxc:usdc-wormhole': '0x543672e9cbec728cbba9c3ccd99ed80ac3607fa8', + 'avaxc:btc-b': '0x152b9d0fdc40c096757f570a51e494bd4b943e50', + + // testnet tokens + 'tavaxc:link': '0x0b9d5d9136855f6fec3c0993fee6e9ce8a297846', + 'tavaxc:opm': '0x9a25414c8a41599cb7048f2e4dd42db02c1de487', +}; + export class AvaxC extends BaseCoin { static hopTransactionSalt = 'bitgoHopAddressRequestSalt'; @@ -340,6 +365,25 @@ export class AvaxC extends BaseCoin { return new optionalDeps.ethUtil.BN(result.result, 10); } + /** + * Queries Snowtrace for the token balance of an address + * @param {string} address - the AVAXC address + * @returns {Promise} address balance + */ + async queryAddressTokenBalance(address: string, contractAddress: string): Promise { + const result = await this.recoveryBlockchainExplorerQuery({ + module: 'account', + action: 'tokenbalance', + address: address, + contractaddress: contractAddress, + }); + // throw if the result does not exist or the result is not a valid number + if (!result || !result.result || isNaN(result.result)) { + throw new Error(`Could not obtain address token balance for ${address} from Snowtrace, got: ${result.result}`); + } + return new optionalDeps.ethUtil.BN(result.result, 10); + } + /** * Queries the contract (via Snowtrace) for the next sequence ID * @param {string} address - address of the contract @@ -523,6 +567,20 @@ export class AvaxC extends BaseCoin { throw new Error('invalid walletContractAddress'); } + let tokenName; + if (params.tokenContractAddress) { + if (!this.isValidAddress(params.tokenContractAddress)) { + throw new Error('invalid tokenContractAddress'); + } + + tokenName = (Object.keys(AVAXC_TOKENS) as (keyof typeof AVAXC_TOKENS)[]).find((key) => { + return AVAXC_TOKENS[key] === params.tokenContractAddress; + }); + if (_.isUndefined(tokenName)) { + throw new Error('token not supported'); + } + } + if (_.isUndefined(params.recoveryDestination) || !this.isValidAddress(params.recoveryDestination)) { throw new Error('invalid recoveryDestination'); } @@ -590,8 +648,14 @@ export class AvaxC extends BaseCoin { ); } - // get balance of wallet and deduct fees to get transaction amount - const txAmount = await this.queryAddressBalance(params.walletContractAddress); + let txAmount; + if (params.tokenContractAddress) { + // get token balance of wallet + txAmount = await this.queryAddressTokenBalance(params.walletContractAddress, params.tokenContractAddress); + } else { + // get balance of wallet and deduct fees to get transaction amount + txAmount = await this.queryAddressBalance(params.walletContractAddress); + } // build recipients object const recipients = [ @@ -626,6 +690,7 @@ export class AvaxC extends BaseCoin { operationHash, signature, gasLimit: gasLimit.toString(10), + tokenContractAddress: params.tokenContractAddress, }; const txBuilder = this.getTransactionBuilder() as TransactionBuilder; @@ -646,12 +711,22 @@ export class AvaxC extends BaseCoin { ...txFee, gasLimit: gasLimit.toString(), }); - txBuilder - .transfer() - .amount(recipients[0].amount) - .contractSequenceId(sequenceId) - .expirationTime(this.getDefaultExpireTime()) - .to(params.recoveryDestination); + if (params.tokenContractAddress) { + txBuilder + .transfer() + .coin(tokenName) + .amount(recipients[0].amount) + .contractSequenceId(sequenceId) + .expirationTime(this.getDefaultExpireTime()) + .to(params.recoveryDestination); + } else { + txBuilder + .transfer() + .amount(recipients[0].amount) + .contractSequenceId(sequenceId) + .expirationTime(this.getDefaultExpireTime()) + .to(params.recoveryDestination); + } if (isUnsignedSweep) { const tx = await txBuilder.build(); diff --git a/modules/sdk-coin-avaxc/test/resources/avaxc.ts b/modules/sdk-coin-avaxc/test/resources/avaxc.ts index 53541ca709..dac5514f75 100644 --- a/modules/sdk-coin-avaxc/test/resources/avaxc.ts +++ b/modules/sdk-coin-avaxc/test/resources/avaxc.ts @@ -109,3 +109,126 @@ export const EXPORT_C = { threshold: 2, fee: '1000025', }; + +const hotWalletRecoveryUser = { + userKey: + '{"iv":"bqPszODXX/dZCdQh3Rtnkw==","v":1,"iter":10000,"ks":256,"ts":64,"mode"' + + ':"ccm","adata":"","cipher":"aes","salt":"g9Z7kqquE78=","ct":"o6JABeNwv0kEcc' + + 'QGuHdrfFiBQ4QE491cP22R8QhREuosAAMezqr7hbT3Q77i/agHK9QXPkUaR/U52TOeX/H6Q7jTY' + + 'EETNxTe+ma3wVrOnPEX6svKERvUCo/IzIDhwriWeT35lZNVxxJGwtpCj2wtelIk67Gzgns="}', + backupKey: + '{"iv":"oE4sYous0T7vfzWl6sOIWg==","v":1,"iter":10000,"ks":256,"ts":64,"mode"' + + ':"ccm","adata":"","cipher":"aes","salt":"I+cvCbXdrfc=","ct":"0QyUMV78WoBILM' + + '32agbvbmsycaN71PIkvMs2qJaPweQ+Gt7lTpbjz88iQFW16tR4tdGRLuhadQZSrlzHEl7CzhYLn' + + 'yBAqacJN3To4teK3VTXYa8xjlpMfLTR47i4zn51qfNx9mR/b5D0DFA2U5SrOG8SsSn+fm8="}', + walletContractAddress: '0xe93f3fb23419b3b2e9b3edcfa79c94ad1d84b381', + walletPassphrase: 'Ghghjkg!455544llll', + backupKeyAddress: '0xd43c4549c80e313293897c545e99d652e98d86da', +}; + +const coldWalletRecoveryUser = { + userKey: + 'xpub661MyMwAqRbcFpb8nBxgG2rkwyHoKfWdgGxGdz84zagAwUwedkiEePHCe6casjvWkFUv4jLYToKRu3S3QANYjytpES8HNZFWZGcCa5hcyHs', + backupKey: + 'xpub661MyMwAqRbcG5ZCcZSAst64sWbLBvd3APKFDJcr9u4AosNbyUGJR6PcSUdxte44AoENFQyy7rMbd3tdZ1kJxw3TQwnnCsfipUx2BNNppog', + walletContractAddress: '0xfe0340c352dd6807f61fa57ba2b4c3fa21b01ec0', + walletPassphrase: 'Ghghjkg!455544llll', + backupKeyAddress: '0xee433c84a778e74b6af4c627c8c739b71b11b8d9', +}; + +export const recoveryUsers = { + hotWalletRecoveryUser, + coldWalletRecoveryUser, +}; + +const addressNonceResponse1 = { + status: '0', + message: 'No transactions found', + result: [], +}; + +const addressNonceResponse2 = { + status: '1', + message: 'OK', + result: [ + { + blockNumber: '27139350', + timeStamp: '1698339817', + hash: '0x7dff6ba726df2d88022f7e26b6bb88253b19b50c9096e479742dcf01ea30a1a4', + nonce: '0', + blockHash: '0x97cf3e28e86e2909996c0e5bccc57aa46bb15dbe06d2dd880dcb8c6a650005b7', + transactionIndex: '0', + from: '0xee433c84a778e74b6af4c627c8c739b71b11b8d9', + to: '0xfe0340c352dd6807f61fa57ba2b4c3fa21b01ec0', + value: '0', + gas: '500000', + gasPrice: '30000000000', + isError: '0', + txreceipt_status: '1', + input: + '0x0dcd7a6c000000000000000000000000e93f3fb23419b3b2e9b3edcfa79c94ad1d84b3810000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000b9d5d9136855f6fec3c0993fee6e9ce8a297846000000000000000000000000000000000000000000000000000000006543d600000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000004132a881d1ad3ba1be799c75d45a1ce9503f607f0eba738e221810af7acc86b810116eebe2dcd0ed83ec25722d239b2104435f27901e6681c396d30e65f5bc36de1b00000000000000000000000000000000000000000000000000000000000000', + contractAddress: '', + cumulativeGasUsed: '100924', + gasUsed: '100924', + confirmations: '500', + methodId: '0x0dcd7a6c', + functionName: '', + }, + { + blockNumber: '27139407', + timeStamp: '1698339962', + hash: '0xaf843a012e814e13cefa451d876851019ad91bca199a9eee2208c6d4fd0f4496', + nonce: '1', + blockHash: '0xc4ef44536185511c13e5d59bd083f347f5c06811dc9a0bbee94e1d015d219b8e', + transactionIndex: '0', + from: '0xee433c84a778e74b6af4c627c8c739b71b11b8d9', + to: '0xfe0340c352dd6807f61fa57ba2b4c3fa21b01ec0', + value: '0', + gas: '500000', + gasPrice: '30000000000', + isError: '0', + txreceipt_status: '1', + input: + '0x39125215000000000000000000000000e93f3fb23419b3b2e9b3edcfa79c94ad1d84b3810000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000006543d6ae000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000041ee83c22815fd8e015321b3fbd463799aa092c942ca2cbf6f8fefb936dde0a6f670381934f9d3f7506aead0438fcd53ef686905cc963d42de49e0261bd15a105e1b00000000000000000000000000000000000000000000000000000000000000', + contractAddress: '', + cumulativeGasUsed: '99542', + gasUsed: '99542', + confirmations: '443', + methodId: '0x39125215', + functionName: '', + }, + ], +}; + +const backupAddressBalanceResponse = { + status: '1', + message: 'OK', + result: '43986020000000000', +}; + +const addressTokenBalanceResponse = { + status: '1', + message: 'OK', + result: '1500000000000000000', +}; + +const sequenceIdResponse1 = { + jsonrpc: '2.0', + id: 1, + result: '0x0000000000000000000000000000000000000000000000000000000000000003', +}; + +const sequenceIdResponse2 = { + jsonrpc: '2.0', + id: 1, + result: '0x000000000000000000000000000000000000000000000000000000000000000d', +}; + +export const endpointResponses = { + addressNonceResponse1, + addressNonceResponse2, + backupAddressBalanceResponse, + addressTokenBalanceResponse, + sequenceIdResponse1, + sequenceIdResponse2, +}; diff --git a/modules/sdk-coin-avaxc/test/unit/avaxc.ts b/modules/sdk-coin-avaxc/test/unit/avaxc.ts index 1ff5b131f7..de6f454c4b 100644 --- a/modules/sdk-coin-avaxc/test/unit/avaxc.ts +++ b/modules/sdk-coin-avaxc/test/unit/avaxc.ts @@ -9,9 +9,10 @@ import { common, TransactionType, Wallet } from '@bitgo/sdk-core'; import { Eth } from '@bitgo/sdk-coin-eth'; import { AvaxSignTransactionOptions } from '../../src/iface'; import * as should from 'should'; -import { EXPORT_C, IMPORT_C } from '../resources/avaxc'; +import { EXPORT_C, IMPORT_C, endpointResponses, recoveryUsers } from '../resources/avaxc'; import { TavaxP } from '@bitgo/sdk-coin-avaxp'; import { decodeTransaction, parseTransaction, walletSimpleABI } from './helpers'; +import * as sinon from 'sinon'; nock.enableNetConnect(); @@ -63,6 +64,7 @@ describe('Avalanche C-Chain', function () { bitgo.safeRegister('teth', Eth.createInstance); bitgo.safeRegister('tavaxp', TavaxP.createInstance); common.Environments[bitgo.getEnv()].hsmXpub = bitgoXpub; + common.Environments[bitgo.getEnv()].snowtraceApiToken = 'A3NFH1QQEW85R3KK1SJ9KN39RI7T5CB7AX'; bitgo.initializeTestVars(); }); @@ -955,5 +957,183 @@ describe('Avalanche C-Chain', function () { rebuiltTx.outputs[0].value.should.equal(txPrebuild.recipient.amount); }); }); + + describe('Token Recovery', async function () { + const destAddr = '0xc74b1c0ee90a481b528253042ce3228a8cd6873e'; + const weiToGwei = 10 ** 9; + const gasPrice = 30 * weiToGwei; + // contract address for 'tavaxc:link' + const tokenContractAddress = '0x0b9d5d9136855f6fec3c0993fee6e9ce8a297846'; + const tokenName = 'tavaxc:link'; + const sequenceIdData = 'a0b7967b'; + const sandBox = sinon.createSandbox(); + + beforeEach(function () { + const callBack = sandBox.stub(AvaxC.prototype, 'recoveryBlockchainExplorerQuery' as keyof AvaxC); + callBack + .withArgs({ + module: 'account', + action: 'txlist', + address: recoveryUsers.hotWalletRecoveryUser.backupKeyAddress, + }) + .resolves(endpointResponses.addressNonceResponse1); + callBack + .withArgs({ + module: 'account', + action: 'txlist', + address: recoveryUsers.coldWalletRecoveryUser.backupKeyAddress, + }) + .resolves(endpointResponses.addressNonceResponse2); + callBack + .withArgs({ + module: 'account', + action: 'balance', + address: recoveryUsers.hotWalletRecoveryUser.backupKeyAddress, + }) + .resolves(endpointResponses.backupAddressBalanceResponse); + callBack + .withArgs({ + module: 'account', + action: 'balance', + address: recoveryUsers.coldWalletRecoveryUser.backupKeyAddress, + }) + .resolves(endpointResponses.backupAddressBalanceResponse); + callBack + .withArgs({ + module: 'account', + action: 'tokenbalance', + address: recoveryUsers.hotWalletRecoveryUser.walletContractAddress, + contractaddress: tokenContractAddress, + }) + .resolves(endpointResponses.addressTokenBalanceResponse); + callBack + .withArgs({ + module: 'account', + action: 'tokenbalance', + address: recoveryUsers.coldWalletRecoveryUser.walletContractAddress, + contractaddress: tokenContractAddress, + }) + .resolves(endpointResponses.addressTokenBalanceResponse); + callBack + .withArgs({ + module: 'proxy', + action: 'eth_call', + to: recoveryUsers.hotWalletRecoveryUser.walletContractAddress, + data: sequenceIdData, + tag: 'latest', + }) + .resolves(endpointResponses.sequenceIdResponse1); + callBack + .withArgs({ + module: 'proxy', + action: 'eth_call', + to: recoveryUsers.coldWalletRecoveryUser.walletContractAddress, + data: sequenceIdData, + tag: 'latest', + }) + .resolves(endpointResponses.sequenceIdResponse2); + }); + + afterEach(function () { + sandBox.restore(); + }); + + it('should build token recovery tx', async function () { + const params = { + userKey: recoveryUsers.hotWalletRecoveryUser.userKey, + backupKey: recoveryUsers.hotWalletRecoveryUser.backupKey, + recoveryDestination: destAddr, + walletPassphrase: recoveryUsers.hotWalletRecoveryUser.walletPassphrase, + walletContractAddress: recoveryUsers.hotWalletRecoveryUser.walletContractAddress, + tokenContractAddress, + gasPrice: gasPrice.toString(), + }; + const recoveryTxn = await tavaxCoin.recover(params); + + recoveryTxn.should.not.be.undefined(); + recoveryTxn.should.have.property('id'); + recoveryTxn.should.have.property('tx'); + + const txBuilder = tavaxCoin.getTransactionBuilder() as TransactionBuilder; + txBuilder.from(recoveryTxn.tx); + const tx = await txBuilder.build(); + tx.toBroadcastFormat().should.not.be.empty(); + tx.inputs.should.not.be.empty(); + tx.inputs[0].address.should.equal(recoveryUsers.hotWalletRecoveryUser.walletContractAddress); + tx.inputs[0].value.should.equal(endpointResponses.addressTokenBalanceResponse.result); + tx.inputs[0].coin?.should.equal(tokenName); + tx.outputs.should.not.be.empty(); + tx.outputs[0].address.should.equal(destAddr); + tx.outputs[0].value.should.equal(endpointResponses.addressTokenBalanceResponse.result); + tx.outputs[0].coin?.should.equal(tokenName); + }); + + it('should build unsigned sweep token recovery tx', async function () { + const params = { + userKey: recoveryUsers.coldWalletRecoveryUser.userKey, + backupKey: recoveryUsers.coldWalletRecoveryUser.backupKey, + recoveryDestination: destAddr, + walletPassphrase: recoveryUsers.coldWalletRecoveryUser.walletPassphrase, + walletContractAddress: recoveryUsers.coldWalletRecoveryUser.walletContractAddress, + tokenContractAddress, + gasPrice: gasPrice.toString(), + }; + const recoveryTxn = await tavaxCoin.recover(params); + + recoveryTxn.should.not.be.undefined(); + recoveryTxn.should.have.property('txHex'); + const txBuilder = tavaxCoin.getTransactionBuilder() as TransactionBuilder; + txBuilder.from(recoveryTxn.txHex); + const tx = await txBuilder.build(); + const recoveryAmount = endpointResponses.addressTokenBalanceResponse.result; + tx.toBroadcastFormat().should.not.be.empty(); + tx.inputs.should.not.be.empty(); + tx.inputs[0].address.should.equal(recoveryUsers.coldWalletRecoveryUser.walletContractAddress); + tx.inputs[0].value.should.equal(recoveryAmount); + tx.inputs[0].should.have.property('coin'); + tx.inputs[0].coin?.should.equal(tokenName); + tx.outputs.should.not.be.empty(); + tx.outputs[0].address.should.equal(destAddr); + tx.outputs[0].value.should.equal(endpointResponses.addressTokenBalanceResponse.result); + tx.outputs[0].should.have.property('coin'); + tx.outputs[0].coin?.should.equal(tokenName); + + recoveryTxn.should.have.property('userKey'); + recoveryTxn.userKey.should.equal(recoveryUsers.coldWalletRecoveryUser.userKey); + recoveryTxn.should.have.property('backupKey'); + recoveryTxn.backupKey.should.equal(recoveryUsers.coldWalletRecoveryUser.backupKey); + + recoveryTxn.should.have.property('coin'); + recoveryTxn.coin.should.equal('tavaxc'); + + recoveryTxn.should.have.property('gasPrice'); + recoveryTxn.gasPrice.should.equal(gasPrice.toString()); + recoveryTxn.should.have.property('gasLimit'); + recoveryTxn.gasLimit.should.equal('500000'); + + recoveryTxn.should.have.property('recipients'); + recoveryTxn.recipients.length.should.equal(1); + recoveryTxn.recipients[0].address.should.equal(destAddr); + recoveryTxn.recipients[0].amount.should.equal(recoveryAmount); + + recoveryTxn.should.have.property('walletContractAddress'); + recoveryTxn.walletContractAddress.should.equal(recoveryUsers.coldWalletRecoveryUser.walletContractAddress); + + recoveryTxn.should.have.property('amount'); + recoveryTxn.amount.should.equal(recoveryAmount); + + recoveryTxn.should.have.property('recipient'); + recoveryTxn.recipient.address.should.equal(destAddr); + recoveryTxn.recipient.amount.should.equal(recoveryAmount); + + recoveryTxn.should.have.property('tokenContractAddress'); + recoveryTxn.tokenContractAddress.should.equal(tokenContractAddress); + + recoveryTxn.should.have.property('backupKeyNonce'); + recoveryTxn.should.have.property('expireTime'); + recoveryTxn.should.have.property('contractSequenceId'); + recoveryTxn.should.have.property('nextContractSequenceId'); + }); + }); }); });