diff --git a/apps/recovery-relay/components/WithdrawModal/CreateTransaction/index.tsx b/apps/recovery-relay/components/WithdrawModal/CreateTransaction/index.tsx index c1f73351..1fa1f88e 100644 --- a/apps/recovery-relay/components/WithdrawModal/CreateTransaction/index.tsx +++ b/apps/recovery-relay/components/WithdrawModal/CreateTransaction/index.tsx @@ -168,7 +168,6 @@ export const CreateTransaction = ({ asset, inboundRelayParams, setSignTxResponse logger.error(`Unknown URL for ${derivation?.assetId ?? ''}`); 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); @@ -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); }, @@ -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 ( { 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}`); diff --git a/apps/recovery-relay/lib/defaultRPCs.ts b/apps/recovery-relay/lib/defaultRPCs.ts index cdacbf68..0586a5b0 100644 --- a/apps/recovery-relay/lib/defaultRPCs.ts +++ b/apps/recovery-relay/lib/defaultRPCs.ts @@ -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, diff --git a/apps/recovery-relay/lib/wallets/ERC20/chains.ts b/apps/recovery-relay/lib/wallets/ERC20/chains.ts deleted file mode 100644 index 0508b59e..00000000 --- a/apps/recovery-relay/lib/wallets/ERC20/chains.ts +++ /dev/null @@ -1,34 +0,0 @@ -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; - } -} diff --git a/apps/recovery-relay/lib/wallets/ERC20/index.ts b/apps/recovery-relay/lib/wallets/ERC20/index.ts index 11b33d6f..9d4b2fc5 100644 --- a/apps/recovery-relay/lib/wallets/ERC20/index.ts +++ b/apps/recovery-relay/lib/wallets/ERC20/index.ts @@ -1,48 +1,56 @@ -/* 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) { @@ -50,21 +58,26 @@ export class ERC20 extends EVMBase implements ConnectedWallet { } public async getBalance(): Promise { - 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 { 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, @@ -76,41 +89,30 @@ export class ERC20 extends EVMBase implements ConnectedWallet { const extraParams = new Map(); 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 { + public async broadcastTx(tx: string): Promise { 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; - } } diff --git a/apps/recovery-relay/lib/wallets/EVM/index.ts b/apps/recovery-relay/lib/wallets/EVM/index.ts index 01078971..b6ea32c5 100644 --- a/apps/recovery-relay/lib/wallets/EVM/index.ts +++ b/apps/recovery-relay/lib/wallets/EVM/index.ts @@ -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'; @@ -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 }); @@ -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())); diff --git a/apps/recovery-utility/renderer/lib/wallets/ERC20/index.ts b/apps/recovery-utility/renderer/lib/wallets/ERC20/index.ts index e9b0476d..5d4b5a67 100644 --- a/apps/recovery-utility/renderer/lib/wallets/ERC20/index.ts +++ b/apps/recovery-utility/renderer/lib/wallets/ERC20/index.ts @@ -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 { + public async generateTx({ to, extraParams, nonce, chainId }: GenerateTxInput): Promise { if (!this.privateKey) { throw new Error('No private key found'); } @@ -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]); diff --git a/packages/asset-config/README.md b/packages/asset-config/README.md index e175093e..b8ca5132 100644 --- a/packages/asset-config/README.md +++ b/packages/asset-config/README.md @@ -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`. diff --git a/packages/shared/lib/sanatize.ts b/packages/shared/lib/sanatize.ts index bb97d56e..3ce94f65 100644 --- a/packages/shared/lib/sanatize.ts +++ b/packages/shared/lib/sanatize.ts @@ -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; };