Skip to content

Commit

Permalink
feat(@fireblocks/recovery-utility): ✨ add erc20 withdrawal support
Browse files Browse the repository at this point in the history
  • Loading branch information
TomerHFB authored and a0ngo committed Dec 24, 2024
1 parent 213987a commit 24446ee
Show file tree
Hide file tree
Showing 9 changed files with 87 additions and 102 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,6 @@ export const CreateTransaction = ({ asset, inboundRelayParams, setSignTxResponse
logger.error(`Unknown URL for ${derivation?.assetId ?? '<empty>'}`);
throw new Error(`No RPC Url for: ${derivation?.assetId}`);
}
if (rpcUrl !== null) derivation!.setRPCUrl(rpcUrl);
if (asset.address && asset.protocol === 'TON') {
(derivation as Jetton).setTokenAddress(asset.address);
(derivation as Jetton).setDecimals(asset.decimals);
Expand All @@ -177,8 +176,9 @@ 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);
(derivation as ERC20).setNativeAsset(asset.nativeAsset);
}
if (rpcUrl !== null) derivation!.setRPCUrl(rpcUrl); // this must remain the last method called on derivation for ERC20 support

return await derivation!.prepare?.(toAddress, values.memo);
},
Expand Down Expand Up @@ -244,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
14 changes: 8 additions & 6 deletions apps/recovery-relay/components/WithdrawModal/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import { useWorkspace } from '../../context/Workspace';
import { CreateTransaction, getAssetURL } from './CreateTransaction';
import { LateInitConnectedWallet } from '../../lib/wallets/LateInitConnectedWallet';
import { useSettings } from '../../context/Settings';
import { getAssetConfig, isTransferableToken } from '@fireblocks/asset-config/util';
import { ERC20 } from '../../lib/wallets/ERC20';

const logger = getLogger(LOGGER_NAME_RELAY);

