Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Win 2117 fix recover token method #4302

Merged
merged 5 commits into from
Feb 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading