Skip to content

Commit

Permalink
Merge pull request #4302 from BitGo/WIN-2117-fix-recoverToken-method
Browse files Browse the repository at this point in the history
Win 2117 fix recover token method
  • Loading branch information
gianchandania authored Feb 23, 2024
2 parents 775f81b + 412e67b commit f97e109
Show file tree
Hide file tree
Showing 17 changed files with 455 additions and 95 deletions.
3 changes: 2 additions & 1 deletion modules/abstract-eth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"ethers": "^5.1.3",
"keccak": "^3.0.3",
"lodash": "4.17.21",
"secp256k1": "5.0.0"
"secp256k1": "5.0.0",
"superagent": "^3.8.3"
}
}
233 changes: 230 additions & 3 deletions modules/abstract-eth/src/ethLikeToken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,23 @@
* @prettier
*/
import { coins, EthLikeTokenConfig, tokens, EthereumNetwork as EthLikeNetwork } from '@bitgo/statics';
import _ from 'lodash';
import { bip32 } from '@bitgo/utxo-lib';

import { BitGoBase, CoinConstructor, NamedCoinConstructor } from '@bitgo/sdk-core';
import { TransactionBuilder as EthLikeTransactionBuilder } from './lib';
import { AbstractEthLikeNewCoins, optionalDeps, TransactionPrebuild } from './abstractEthLikeNewCoins';
import { BitGoBase, CoinConstructor, NamedCoinConstructor, getIsUnsignedSweep, Util } from '@bitgo/sdk-core';
import {
TransactionBuilder as EthLikeTransactionBuilder,
TransferBuilder as EthLikeTransferBuilder,
KeyPair as KeyPairLib,
} from './lib';
import {
AbstractEthLikeNewCoins,
optionalDeps,
TransactionPrebuild,
RecoverOptions,
RecoveryInfo,
OfflineVaultTxInfo,
} from './abstractEthLikeNewCoins';

export type CoinNames = {
[network: string]: string;
Expand Down Expand Up @@ -149,6 +162,220 @@ export class EthLikeToken extends AbstractEthLikeNewCoins {
];
}

/**
* Builds a token recovery transaction without BitGo
* @param params
* @param params.userKey {String} [encrypted] xprv
* @param params.backupKey {String} [encrypted] xprv or xpub if the xprv is held by a KRS providers
* @param params.walletPassphrase {String} used to decrypt userKey and backupKey
* @param params.walletContractAddress {String} the ETH address of the wallet contract
* @param params.recoveryDestination {String} target address to send recovered funds to
* @param params.krsProvider {String} necessary if backup key is held by KRS
* @param params.tokenContractAddress {String} contract address for token to recover
*/
async recover(params: RecoverOptions): Promise<RecoveryInfo | OfflineVaultTxInfo> {
if (_.isUndefined(params.userKey)) {
throw new Error('missing userKey');
}

if (_.isUndefined(params.backupKey)) {
throw new Error('missing backupKey');
}

if (_.isUndefined(params.walletPassphrase) && !params.userKey.startsWith('xpub')) {
throw new Error('missing wallet passphrase');
}

if (_.isUndefined(params.walletContractAddress) || !this.isValidAddress(params.walletContractAddress)) {
throw new Error('invalid walletContractAddress');
}

if (_.isUndefined(params.tokenContractAddress) || !this.isValidAddress(params.tokenContractAddress)) {
throw new Error('invalid tokenContractAddress');
}

if (_.isUndefined(params.recoveryDestination) || !this.isValidAddress(params.recoveryDestination)) {
throw new Error('invalid recoveryDestination');
}

const 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));

// Decrypt private keys from KeyCard values
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 {
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();
}

// Get nonce for backup key (should be 0)
let backupKeyNonce = 0;

const result = await this.recoveryBlockchainExplorerQuery({
module: 'account',
action: 'txlist',
address: backupKeyAddress,
});

const backupKeyTxList = result.result;
if (backupKeyTxList.length > 0) {
// Calculate last nonce used
const outgoingTxs = backupKeyTxList.filter((tx) => tx.from === backupKeyAddress);
backupKeyNonce = outgoingTxs.length;
}

// get balance of backup key and make sure we can afford gas
const backupKeyBalance = await this.queryAddressBalance(backupKeyAddress);

if (backupKeyBalance.lt(gasPrice.mul(gasLimit))) {
throw new Error(
`Backup key address ${backupKeyAddress} has balance ${backupKeyBalance.toString(
10
)}. This address must have a balance of at least 0.01 ETH to perform recoveries`
);
}

// get token balance of wallet
const txAmount = await this.queryAddressTokenBalance(
params.tokenContractAddress as string,
params.walletContractAddress
);

// build recipients object
const recipients = [
{
address: params.recoveryDestination,
amount: txAmount.toString(10),
},
];

// Get sequence ID using contract call
await new Promise((resolve) => setTimeout(resolve, 1000));
const sequenceId = await this.querySequenceId(params.walletContractAddress);

let operationHash, signature;
if (!isUnsignedSweep) {
// Get operation hash and sign it
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),
tokenContractAddress: params.tokenContractAddress,
};

const txBuilder = this.getTransactionBuilder() as EthLikeTransactionBuilder;
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 EthLikeTransferBuilder;
transferBuilder
.coin(this.tokenConfig.type)
.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.tokenConfig.type)
.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(),
};
}

verifyCoin(txPrebuild: TransactionPrebuild): boolean {
return txPrebuild.coin === this.tokenConfig.coin && txPrebuild.token === this.tokenConfig.type;
}
Expand Down
28 changes: 28 additions & 0 deletions modules/abstract-eth/src/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Buffer } from 'buffer';
import request from 'superagent';
import assert from 'assert';
import {
addHexPrefix,
Expand Down Expand Up @@ -788,3 +789,30 @@ export function decodeForwarderCreationData(data: string): ForwarderInitializati
} as const;
}
}

/**
* Make a query to explorer for information such as balance, token balance, solidity calls
* @param {Object} query key-value pairs of parameters to append after /api
* @param {string} token the API token to use for the request
* @param {string} explorerUrl the URL of the explorer
* @returns {Promise<Object>} response from explorer
*/
export async function recoveryBlockchainExplorerQuery(
query: Record<string, string>,
explorerUrl: string,
token?: string
): Promise<Record<string, unknown>> {
if (token) {
query.apikey = token;
}
const response = await request.get(`${explorerUrl}/api`).query(query);

if (!response.ok) {
throw new Error('could not reach explorer');
}

if (response.body.status === '0' && response.body.message === 'NOTOK') {
throw new Error('Explorer rate limit reached');
}
return response.body;
}
3 changes: 1 addition & 2 deletions modules/sdk-coin-arbeth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,7 @@
"@bitgo/utxo-lib": "^9.34.0",
"@ethereumjs/common": "^2.6.5",
"ethereumjs-abi": "^0.6.5",
"ethereumjs-util": "7.1.5",
"superagent": "^3.8.3"
"ethereumjs-util": "7.1.5"
},
"devDependencies": {
"@bitgo/sdk-api": "^1.43.0",
Expand Down
24 changes: 8 additions & 16 deletions modules/sdk-coin-arbeth/src/arbeth.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
/**
* @prettier
*/
import request from 'superagent';
import { BaseCoin, BitGoBase, common } from '@bitgo/sdk-core';
import { BaseCoin as StaticsBaseCoin, coins } from '@bitgo/statics';
import { AbstractEthLikeNewCoins, TransactionBuilder as EthLikeTransactionBuilder } from '@bitgo/abstract-eth';
import {
AbstractEthLikeNewCoins,
TransactionBuilder as EthLikeTransactionBuilder,
recoveryBlockchainExplorerQuery,
} from '@bitgo/abstract-eth';
import { TransactionBuilder } from './lib';

export class Arbeth extends AbstractEthLikeNewCoins {
Expand All @@ -26,19 +29,8 @@ export class Arbeth extends AbstractEthLikeNewCoins {
* @returns {Promise<Object>} response from Arbiscan
*/
async recoveryBlockchainExplorerQuery(query: Record<string, string>): Promise<Record<string, unknown>> {
const token = common.Environments[this.bitgo.getEnv()].arbiscanApiToken;
if (token) {
query.apikey = token;
}
const response = await request.get(common.Environments[this.bitgo.getEnv()].arbiscanBaseUrl + '/api').query(query);

if (!response.ok) {
throw new Error('could not reach Arbiscan');
}

if (response.body.status === '0' && response.body.message === 'NOTOK') {
throw new Error('Arbiscan rate limit reached');
}
return response.body;
const apiToken = common.Environments[this.bitgo.getEnv()].arbiscanApiToken;
const explorerUrl = common.Environments[this.bitgo.getEnv()].arbiscanBaseUrl;
return await recoveryBlockchainExplorerQuery(query, explorerUrl as string, apiToken);
}
}
15 changes: 13 additions & 2 deletions modules/sdk-coin-arbeth/src/arbethToken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
* @prettier
*/
import { EthLikeTokenConfig, coins } from '@bitgo/statics';
import { BitGoBase, CoinConstructor, NamedCoinConstructor } from '@bitgo/sdk-core';
import { CoinNames, EthLikeToken } from '@bitgo/abstract-eth';
import { BitGoBase, CoinConstructor, NamedCoinConstructor, common } from '@bitgo/sdk-core';
import { CoinNames, EthLikeToken, recoveryBlockchainExplorerQuery } from '@bitgo/abstract-eth';

import { TransactionBuilder } from './lib';
export { EthLikeTokenConfig };
Expand All @@ -29,6 +29,17 @@ export class ArbethToken extends EthLikeToken {
return new TransactionBuilder(coins.get(this.getBaseChain()));
}

/**
* Make a query to Arbiscan for information such as balance, token balance, solidity calls
* @param {Object} query key-value pairs of parameters to append after /api
* @returns {Promise<Object>} response from Arbiscan
*/
async recoveryBlockchainExplorerQuery(query: Record<string, string>): Promise<Record<string, unknown>> {
const apiToken = common.Environments[this.bitgo.getEnv()].arbiscanApiToken;
const explorerUrl = common.Environments[this.bitgo.getEnv()].arbiscanBaseUrl;
return await recoveryBlockchainExplorerQuery(query, explorerUrl as string, apiToken);
}

getFullName(): string {
return 'Arbeth Token';
}
Expand Down
6 changes: 6 additions & 0 deletions modules/sdk-coin-arbeth/test/fixtures/arbeth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ export function getTokenBalanceRequest(tokenContractAddress: string, address: st
};
}

export const getTokenBalanceResponse = {
status: '1',
message: 'OK',
result: '9999999999999999948',
};

export const getBalanceResponse = {
status: '1',
message: 'OK',
Expand Down
Loading

0 comments on commit f97e109

Please sign in to comment.