Skip to content

Commit

Permalink
feat(sdk-coin-avaxc): add avaxc support for evm ccr
Browse files Browse the repository at this point in the history
  • Loading branch information
nrjsuthar committed Sep 25, 2024
1 parent 23c046b commit c48ecda
Show file tree
Hide file tree
Showing 3 changed files with 359 additions and 3 deletions.
4 changes: 3 additions & 1 deletion modules/sdk-coin-avaxc/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@
"keccak": "^3.0.3",
"lodash": "^4.17.14",
"secp256k1": "5.0.0",
"superagent": "^9.0.1"
"superagent": "^9.0.1",
"@bitgo/abstract-eth": "^22.1.1",
"bn.js": "^5.0.0"
},
"devDependencies": {
"@bitgo/sdk-api": "^1.54.2",
Expand Down
350 changes: 350 additions & 0 deletions modules/sdk-coin-avaxc/src/EthLikeAvaxc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,350 @@
/**
* @prettier
*/
import { BaseCoin, BitGoBase, common, HalfSignedTransaction, Recipient } from '@bitgo/sdk-core';
import { BaseCoin as StaticsBaseCoin, coins, ethGasConfigs } from '@bitgo/statics';
import {
AbstractEthLikeNewCoins,
FeesUsed,
KeyPair as KeyPairLib,
OfflineVaultTxInfo,
optionalDeps,
RecoverOptions,
RecoveryInfo,
TransactionBuilder as EthLikeTransactionBuilder,
TransferBuilder,
} from '@bitgo/abstract-eth';
import { TransactionBuilder } from './lib';
import request from 'superagent';
import BN from 'bn.js';
import { Buffer } from 'buffer';
import { BigNumber } from 'bignumber.js';
import _ from 'lodash';

export class EthLikeAvaxc extends AbstractEthLikeNewCoins {
protected constructor(bitgo: BitGoBase, staticsCoin?: Readonly<StaticsBaseCoin>) {
super(bitgo, staticsCoin);
}

static createInstance(bitgo: BitGoBase, staticsCoin?: Readonly<StaticsBaseCoin>): BaseCoin {
return new EthLikeAvaxc(bitgo, staticsCoin);
}

protected getTransactionBuilder(): EthLikeTransactionBuilder {
return new TransactionBuilder(coins.get(this.getBaseChain()));
}

setGasLimit(userGasLimit?: number): number {
if (!userGasLimit) {
return ethGasConfigs.defaultGasLimit;
}
const gasLimitMax = ethGasConfigs.maximumGasLimit;
const gasLimitMin = ethGasConfigs.minimumGasLimit;
if (userGasLimit < gasLimitMin || userGasLimit > gasLimitMax) {
throw new Error(`Gas limit must be between ${gasLimitMin} and ${gasLimitMax}`);
}
return userGasLimit;
}

setGasPrice(userGasPrice?: number): number {
if (!userGasPrice) {
return ethGasConfigs.defaultGasPrice;
}

const gasPriceMax = ethGasConfigs.maximumGasPrice;
const gasPriceMin = ethGasConfigs.minimumGasPrice;
if (userGasPrice < gasPriceMin || userGasPrice > gasPriceMax) {
throw new Error(`Gas price must be between ${gasPriceMin} and ${gasPriceMax}`);
}
return userGasPrice;
}

async recoveryBlockchainExplorerQuery(query: Record<string, any>): Promise<any> {
const env = this.bitgo.getEnv();
const response = await request.post(common.Environments[env].avaxcNetworkBaseUrl + '/ext/bc/C/rpc').send(query);
if (!response.ok) {
throw new Error('could not reach avax.network');
}

if (response.body.status === '0' && response.body.message === 'NOTOK') {
throw new Error('avax.network rate limit reached');
}
return response.body;
}

async getAddressNonce(address: string): Promise<number> {
// Get nonce for backup key (should be 0)
const result = await this.recoveryBlockchainExplorerQuery({
jsonrpc: '2.0',
method: 'eth_getTransactionCount',
params: [address, 'latest'],
id: 1,
});
if (!result || isNaN(result.result)) {
throw new Error('Unable to find next nonce from avax.network, got: ' + JSON.stringify(result));
}
const nonceHex = result.result;
return new optionalDeps.ethUtil.BN(nonceHex.slice(2), 16).toNumber();
}

async queryAddressBalance(address: string): Promise<BN> {
const result = await this.recoveryBlockchainExplorerQuery({
jsonrpc: '2.0',
method: 'eth_getBalance',
params: [address, 'latest'],
id: 1,
});
// 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 balance for ${address} from avax.network, got: ${result.result}`);
}
const nativeBalanceHex = result.result;
return new optionalDeps.ethUtil.BN(nativeBalanceHex.slice(2), 16);
}

async querySequenceId(address: string): Promise<number> {
// Get sequence ID using contract call
const sequenceIdMethodSignature = optionalDeps.ethAbi.methodID('getNextSequenceId', []);
const sequenceIdArgs = optionalDeps.ethAbi.rawEncode([], []);
const sequenceIdData = Buffer.concat([sequenceIdMethodSignature, sequenceIdArgs]).toString('hex');
const sequenceIdDataHex = optionalDeps.ethUtil.addHexPrefix(sequenceIdData);
const result = await this.recoveryBlockchainExplorerQuery({
jsonrpc: '2.0',
method: 'eth_call',
params: [{ to: address, data: sequenceIdDataHex }, 'latest'],
id: 1,
});
if (!result || !result.result) {
throw new Error('Could not obtain sequence ID from avax.network, got: ' + result.result);
}
const sequenceIdHex = result.result;
return new optionalDeps.ethUtil.BN(sequenceIdHex.slice(2), 16).toNumber();
}

protected async recoverEthLikeforEvmBasedRecovery(
params: RecoverOptions
): Promise<RecoveryInfo | OfflineVaultTxInfo> {
this.validateEvmBasedRecoveryParams(params);

// Clean up whitespace from entered values
const userKey = params.userKey.replace(/\s/g, '');
const bitgoFeeAddress = params.bitgoFeeAddress?.replace(/\s/g, '').toLowerCase() as string;
const bitgoDestinationAddress = params.bitgoDestinationAddress?.replace(/\s/g, '').toLowerCase() as string;
const recoveryDestination = params.recoveryDestination?.replace(/\s/g, '').toLowerCase() as string;
const walletContractAddress = params.walletContractAddress?.replace(/\s/g, '').toLowerCase() as string;
const tokenContractAddress = params.tokenContractAddress?.replace(/\s/g, '').toLowerCase() as string;

let userSigningKey;
let userKeyPrv;
if (params.walletPassphrase) {
if (!userKey.startsWith('xpub') && !userKey.startsWith('xprv')) {
try {
userKeyPrv = this.bitgo.decrypt({
input: userKey,
password: params.walletPassphrase,
});
} catch (e) {
throw new Error(`Error decrypting user keychain: ${e.message}`);
}
}

const keyPair = new KeyPairLib({ prv: userKeyPrv });
userSigningKey = keyPair.getKeys().prv;
if (!userSigningKey) {
throw new Error('no private key');
}
}

// Use default gasLimit for cold and custody wallets
let gasLimit =
params.gasLimit || userKey.startsWith('xpub') || !userKey
? new optionalDeps.ethUtil.BN(this.setGasLimit(params.gasLimit))
: new optionalDeps.ethUtil.BN(0);

const gasPrice = await this.getGasPriceFromExternalAPI();
const bitgoFeeAddressNonce = await this.getAddressNonce(bitgoFeeAddress);

if (tokenContractAddress) {
return this.recoverEthLikeTokenforEvmBasedRecovery(
params,
bitgoFeeAddressNonce,
gasLimit,
gasPrice,
userKey,
userSigningKey
);
}

// get balance of wallet
const txAmount = await this.queryAddressBalance(walletContractAddress);

const bitgoFeePercentage = 0; // TODO: BG-71912 can change the fee% here.
const bitgoFeeAmount = txAmount.toNumber() * (bitgoFeePercentage / 100);

// build recipients object
const recipients: Recipient[] = [
{
address: recoveryDestination,
amount: new BigNumber(txAmount.toNumber()).minus(bitgoFeeAmount).toFixed(),
},
];

if (bitgoFeePercentage > 0) {

Check warning

Code scanning / CodeQL

Useless comparison test Warning

The condition 'bitgoFe ... age > 0' is always false.
if (_.isUndefined(bitgoDestinationAddress) || !this.isValidAddress(bitgoDestinationAddress)) {
throw new Error('invalid bitgoDestinationAddress');
}

recipients.push({
address: bitgoDestinationAddress,
amount: bitgoFeeAmount.toString(10),
});
}

// calculate batch data
const BATCH_METHOD_NAME = 'batch';
const BATCH_METHOD_TYPES = ['address[]', 'uint256[]'];
const batchExecutionInfo = this.getBatchExecutionInfo(recipients);
const batchData = optionalDeps.ethUtil.addHexPrefix(
this.getMethodCallData(BATCH_METHOD_NAME, BATCH_METHOD_TYPES, batchExecutionInfo.values).toString('hex')
);

// 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(walletContractAddress);

const network = this.getNetwork();
const batcherContractAddress = network?.batcherContractAddress as string;

const txBuilder = this.getTransactionBuilder() as TransactionBuilder;
txBuilder.counter(bitgoFeeAddressNonce);
txBuilder.contract(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;
if (!batcherContractAddress) {
transferBuilder
.coin(this.staticsCoin?.name as string)
.amount(batchExecutionInfo.totalAmount)
.contractSequenceId(sequenceId)
.expirationTime(this.getDefaultExpireTime())
.to(recoveryDestination);
} else {
transferBuilder
.coin(this.staticsCoin?.name as string)
.amount(batchExecutionInfo.totalAmount)
.contractSequenceId(sequenceId)
.expirationTime(this.getDefaultExpireTime())
.to(batcherContractAddress)
.data(batchData);
}

if (params.walletPassphrase) {
transferBuilder.key(userSigningKey);
}

// If the intended chain is arbitrum or optimism, we need to use wallet version 4
// since these contracts construct operationHash differently
if (params.intendedChain && ['arbeth', 'opeth'].includes(coins.get(params.intendedChain).family)) {
txBuilder.walletVersion(4);
}

// If gasLimit was not passed as a param, then fetch the gasLimit from Explorer
if (!params.gasLimit && !userKey.startsWith('xpub')) {
// TODO Neeraj : Fix This
const sendData = txBuilder.getSendData();
gasLimit = await this.getGasLimitFromExternalAPI(
params.bitgoFeeAddress as string,
params.walletContractAddress,
sendData
);
txBuilder.fee({
...txFee,
gasLimit: gasLimit.toString(),
});
}

// Get the balance of bitgoFeeAddress to ensure funds are available to pay fees
await this.ensureSufficientBalance(bitgoFeeAddress, gasPrice, gasLimit);

const tx = await txBuilder.build();

const txInfo = {
recipients: recipients,
expireTime: this.getDefaultExpireTime(),
contractSequenceId: sequenceId,
gasLimit: gasLimit.toString(10),
isEvmBasedCrossChainRecovery: true,
};

const response: OfflineVaultTxInfo = {
txHex: tx.toBroadcastFormat(),
userKey,
coin: this.getChain(),
gasPrice: optionalDeps.ethUtil.bufferToInt(gasPrice).toFixed(),
gasLimit,
recipients: txInfo.recipients,
walletContractAddress: tx.toJson().to,
amount: batchExecutionInfo.totalAmount,
backupKeyNonce: bitgoFeeAddressNonce,
eip1559: params.eip1559,
};
_.extend(response, txInfo);
response.nextContractSequenceId = response.contractSequenceId;

if (params.walletPassphrase) {
const halfSignedTxn: HalfSignedTransaction = {
halfSigned: {
txHex: tx.toBroadcastFormat(),
recipients: txInfo.recipients,
expireTime: txInfo.expireTime,
},
};
_.extend(response, halfSignedTxn);

const feesUsed: FeesUsed = {
gasPrice: optionalDeps.ethUtil.bufferToInt(gasPrice).toFixed(),
gasLimit: optionalDeps.ethUtil.bufferToInt(gasLimit).toFixed(),
};
response['feesUsed'] = feesUsed;
}

return response;
}

async getGasPriceFromExternalAPI(): Promise<BN> {
try {
// COIN -1708 : hardcoded for half signing
const gasPrice = new BN(250000);
console.log(` Got hardcoded gas price: ${gasPrice}`);
return gasPrice;
} catch (e) {
throw new Error('Failed to get gas price');
}
}

async getGasLimitFromExternalAPI(from: string, to: string, data: string): Promise<BN> {
try {
// COIN -1708 : hardcoded for half signing
const gasLimit = new BN(250000);
console.log(`Got hardcoded gas limit: ${gasLimit}`);
return gasLimit;
} catch (e) {
throw new Error('Failed to get gas limit: ');
}
}
}
8 changes: 6 additions & 2 deletions modules/sdk-coin-avaxc/src/avaxc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,20 +62,21 @@ import {
VerifyAvaxcTransactionOptions,
} from './iface';
import { AvaxpLib } from '@bitgo/sdk-coin-avaxp';
import { EthLikeAvaxc } from './EthLikeAvaxc';

export class AvaxC extends BaseCoin {
static hopTransactionSalt = 'bitgoHopAddressRequestSalt';

protected readonly _staticsCoin: Readonly<StaticsBaseCoin>;
private ethLikeAvaxc: any;

protected constructor(bitgo: BitGoBase, staticsCoin?: Readonly<StaticsBaseCoin>) {
super(bitgo);

if (!staticsCoin) {
throw new Error('missing required constructor parameter staticsCoin');
}

this._staticsCoin = staticsCoin;
this.ethLikeAvaxc = EthLikeAvaxc.createInstance(bitgo, staticsCoin);
}

static createInstance(bitgo: BitGoBase, staticsCoin?: Readonly<StaticsBaseCoin>): BaseCoin {
Expand Down Expand Up @@ -578,6 +579,9 @@ export class AvaxC extends BaseCoin {
throw new Error('invalid recoveryDestination');
}

// COIN-1708 : Recover method to be invoked from WRW
if (params.bitgoFeeAddress) return this.ethLikeAvaxc.recoverEthLikeforEvmBasedRecovery(params);

// TODO (BG-56531): add support for krs
const isUnsignedSweep = getIsUnsignedSweep(params);

Expand Down

0 comments on commit c48ecda

Please sign in to comment.