Expand Down Expand Up @@ -123,15 +125,15 @@ export const WithdrawModal = () => {
disabled={process.env.CI === 'e2e' ? false : txBroadcastError !== undefined}
onClick={async () => {
logger.info('Inbound parameters:', { inboundRelayParams });

const wallet = accounts
.get(inboundRelayParams?.accountId)
?.wallets.get(inboundRelayParams?.signedTx.assetId);
const assetId = inboundRelayParams?.signedTx.assetId;
const wallet = accounts.get(inboundRelayParams?.accountId)?.wallets.get(assetId);

const derivation = wallet?.derivations?.get(
getDerivationMapKey(inboundRelayParams?.signedTx.assetId, inboundRelayParams?.signedTx.from),
getDerivationMapKey(assetId, inboundRelayParams?.signedTx.from),
);

if (isTransferableToken(assetId) && derivation instanceof ERC20) {
(derivation as ERC20).setNativeAsset(getAssetConfig(assetId)!.nativeAsset);
}
const rpcUrl = getAssetURL(derivation?.assetId ?? '', RPCs);
if (rpcUrl === undefined) {
throw new Error(`No RPC URL for asset ${derivation?.assetId}`);
Expand Down
2 changes: 1 addition & 1 deletion apps/recovery-relay/lib/defaultRPCs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export const defaultRPCs: Record<
allowedEmptyValue: false,
},
ETH: {
url: 'https://mainnet.infura.io/v3/9aa3d95b3bc440fa88ea12eaa4456161',
url: 'https://eth-mainnet.public.blastapi.io',
name: 'Ethereum',
enabled: true,
allowedEmptyValue: false,
Expand Down
34 changes: 0 additions & 34 deletions apps/recovery-relay/lib/wallets/ERC20/chains.ts

This file was deleted.

86 changes: 44 additions & 42 deletions apps/recovery-relay/lib/wallets/ERC20/index.ts
Original file line number Diff line number Diff line change
@@ -1,70 +1,83 @@
/* eslint-disable prefer-destructuring */
import { Contract, ethers, JsonRpcProvider } from 'ethers';
import { Contract, ethers, JsonRpcProvider, parseUnits } from 'ethers';
import { AccountData } from '../types';
import { ConnectedWallet } from '../ConnectedWallet';
import { EVMWallet as EVMBase } from '@fireblocks/wallet-derivation';
import { Input, EVMWallet as EVMBase } from '@fireblocks/wallet-derivation';
import { erc20Abi } from './erc20.abi';
import { getChainId } from './chains';
import { WalletClasses } from '..';
import { EVM } from '../EVM';

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

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

public setRPCUrl(url: string): void {
this.rpcURL = url;
this.provider = new JsonRpcProvider(this.rpcURL, this.chainId, { cacheTimeout: -1 });
public setRPCUrl(url: string) {
this.backendWallet?.setRPCUrl(url);
//@ts-ignore
this.provider = this.backendWallet?.provider;
}

public setTokenAddress(address: string) {
this.tokenAddress = address;
}

public setNativeAsset(nativeAsset: String) {
this.backendWallet = new WalletClasses[nativeAsset as keyof typeof WalletClasses]({ ...this.input, assetId: nativeAsset });
}

public init() {
if (!this.tokenAddress) {
this.relayLogger.error(`ERC20 Token address unavailable: ${this.assetId}`);
throw new Error(`ERC20 Token address unavailable: ${this.assetId}`);
}

this.contract = new ethers.Contract(this.tokenAddress, erc20Abi, this.provider);

if (!this.contract) {
this.relayLogger.error(`ERC20 Token contract is undefined`);
throw new Error(`ERC20 Token contract is undefined`);
}
}

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

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

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

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

const displayBalance = await this.getBalance();
const ethBalance = await this.getEthBalance();
const ethBalance = await this.backendWallet?.getBalance();
if (!ethBalance) {
this.relayLogger.error(`Fee asset balance not available`);
throw new Error(`Fee asset balance not available`);
}
const ethBalanceInWei = parseUnits(ethBalance.toString(), 'ether');

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

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

const tx = {
to: this.tokenAddress,
Expand All @@ -76,41 +89,30 @@ export class ERC20 extends EVMBase implements ConnectedWallet {
const extraParams = new Map<string, any>();
extraParams.set('tokenAddress', this.tokenAddress);
extraParams.set('gasLimit', gasLimit?.toString());
extraParams.set('gasPrice', gasPrice?.toString());
extraParams.set('maxFee', maxFeePerGas?.toString());
extraParams.set('priorityFee', maxPriorityFeePerGas?.toString());
extraParams.set('weiBalance', (BigInt(displayBalance) * this.normalizingFactor!).toString());
extraParams.set('weiBalance', (BigInt(displayBalance) * BigInt(this.normalizingFactor!)).toString());

const preparedData: AccountData = {
balance: displayBalance,
extraParams,
gasPrice,
nonce,
chainId: this.chainId,
//@ts-ignore
chainId: this.backendWallet?.chainId,
insufficientBalance: displayBalance <= 0,
insufficientBalanceForTokenTransfer: Number(ethBalance!) <= Number(gasPrice! * gasLimit!),
insufficientBalanceForTokenTransfer: Number(ethBalanceInWei!) <= Number(gasPrice! * gasLimit!),
};
return preparedData;
}

public async broadcastTx(txHex: string): Promise<string> {
public async broadcastTx(tx: string): Promise<string> {
try {
const txRes = await this.provider!.broadcastTransaction(txHex);
this.relayLogger.debug(`EVM: Tx broadcasted: ${JSON.stringify(txRes, null, 2)}`);
return txRes.hash;
//@ts-ignore
return await this.backendWallet.broadcastTx(tx);
} 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;
this.relayLogger.error('ERC20: Error broadcasting tx:', e);
throw new Error(`ERC20: Error broadcasting tx: ${e}`);
}
}

private async getEthBalance() {
const weiBalanceBN = await this.provider?.getBalance(this.address);
console.info('Eth balance info', { weiBalanceBN });
return weiBalanceBN;
}
}
7 changes: 4 additions & 3 deletions apps/recovery-relay/lib/wallets/EVM/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { JsonRpcProvider, formatEther, parseEther } from 'ethers';
import { JsonRpcProvider, formatEther } from 'ethers';
import { EVMWallet as EVMBase, Input } from '@fireblocks/wallet-derivation';
import { AccountData } from '../types';
import { ConnectedWallet } from '../ConnectedWallet';
Expand All @@ -7,11 +7,11 @@ import BigNumber from 'bignumber.js';
export class EVM extends EVMBase implements ConnectedWallet {
protected provider: JsonRpcProvider | undefined;

protected weiBalance: bigint = BigInt(0);
protected weiBalance: bigint | string | undefined = BigInt(0);

public rpcURL: string | undefined;

constructor(input: Input, private chainId?: number) {
constructor(input: Input, protected chainId?: number) {
super(input);

this.relayLogger.info('Creating EVM wallet:', { chainId, input });
Expand Down Expand Up @@ -52,6 +52,7 @@ export class EVM extends EVMBase implements ConnectedWallet {
}

const gas = gasPrice * 21000n;
//@ts-ignore
const balance = new BigNumber(this.weiBalance.toString());

const adjustedBalance = balance.minus(new BigNumber(gas.toString()));
Expand Down
3 changes: 2 additions & 1 deletion apps/recovery-utility/renderer/lib/wallets/ERC20/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { SigningWallet } from '../SigningWallet';
import { erc20Abi } from './erc20.abi';

export class ERC20 extends EVMBase implements SigningWallet {
public async generateTx({ to, extraParams, nonce, chainId, gasPrice }: GenerateTxInput): Promise<TxPayload> {
public async generateTx({ to, extraParams, nonce, chainId }: GenerateTxInput): Promise<TxPayload> {
if (!this.privateKey) {
throw new Error('No private key found');
}
Expand All @@ -17,6 +17,7 @@ export class ERC20 extends EVMBase implements SigningWallet {
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 gasPrice = BigInt(extraParams?.get('gasPrice'));

const iface = new ethers.Interface(erc20Abi);
const data = iface.encodeFunctionData('transfer', [to, balanceWei]);
Expand Down
6 changes: 6 additions & 0 deletions packages/asset-config/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ For your convinience we have provided base methods for common types of chains:
- `evm(baseExplorerUrl: string, rpcUrl?: string)` to create a basic EVM chain, simply provide the `baseExplorerUrl` (the URL of an explorer) and optionally `rpcUrl` as the URL of the RPC to communicate with
- `btc(baseExplorerUrl: string, segwit: boolean)` to create a basic BTC chain (ZCash, LTC, etc are all considered such) simply provide the `baseExplorerUrl` (the URL of an explorer) and optionally `segwit` should be false, as only BTC is relevant for this field

### Add a new ERC20 token

To add support for withdrawals of a listed ERC20 on supported EVM chain, make sure the token is listed in `globalAssets.ts`.
The token contract address must be present in the `globalAssets` list as the `address` parameter.
Note: the tool will support ERC20 token withdrawals only on EVM chains that has withdrawal support for the base asset as well.

### Add a new Jetton token

To add support for withdrawals of a listed Jetton, make sure the token is listed in `globalAssets.ts`.
Expand Down
9 changes: 8 additions & 1 deletion packages/shared/lib/sanatize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,14 @@ export const sanatize = (value: any, depth = 0): any => {
return;
}
const typeOf = typeof value[key];
sanatized[key] = typeOf === 'object' ? sanatize(value[key], depth + 1) : typeOf === 'function' ? '[Function]' : value[key];
sanatized[key] =
typeOf === 'object'
? sanatize(value[key], depth + 1)
: typeOf === 'function'
? '[Function]'
: typeOf === 'bigint'
? value[key].toString(16)
: value[key];
});
return sanatized;
};

0 comments on commit 24446ee

Please sign in to comment.