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 @@ -24,6 +24,7 @@ import { Derivation, AccountData } from '../../../lib/wallets';
import { LateInitConnectedWallet } from '../../../lib/wallets/LateInitConnectedWallet';
import { useSettings } from '../../../context/Settings';
import { Jetton } from '../../../lib/wallets/Jetton';
import { ERC20 } from '../../../lib/wallets/ERC20';

const logger = getLogger(LOGGER_NAME_RELAY);

Expand Down Expand Up @@ -167,11 +168,17 @@ 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);
}
if (asset.address && asset.protocol === 'ETH') {
(derivation as ERC20).setTokenAddress(asset.address);
(derivation as ERC20).setDecimals(asset.decimals);
(derivation as ERC20).setToAddress(toAddress);
(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 @@ -444,9 +451,7 @@ export const CreateTransaction = ({ asset, inboundRelayParams, setSignTxResponse
(prepareQuery.data &&
prepareQuery.data?.insufficientBalance !== undefined &&
prepareQuery.data.insufficientBalance) ||
(prepareQuery.data &&
prepareQuery.data?.insufficientBalanceForTokenTransfer !== undefined &&
prepareQuery.data.insufficientBalanceForTokenTransfer)
(prepareQuery.data && prepareQuery.data.insufficientBalanceForTokenTransfer)
}
>
Prepare Transaction
Expand Down
57 changes: 32 additions & 25 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 Expand Up @@ -169,26 +171,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'>
<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}
</Link>
) : (
txHash
)}
</Typography>
)}
</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
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
140 changes: 94 additions & 46 deletions apps/recovery-relay/lib/wallets/ERC20/index.ts
Original file line number Diff line number Diff line change
@@ -1,73 +1,121 @@
/* eslint-disable prefer-destructuring */
import { Input } from '@fireblocks/wallet-derivation';
import { Contract, Interface, Transaction, ethers } from 'ethers';
import { AccountData, TxPayload, RawSignature } from '../types';
import { Contract, ethers, JsonRpcProvider, parseUnits } from 'ethers';
import { AccountData } from '../types';
import { ConnectedWallet } from '../ConnectedWallet';
import { Ethereum } from '../EVM/ETH';
import { Input, EVMWallet as EVMBase } from '@fireblocks/wallet-derivation';
import { erc20Abi } from './erc20.abi';
import { transferAbi } from './transfer.abi';
import BigNumber from 'bignumber.js';
import { WalletClasses } from '..';
import { EVM } from '../EVM';

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

constructor(input: Input, tokenAddress: string) {
constructor(private input: Input) {
super(input);
}

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 = (10 ** decimals).toString();
}

this.contract = new ethers.Contract(tokenAddress, erc20Abi);
public setToAddress(toAddress: string) {
this.toAddress = toAddress;
}

public async getBalance(): Promise<number> {
this.weiBalance = await this.contract.balanceOf(this.address);
return parseFloat(parseFloat(ethers.formatEther(this.weiBalance)).toFixed(2));
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 displayBalance = await this.getBalance();
const extraParams = new Map();
extraParams.set(this.KEY_EVM_WEI_BALANCE, new BigNumber(this.weiBalance.toString()).toString(16));
const preparedData = {
balance: displayBalance,
extraParams,
};
this.relayLogger.logPreparedData('ERC20', preparedData);
return preparedData;
}
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');

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

// Should we use maxGasPrice? i.e. EIP1559.
const { gasPrice } = await this.provider!.getFeeData();
const abiInterface = new ethers.Interface(erc20Abi);
const data = abiInterface.encodeFunctionData('transfer', [
this.toAddress,
BigInt(displayBalance) * BigInt(this.normalizingFactor!),
]);

const tx = {
to: this.tokenAddress,
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()),
]),
data: data,
};
const gasLimit = await this.provider?.estimateGas(tx);

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

const unsignedTx = Transaction.from(tx).serialized;
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) * BigInt(this.normalizingFactor!)).toString());

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

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

public async broadcastTx(txHex: string): Promise<string> {
return super.broadcastTx(txHex);
public async broadcastTx(tx: string): Promise<string> {
try {
//@ts-ignore
return await this.backendWallet.broadcastTx(tx);
} catch (e) {
this.relayLogger.error('ERC20: Error broadcasting tx:', e);
throw new Error(`ERC20: Error broadcasting tx: ${e}`);
}
}
}
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
17 changes: 16 additions & 1 deletion apps/recovery-relay/lib/wallets/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getAllJettons } from '@fireblocks/asset-config';
import { getAllJettons, getAllERC20s } from '@fireblocks/asset-config';
import { Cardano } from './ADA';
import { Cosmos } from './ATOM';
import { Bitcoin, BitcoinCash, BitcoinSV, DASH, DogeCoin, LiteCoin, ZCash } from './BTCBased';
Expand Down Expand Up @@ -39,6 +39,7 @@ import { Celestia } from './CELESTIA';
import { CoreDAO } from './EVM/CORE_COREDAO';
import { Ton } from './TON';
import { Jetton } from './Jetton';
import { ERC20 } from './ERC20';
export { ConnectedWallet } from './ConnectedWallet';

const fillJettons = () => {
Expand All @@ -54,6 +55,19 @@ const fillJettons = () => {
return jettons;
};

const fillERC20s = () => {
const jerc20List = getAllERC20s();
const erc20Tokens = jerc20List.reduce(
(prev, curr) => ({
...prev,
[curr]: ERC20,
}),
{},
) as any;
Object.keys(erc20Tokens).forEach((key) => (erc20Tokens[key] === undefined ? delete erc20Tokens[key] : {}));
return erc20Tokens;
};

export const WalletClasses = {
ALGO: Algorand,
ALGO_TEST: Algorand,
Expand Down Expand Up @@ -137,6 +151,7 @@ export const WalletClasses = {
TON: Ton,
TON_TEST: Ton,
...fillJettons(),
...fillERC20s(),
} as const;

type WalletClass = (typeof WalletClasses)[keyof typeof WalletClasses];
Expand Down
Loading