Skip to content

Commit 1cfce32

Browse files
committed
feat(@fireblocks/recovery-utility): ✨ add erc20 withdrawals supoprt for evms
1 parent 17d46e8 commit 1cfce32

File tree

8 files changed

+144
-115
lines changed

8 files changed

+144
-115
lines changed

apps/recovery-relay/components/WithdrawModal/CreateTransaction/index.tsx

+13-12
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ export const CreateTransaction = ({ asset, inboundRelayParams, setSignTxResponse
177177
(derivation as ERC20).setTokenAddress(asset.address);
178178
(derivation as ERC20).setDecimals(asset.decimals);
179179
(derivation as ERC20).setToAddress(toAddress);
180+
(derivation as ERC20).getNativeAsset(asset.nativeAsset);
180181
}
181182

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

246-
logger.info('Parameters for CreateTransaction ', {
247-
txId,
248-
accountId,
249-
values,
250-
asset,
251-
derivation: sanatize(derivation),
252-
prepare: JSON.stringify(
253-
prepareQuery.data,
254-
(_, v) => (typeof v === 'bigint' ? v.toString() : typeof v === 'function' ? 'function' : v),
255-
2,
256-
),
257-
});
247+
// logger.info('Parameters for CreateTransaction ', {
248+
// txId,
249+
// accountId,
250+
// values,
251+
// asset,
252+
// derivation: sanatize(derivation),
253+
// prepare: JSON.stringify(
254+
// prepareQuery.data,
255+
// (_, v) => (typeof v === 'bigint' ? v.toString() : typeof v === 'function' ? 'function' : v),
256+
// 2,
257+
// ),
258+
// });
258259

259260
return (
260261
<Grid

apps/recovery-relay/components/WithdrawModal/index.tsx

+25-20
Original file line numberDiff line numberDiff line change
@@ -169,26 +169,31 @@ export const WithdrawModal = () => {
169169
</>
170170
)}
171171
{!!txHash && (
172-
<Typography
173-
variant='body1'
174-
paragraph
175-
sx={{
176-
display: 'flex',
177-
alignItems: 'center',
178-
'& > *': {
179-
marginRight: '0.5rem',
180-
},
181-
}}
182-
>
183-
<Typography variant='body1'>Transaction hash:</Typography>
184-
{asset.getExplorerUrl ? (
185-
<Link href={asset.getExplorerUrl!('tx')(txHash)} target='_blank' rel='noopener noreferrer'>
186-
{txHash}
187-
</Link>
188-
) : (
189-
txHash
190-
)}
191-
</Typography>
172+
<Box>
173+
<Typography
174+
variant='body1'
175+
paragraph
176+
sx={{
177+
display: 'flex',
178+
alignItems: 'center',
179+
'& > *': {
180+
marginRight: '0.5rem',
181+
},
182+
}}
183+
>
184+
<Typography variant='body1'>Transaction Hash:</Typography>
185+
{asset.getExplorerUrl ? (
186+
<Link href={asset.getExplorerUrl!('tx')(txHash)} target='_blank' rel='noopener noreferrer'>
187+
{txHash}
188+
</Link>
189+
) : (
190+
txHash
191+
)}
192+
</Typography>
193+
<Typography variant='body1'>
194+
The transaction might take a few seconds to appear on the block explorer
195+
</Typography>
196+
</Box>
192197
)}
193198
{!!txBroadcastError && (
194199
<Typography variant='body1' fontWeight='600' color={(theme) => theme.palette.error.main}>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
export function getChainId(nativeAsset: string): number | undefined {
2+
switch (nativeAsset) {
3+
case 'ETH':
4+
return 1;
5+
case 'BNB_BSC':
6+
return 56;
7+
case 'CHZ_$CHZ':
8+
return 88888;
9+
case 'CELO':
10+
return 42220;
11+
case 'RBTC':
12+
return 30;
13+
case 'AVAX':
14+
return 43114;
15+
case 'MATIC_POLYGON':
16+
return 137;
17+
case 'RON':
18+
return 2020;
19+
case 'ETH_TEST5':
20+
return 11155111;
21+
case 'ETH_TEST6':
22+
return 17000;
23+
case 'SMARTBCH':
24+
return 10000;
25+
case 'ETH-AETH':
26+
return 42161;
27+
case 'BNB_TEST':
28+
return 97;
29+
case 'FTM_FANTOM':
30+
return 250;
31+
default:
32+
return undefined;
33+
}
34+
}
+42-57
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,31 @@
11
/* eslint-disable prefer-destructuring */
2-
import { Contract, ethers, formatEther, JsonRpcProvider } from 'ethers';
2+
import { Contract, ethers, JsonRpcProvider } from 'ethers';
33
import { AccountData } from '../types';
44
import { ConnectedWallet } from '../ConnectedWallet';
5-
import { EVM } from '../EVM';
5+
import { EVMWallet as EVMBase } from '@fireblocks/wallet-derivation';
66
import { erc20Abi } from './erc20.abi';
7+
import { getChainId } from './chains';
78

8-
export class ERC20 extends EVM implements ConnectedWallet {
9+
export class ERC20 extends EVMBase implements ConnectedWallet {
910
protected provider: JsonRpcProvider | undefined;
1011
public rpcURL: string | undefined;
1112
public contract!: Contract;
1213
public tokenAddress: string | undefined;
1314
public decimals: number | undefined;
1415
public toAddress: string | undefined;
16+
private normalizingFactor: bigint | undefined;
17+
private chainId: number | undefined;
18+
19+
public getNativeAsset(nativeAsset: string) {
20+
this.chainId = getChainId(nativeAsset);
21+
if (!this.chainId) {
22+
throw new Error('Unrecognaized native asset for ERC20 token withdrawal');
23+
}
24+
}
1525

1626
public setRPCUrl(url: string): void {
1727
this.rpcURL = url;
18-
this.provider = new JsonRpcProvider(this.rpcURL);
28+
this.provider = new JsonRpcProvider(this.rpcURL, this.chainId, { cacheTimeout: -1 });
1929
}
2030

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

3343
public setDecimals(decimals: number) {
3444
this.decimals = decimals;
45+
this.normalizingFactor = BigInt(10 ** decimals);
3546
}
3647

3748
public setToAddress(toAddress: string) {
3849
this.toAddress = toAddress;
3950
}
4051

4152
public async getBalance(): Promise<number> {
42-
const weiBalance = await this.contract.balanceOf(this.address);
43-
return parseFloat(parseFloat(ethers.formatEther(weiBalance)).toFixed(2));
53+
const weiBalance: bigint = await this.contract.balanceOf(this.address);
54+
return Number(weiBalance / this.normalizingFactor!);
4455
}
4556

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

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

54-
let { gasPrice, maxFeePerGas, maxPriorityFeePerGas } = await this.provider!.getFeeData();
64+
const { gasPrice, maxFeePerGas, maxPriorityFeePerGas } = await this.provider!.getFeeData();
5565

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

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

66-
const extraParams = new Map();
76+
const extraParams = new Map<string, any>();
6777
extraParams.set('tokenAddress', this.tokenAddress);
68-
extraParams.set('gasLimit', gasLimit);
69-
extraParams.set('maxFee', maxFeePerGas);
70-
extraParams.set('priorityFee', maxPriorityFeePerGas);
78+
extraParams.set('gasLimit', gasLimit?.toString());
79+
extraParams.set('maxFee', maxFeePerGas?.toString());
80+
extraParams.set('priorityFee', maxPriorityFeePerGas?.toString());
81+
extraParams.set('weiBalance', (BigInt(displayBalance) * this.normalizingFactor!).toString());
7182

7283
const preparedData: AccountData = {
7384
balance: displayBalance,
7485
extraParams,
7586
gasPrice,
7687
nonce,
77-
chainId: Number(chainId),
88+
chainId: this.chainId,
7889
insufficientBalance: displayBalance <= 0,
79-
insufficientBalanceForTokenTransfer: ethBalance <= gasPrice! * gasLimit!,
90+
insufficientBalanceForTokenTransfer: Number(ethBalance!) <= Number(gasPrice! * gasLimit!),
8091
};
81-
this.relayLogger.logPreparedData('ERC20', preparedData);
8292
return preparedData;
8393
}
8494

85-
// public async generateTx(to: string, amount: number): Promise<TxPayload> {
86-
// const nonce = await this.provider!.getTransactionCount(this.address, 'latest');
87-
88-
// // Should we use maxGasPrice? i.e. EIP1559.
89-
// const { gasPrice } = await this.provider!.getFeeData();
90-
91-
// const tx = {
92-
// from: this.address,
93-
// to,
94-
// nonce,
95-
// gasLimit: 21000,
96-
// gasPrice,
97-
// value: 0,
98-
// chainId: this.path.coinType === 1 ? 5 : 1,
99-
// data: new Interface(transferAbi).encodeFunctionData('transfer', [
100-
// to,
101-
// BigInt(amount) * BigInt(await this.contract.decimals()),
102-
// ]),
103-
// };
104-
105-
// this.relayLogger.debug(`ERC20: Generated tx: ${JSON.stringify(tx, (_, v) => (typeof v === 'bigint' ? v.toString() : v), 2)}`);
106-
107-
// const unsignedTx = Transaction.from(tx).serialized;
108-
109-
// const preparedData = {
110-
// derivationPath: this.pathParts,
111-
// tx: unsignedTx,
112-
// };
113-
114-
// this.relayLogger.debug(`ERC20: Prepared data: ${JSON.stringify(preparedData, null, 2)}`);
115-
// return preparedData;
116-
// }
117-
11895
public async broadcastTx(txHex: string): Promise<string> {
119-
return super.broadcastTx(txHex);
96+
try {
97+
const txRes = await this.provider!.broadcastTransaction(txHex);
98+
this.relayLogger.debug(`EVM: Tx broadcasted: ${JSON.stringify(txRes, null, 2)}`);
99+
return txRes.hash;
100+
} catch (e) {
101+
this.relayLogger.error('EVM: Error broadcasting tx:', e);
102+
if ((e as Error).message.includes('insufficient funds for intrinsic transaction cost')) {
103+
throw new Error(
104+
'Insufficient funds for transfer, this might be due to a spike in network fees, please wait and try again',
105+
);
106+
}
107+
throw e;
108+
}
120109
}
121110

122111
private async getEthBalance() {
123-
const weiBalance = await this.provider?.getBalance(this.address);
124-
const balance = formatEther(weiBalance!);
125-
const ethBalance = Number(balance);
126-
127-
console.info('Eth balance info', { ethBalance });
128-
129-
return ethBalance;
112+
const weiBalanceBN = await this.provider?.getBalance(this.address);
113+
console.info('Eth balance info', { weiBalanceBN });
114+
return weiBalanceBN;
130115
}
131116
}

apps/recovery-utility/renderer/lib/wallets/ERC20/index.ts

+17-19
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,36 @@
11
import { ethers, Wallet } from 'ethers';
2-
import { EVMWallet as EVMBase, Input } from '@fireblocks/wallet-derivation';
2+
import { EVMWallet as EVMBase } from '@fireblocks/wallet-derivation';
33
import { TxPayload, GenerateTxInput } from '../types';
44
import { SigningWallet } from '../SigningWallet';
55
import { erc20Abi } from './erc20.abi';
66

77
export class ERC20 extends EVMBase implements SigningWallet {
8-
constructor(input: Input, chainId?: number) {
9-
super(input);
10-
}
11-
12-
public async generateTx({ to, amount, extraParams, gasPrice, nonce, chainId }: GenerateTxInput): Promise<TxPayload> {
8+
public async generateTx({ to, extraParams, nonce, chainId, gasPrice }: GenerateTxInput): Promise<TxPayload> {
139
if (!this.privateKey) {
1410
throw new Error('No private key found');
1511
}
1612

17-
const balanceWei = ethers.parseUnits(amount.toFixed(2), 'ether');
13+
const balanceWei = BigInt(extraParams?.get('weiBalance'));
14+
1815
const tokenAddress = extraParams?.get('tokenAddress');
19-
const maxPriorityFeePerGas = extraParams?.get('priorityFee');
20-
const maxFeePerGas = extraParams?.get('maxFee');
21-
const gasLimit = extraParams?.get('gasLimit');
16+
17+
const maxPriorityFeePerGas = (BigInt(extraParams?.get('priorityFee')) * 115n) / 100n; //increase priority fee by 15% to increase chance of tx to be included in next block
18+
const maxFeePerGas = BigInt(extraParams?.get('maxFee'));
19+
const gasLimit = BigInt(extraParams?.get('gasLimit'));
2220

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

26-
const txObject = {
27-
to: tokenAddress,
28-
data,
29-
nonce,
30-
gasLimit,
31-
maxFeePerGas,
32-
maxPriorityFeePerGas,
33-
};
24+
let txObject = {};
25+
// EIP-1559 chain
26+
if (maxFeePerGas && maxPriorityFeePerGas) {
27+
txObject = { to: tokenAddress, data, nonce, gasLimit, maxFeePerGas, maxPriorityFeePerGas, chainId };
28+
// non EIP-1559 chain
29+
} else {
30+
txObject = { to: tokenAddress, data, nonce, gasLimit, gasPrice, chainId };
31+
}
3432

35-
this.utilityLogger.logSigningTx('EVM', txObject);
33+
this.utilityLogger.logSigningTx('ERC20', txObject);
3634

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

apps/recovery-utility/renderer/lib/wallets/index.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// import { Bitcoin } from './BTC';
22
import { assets, getAllJettons } from '@fireblocks/asset-config';
3-
import { ERC20, ETC } from '@fireblocks/wallet-derivation';
3+
import { ETC } from '@fireblocks/wallet-derivation';
44
import { Ripple } from './XRP';
55
import { Cosmos } from './ATOM';
66
import { EOS } from './EOS';
@@ -22,6 +22,7 @@ import { Bitcoin, BitcoinSV, LiteCoin, Dash, ZCash, Doge } from './BTC';
2222
import { Celestia } from './CELESTIA';
2323
import { Ton } from './TON';
2424
import { Jetton } from './Jetton';
25+
import { ERC20 } from './ERC20';
2526

2627
const fillEVMs = () => {
2728
const evms = Object.keys(assets).reduce(
@@ -89,6 +90,7 @@ export const WalletClasses = {
8990
LUNA2_TEST: Luna,
9091
CELESTIA: Celestia,
9192
CELESTIA_TEST: Celestia,
93+
...fillEVMs(),
9294

9395
// EDDSA
9496
SOL: Solana,
@@ -110,7 +112,6 @@ export const WalletClasses = {
110112
TON_TEST: Ton,
111113

112114
...fillJettons(),
113-
...fillEVMs(),
114115
} as const;
115116

116117
type WalletClass = (typeof WalletClasses)[keyof typeof WalletClasses];

packages/asset-config/config/patches.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ export const nativeAssetPatches: NativeAssetPatches = {
140140
},
141141
ETC: evm('blockscout.com/etc/mainnet', 'https://geth-de.etc-network.info'),
142142
ETC_TEST: evm('blockscout.com/etc/kotti', 'https://geth-mordor.etc-network.info'),
143-
ETH: evm('etherscan.io', 'https://mainnet.infura.io/v3/9aa3d95b3bc440fa88ea12eaa4456161'),
143+
ETH: evm('etherscan.io', 'https://eth-mainnet.public.blastapi.io'),
144144
ETH_TEST3: evm('goerli.etherscan.io', 'https://goerli.infura.io/v3/9aa3d95b3bc440fa88ea12eaa4456161'),
145145
ETH_TEST5: evm('sepolia.etherscan.io', 'https://sepolia.infura.io/v3/9aa3d95b3bc440fa88ea12eaa4456161'),
146146
ETH_TEST6: evm('holesky.etherscan.io', 'https://ethereum-holesky-rpc.publicnode.com'),

0 commit comments

Comments
 (0)