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 #111

Closed
wants to merge 6 commits into from
Closed
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
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 @@ -172,6 +173,12 @@ export const CreateTransaction = ({ asset, inboundRelayParams, setSignTxResponse
(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).getNativeAsset(asset.nativeAsset);
}

return await derivation!.prepare?.(toAddress, values.memo);
},
Expand Down Expand Up @@ -237,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 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
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;
}
}
133 changes: 88 additions & 45 deletions apps/recovery-relay/lib/wallets/ERC20/index.ts
Original file line number Diff line number Diff line change
@@ -1,73 +1,116 @@
/* 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 } from 'ethers';
import { AccountData } from '../types';
import { ConnectedWallet } from '../ConnectedWallet';
import { Ethereum } from '../EVM/ETH';
import { EVMWallet as EVMBase } from '@fireblocks/wallet-derivation';
import { erc20Abi } from './erc20.abi';
import { transferAbi } from './transfer.abi';
import BigNumber from 'bignumber.js';
import { getChainId } from './chains';

export class ERC20 extends Ethereum implements ConnectedWallet {
private contract: Contract;
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;

constructor(input: Input, tokenAddress: string) {
super(input);
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.chainId, { cacheTimeout: -1 });
}

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

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);
}

this.contract = new ethers.Contract(tokenAddress, erc20Abi);
public setDecimals(decimals: number) {
this.decimals = decimals;
this.normalizingFactor = BigInt(10 ** decimals);
}

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

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 iface = new ethers.Interface(erc20Abi);
const data = iface.encodeFunctionData('transfer', [this.toAddress, BigInt(displayBalance) * 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('maxFee', maxFeePerGas?.toString());
extraParams.set('priorityFee', maxPriorityFeePerGas?.toString());
extraParams.set('weiBalance', (BigInt(displayBalance) * this.normalizingFactor!).toString());

const preparedData = {
derivationPath: this.pathParts,
tx: unsignedTx,
const preparedData: AccountData = {
balance: displayBalance,
extraParams,
gasPrice,
nonce,
chainId: this.chainId,
insufficientBalance: displayBalance <= 0,
insufficientBalanceForTokenTransfer: Number(ethBalance!) <= 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);
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 weiBalanceBN = await this.provider?.getBalance(this.address);
console.info('Eth balance info', { weiBalanceBN });
return weiBalanceBN;
}
}
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