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

Feat/add erc20 support #112

Merged
merged 9 commits into from
Dec 24, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ export const CreateTransaction = ({ asset, inboundRelayParams, setSignTxResponse
(derivation as ERC20).setTokenAddress(asset.address);
(derivation as ERC20).setDecimals(asset.decimals);
(derivation as ERC20).setToAddress(toAddress);
(derivation as ERC20).getNativeAsset(asset.nativeAsset);
}

return await derivation!.prepare?.(toAddress, values.memo);
Expand Down Expand Up @@ -243,18 +244,18 @@ export const CreateTransaction = ({ asset, inboundRelayParams, setSignTxResponse
const balanceId = useId();
const addressExplorerId = useId();

logger.info('Parameters for CreateTransaction ', {
txId,
accountId,
values,
asset,
derivation: sanatize(derivation),
prepare: JSON.stringify(
prepareQuery.data,
(_, v) => (typeof v === 'bigint' ? v.toString() : typeof v === 'function' ? 'function' : v),
2,
),
});
// logger.info('Parameters for CreateTransaction ', {
// txId,
// accountId,
// values,
// asset,
// derivation: sanatize(derivation),
// prepare: JSON.stringify(
// prepareQuery.data,
// (_, v) => (typeof v === 'bigint' ? v.toString() : typeof v === 'function' ? 'function' : v),
// 2,
// ),
// });

return (
<Grid
Expand Down
45 changes: 25 additions & 20 deletions apps/recovery-relay/components/WithdrawModal/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -169,26 +169,31 @@ export const WithdrawModal = () => {
</>
)}
{!!txHash && (
<Typography
variant='body1'
paragraph
sx={{
display: 'flex',
alignItems: 'center',
'& > *': {
marginRight: '0.5rem',
},
}}
>
<Typography variant='body1'>Transaction hash:</Typography>
{asset.getExplorerUrl ? (
<Link href={asset.getExplorerUrl!('tx')(txHash)} target='_blank' rel='noopener noreferrer'>
{txHash}
</Link>
) : (
txHash
)}
</Typography>
<Box>
<Typography
variant='body1'
paragraph
sx={{
display: 'flex',
alignItems: 'center',
'& > *': {
marginRight: '0.5rem',
},
}}
>
<Typography variant='body1'>Transaction Hash:</Typography>
{asset.getExplorerUrl ? (
<Link href={asset.getExplorerUrl!('tx')(txHash)} target='_blank' rel='noopener noreferrer'>
{txHash}
</Link>
) : (
txHash
)}
</Typography>
<Typography variant='body1'>
The transaction might take a few seconds to appear on the block explorer
</Typography>
</Box>
)}
{!!txBroadcastError && (
<Typography variant='body1' fontWeight='600' color={(theme) => theme.palette.error.main}>
Expand Down
34 changes: 34 additions & 0 deletions apps/recovery-relay/lib/wallets/ERC20/chains.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
export function getChainId(nativeAsset: string): number | undefined {
switch (nativeAsset) {
case 'ETH':
return 1;
case 'BNB_BSC':
return 56;
case 'CHZ_$CHZ':
return 88888;
case 'CELO':
return 42220;
case 'RBTC':
return 30;
case 'AVAX':
return 43114;
case 'MATIC_POLYGON':
return 137;
case 'RON':
return 2020;
case 'ETH_TEST5':
return 11155111;
case 'ETH_TEST6':
return 17000;
case 'SMARTBCH':
return 10000;
case 'ETH-AETH':
return 42161;
case 'BNB_TEST':
return 97;
case 'FTM_FANTOM':
return 250;
default:
return undefined;
}
}
99 changes: 42 additions & 57 deletions apps/recovery-relay/lib/wallets/ERC20/index.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,31 @@
/* eslint-disable prefer-destructuring */
import { Contract, ethers, formatEther, JsonRpcProvider } from 'ethers';
import { Contract, ethers, JsonRpcProvider } from 'ethers';
import { AccountData } from '../types';
import { ConnectedWallet } from '../ConnectedWallet';
import { EVM } from '../EVM';
import { EVMWallet as EVMBase } from '@fireblocks/wallet-derivation';
import { erc20Abi } from './erc20.abi';
import { getChainId } from './chains';

export class ERC20 extends EVM implements ConnectedWallet {
export class ERC20 extends EVMBase implements ConnectedWallet {
protected provider: JsonRpcProvider | undefined;
public rpcURL: string | undefined;
public contract!: Contract;
public tokenAddress: string | undefined;
public decimals: number | undefined;
public toAddress: string | undefined;
private normalizingFactor: bigint | undefined;
private chainId: number | undefined;

public getNativeAsset(nativeAsset: string) {
this.chainId = getChainId(nativeAsset);
if (!this.chainId) {
throw new Error('Unrecognaized native asset for ERC20 token withdrawal');
}
}

public setRPCUrl(url: string): void {
this.rpcURL = url;
this.provider = new JsonRpcProvider(this.rpcURL);
this.provider = new JsonRpcProvider(this.rpcURL, this.chainId, { cacheTimeout: -1 });
}

public setTokenAddress(address: string) {
Expand All @@ -32,29 +42,29 @@ export class ERC20 extends EVM implements ConnectedWallet {

public setDecimals(decimals: number) {
this.decimals = decimals;
this.normalizingFactor = BigInt(10 ** decimals);
}

public setToAddress(toAddress: string) {
this.toAddress = toAddress;
}

public async getBalance(): Promise<number> {
const weiBalance = await this.contract.balanceOf(this.address);
return parseFloat(parseFloat(ethers.formatEther(weiBalance)).toFixed(2));
const weiBalance: bigint = await this.contract.balanceOf(this.address);
return Number(weiBalance / this.normalizingFactor!);
}

public async prepare(): Promise<AccountData> {
this.init();
const nonce = await this.provider!.getTransactionCount(this.address, 'latest');
const chainId = (await this.provider!.getNetwork()).chainId;

const displayBalance = await this.getBalance();
const ethBalance = await this.getEthBalance();

let { gasPrice, maxFeePerGas, maxPriorityFeePerGas } = await this.provider!.getFeeData();
const { gasPrice, maxFeePerGas, maxPriorityFeePerGas } = await this.provider!.getFeeData();

const iface = new ethers.Interface(erc20Abi);
const data = iface.encodeFunctionData('transfer', [this.toAddress, ethers.parseUnits(displayBalance.toFixed(2), 'ether')]);
const data = iface.encodeFunctionData('transfer', [this.toAddress, BigInt(displayBalance) * this.normalizingFactor!]);

const tx = {
to: this.tokenAddress,
Expand All @@ -63,69 +73,44 @@ export class ERC20 extends EVM implements ConnectedWallet {
};
const gasLimit = await this.provider?.estimateGas(tx);

const extraParams = new Map();
const extraParams = new Map<string, any>();
extraParams.set('tokenAddress', this.tokenAddress);
extraParams.set('gasLimit', gasLimit);
extraParams.set('maxFee', maxFeePerGas);
extraParams.set('priorityFee', maxPriorityFeePerGas);
extraParams.set('gasLimit', gasLimit?.toString());
extraParams.set('maxFee', maxFeePerGas?.toString());
extraParams.set('priorityFee', maxPriorityFeePerGas?.toString());
extraParams.set('weiBalance', (BigInt(displayBalance) * this.normalizingFactor!).toString());

const preparedData: AccountData = {
balance: displayBalance,
extraParams,
gasPrice,
nonce,
chainId: Number(chainId),
chainId: this.chainId,
insufficientBalance: displayBalance <= 0,
insufficientBalanceForTokenTransfer: ethBalance <= gasPrice! * gasLimit!,
insufficientBalanceForTokenTransfer: Number(ethBalance!) <= Number(gasPrice! * gasLimit!),
};
this.relayLogger.logPreparedData('ERC20', preparedData);
return preparedData;
}

// public async generateTx(to: string, amount: number): Promise<TxPayload> {
// const nonce = await this.provider!.getTransactionCount(this.address, 'latest');

// // Should we use maxGasPrice? i.e. EIP1559.
// const { gasPrice } = await this.provider!.getFeeData();

// const tx = {
// from: this.address,
// to,
// nonce,
// gasLimit: 21000,
// gasPrice,
// value: 0,
// chainId: this.path.coinType === 1 ? 5 : 1,
// data: new Interface(transferAbi).encodeFunctionData('transfer', [
// to,
// BigInt(amount) * BigInt(await this.contract.decimals()),
// ]),
// };

// this.relayLogger.debug(`ERC20: Generated tx: ${JSON.stringify(tx, (_, v) => (typeof v === 'bigint' ? v.toString() : v), 2)}`);

// const unsignedTx = Transaction.from(tx).serialized;

// const preparedData = {
// derivationPath: this.pathParts,
// tx: unsignedTx,
// };

// this.relayLogger.debug(`ERC20: Prepared data: ${JSON.stringify(preparedData, null, 2)}`);
// return preparedData;
// }

public async broadcastTx(txHex: string): Promise<string> {
return super.broadcastTx(txHex);
try {
const txRes = await this.provider!.broadcastTransaction(txHex);
this.relayLogger.debug(`EVM: Tx broadcasted: ${JSON.stringify(txRes, null, 2)}`);
return txRes.hash;
} catch (e) {
this.relayLogger.error('EVM: Error broadcasting tx:', e);
if ((e as Error).message.includes('insufficient funds for intrinsic transaction cost')) {
throw new Error(
'Insufficient funds for transfer, this might be due to a spike in network fees, please wait and try again',
);
}
throw e;
}
}

private async getEthBalance() {
const weiBalance = await this.provider?.getBalance(this.address);
const balance = formatEther(weiBalance!);
const ethBalance = Number(balance);

console.info('Eth balance info', { ethBalance });

return ethBalance;
const weiBalanceBN = await this.provider?.getBalance(this.address);
console.info('Eth balance info', { weiBalanceBN });
return weiBalanceBN;
}
}
36 changes: 17 additions & 19 deletions apps/recovery-utility/renderer/lib/wallets/ERC20/index.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,36 @@
import { ethers, Wallet } from 'ethers';
import { EVMWallet as EVMBase, Input } from '@fireblocks/wallet-derivation';
import { EVMWallet as EVMBase } from '@fireblocks/wallet-derivation';
import { TxPayload, GenerateTxInput } from '../types';
import { SigningWallet } from '../SigningWallet';
import { erc20Abi } from './erc20.abi';

export class ERC20 extends EVMBase implements SigningWallet {
constructor(input: Input, chainId?: number) {
super(input);
}

public async generateTx({ to, amount, extraParams, gasPrice, nonce, chainId }: GenerateTxInput): Promise<TxPayload> {
public async generateTx({ to, extraParams, nonce, chainId, gasPrice }: GenerateTxInput): Promise<TxPayload> {
if (!this.privateKey) {
throw new Error('No private key found');
}

const balanceWei = ethers.parseUnits(amount.toFixed(2), 'ether');
const balanceWei = BigInt(extraParams?.get('weiBalance'));

const tokenAddress = extraParams?.get('tokenAddress');
const maxPriorityFeePerGas = extraParams?.get('priorityFee');
const maxFeePerGas = extraParams?.get('maxFee');
const gasLimit = extraParams?.get('gasLimit');

const maxPriorityFeePerGas = (BigInt(extraParams?.get('priorityFee')) * 115n) / 100n; //increase priority fee by 15% to increase chance of tx to be included in next block
const maxFeePerGas = BigInt(extraParams?.get('maxFee'));
const gasLimit = BigInt(extraParams?.get('gasLimit'));

const iface = new ethers.Interface(erc20Abi);
const data = iface.encodeFunctionData('transfer', [to, balanceWei]);

const txObject = {
to: tokenAddress,
data,
nonce,
gasLimit,
maxFeePerGas,
maxPriorityFeePerGas,
};
let txObject = {};
// EIP-1559 chain
if (maxFeePerGas && maxPriorityFeePerGas) {
txObject = { to: tokenAddress, data, nonce, gasLimit, maxFeePerGas, maxPriorityFeePerGas, chainId };
// non EIP-1559 chain
} else {
txObject = { to: tokenAddress, data, nonce, gasLimit, gasPrice, chainId };
}

this.utilityLogger.logSigningTx('EVM', txObject);
this.utilityLogger.logSigningTx('ERC20', txObject);

const serialized = await new Wallet(this.privateKey).signTransaction(txObject);

Expand Down
5 changes: 3 additions & 2 deletions apps/recovery-utility/renderer/lib/wallets/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// import { Bitcoin } from './BTC';
import { assets, getAllJettons } from '@fireblocks/asset-config';
import { ERC20, ETC } from '@fireblocks/wallet-derivation';
import { ETC } from '@fireblocks/wallet-derivation';
import { Ripple } from './XRP';
import { Cosmos } from './ATOM';
import { EOS } from './EOS';
Expand All @@ -22,6 +22,7 @@ import { Bitcoin, BitcoinSV, LiteCoin, Dash, ZCash, Doge } from './BTC';
import { Celestia } from './CELESTIA';
import { Ton } from './TON';
import { Jetton } from './Jetton';
import { ERC20 } from './ERC20';

const fillEVMs = () => {
const evms = Object.keys(assets).reduce(
Expand Down Expand Up @@ -89,6 +90,7 @@ export const WalletClasses = {
LUNA2_TEST: Luna,
CELESTIA: Celestia,
CELESTIA_TEST: Celestia,
...fillEVMs(),

// EDDSA
SOL: Solana,
Expand All @@ -110,7 +112,6 @@ export const WalletClasses = {
TON_TEST: Ton,

...fillJettons(),
...fillEVMs(),
} as const;

type WalletClass = (typeof WalletClasses)[keyof typeof WalletClasses];
Expand Down
2 changes: 1 addition & 1 deletion packages/asset-config/config/patches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ export const nativeAssetPatches: NativeAssetPatches = {
},
ETC: evm('blockscout.com/etc/mainnet', 'https://geth-de.etc-network.info'),
ETC_TEST: evm('blockscout.com/etc/kotti', 'https://geth-mordor.etc-network.info'),
ETH: evm('etherscan.io', 'https://mainnet.infura.io/v3/9aa3d95b3bc440fa88ea12eaa4456161'),
ETH: evm('etherscan.io', 'https://eth-mainnet.public.blastapi.io'),
ETH_TEST3: evm('goerli.etherscan.io', 'https://goerli.infura.io/v3/9aa3d95b3bc440fa88ea12eaa4456161'),
ETH_TEST5: evm('sepolia.etherscan.io', 'https://sepolia.infura.io/v3/9aa3d95b3bc440fa88ea12eaa4456161'),
ETH_TEST6: evm('holesky.etherscan.io', 'https://ethereum-holesky-rpc.publicnode.com'),
Expand Down
Loading