From 2002731c52daf42962435f7862c06ee135eef62a Mon Sep 17 00:00:00 2001 From: Art Date: Thu, 1 Feb 2024 09:07:13 +0100 Subject: [PATCH] feat(trading): gas fee estimation for withdraw transaction (#5668) --- libs/assets/src/lib/assets-data-provider.ts | 22 +++ libs/i18n/src/locales/en/withdraws.json | 8 +- libs/utils/src/lib/format/ether.spec.ts | 42 +++++ libs/utils/src/lib/format/ether.ts | 84 ++++++++++ libs/utils/src/lib/format/index.ts | 1 + libs/utils/src/lib/format/number.spec.ts | 20 +++ libs/utils/src/lib/format/number.ts | 23 ++- libs/web3/src/index.ts | 1 + libs/web3/src/lib/use-gas-price.ts | 111 +++++++++++++ libs/withdraws/src/lib/withdraw-form.tsx | 4 + libs/withdraws/src/lib/withdraw-limits.tsx | 146 +++++++++++++++++- .../src/lib/withdraw-manager.spec.tsx | 1 + libs/withdraws/src/lib/withdraw-manager.tsx | 4 + 13 files changed, 464 insertions(+), 3 deletions(-) create mode 100644 libs/utils/src/lib/format/ether.spec.ts create mode 100644 libs/utils/src/lib/format/ether.ts create mode 100644 libs/web3/src/lib/use-gas-price.ts diff --git a/libs/assets/src/lib/assets-data-provider.ts b/libs/assets/src/lib/assets-data-provider.ts index 7180a0b59..964de03a6 100644 --- a/libs/assets/src/lib/assets-data-provider.ts +++ b/libs/assets/src/lib/assets-data-provider.ts @@ -7,6 +7,7 @@ import { AssetsDocument, type AssetsQuery } from './__generated__/Assets'; import { AssetStatus } from '@vegaprotocol/types'; import { type Asset } from './asset-data-provider'; import { DENY_LIST } from './constants'; +import { type AssetFieldsFragment } from './__generated__/Asset'; export interface BuiltinAssetSource { __typename: 'BuiltinAsset'; @@ -89,3 +90,24 @@ export const useEnabledAssets = () => { variables: undefined, }); }; + +/** Wrapped ETH symbol */ +const WETH = 'WETH'; +type WETHDetails = Pick; +/** + * Tries to find WETH asset configuration on Vega in order to provide its + * details, otherwise it returns hardcoded values. + */ +export const useWETH = (): WETHDetails => { + const { data } = useAssetsDataProvider(); + if (data) { + const weth = data.find((a) => a.symbol.toUpperCase() === WETH); + if (weth) return weth; + } + + return { + symbol: WETH, + decimals: 18, + quantum: '500000000000000', // 1 WETH ~= 2000 qUSD + }; +}; diff --git a/libs/i18n/src/locales/en/withdraws.json b/libs/i18n/src/locales/en/withdraws.json index 22807f862..98d82bf93 100644 --- a/libs/i18n/src/locales/en/withdraws.json +++ b/libs/i18n/src/locales/en/withdraws.json @@ -47,5 +47,11 @@ "Withdrawals of {{threshold}} {{symbol}} or more will be delayed for {{delay}}.": "Withdrawals of {{threshold}} {{symbol}} or more will be delayed for {{delay}}.", "Withdrawals ready": "Withdrawals ready", "You have no assets to withdraw": "You have no assets to withdraw", - "Your funds have been unlocked for withdrawal - <0>View in block explorer<0>": "Your funds have been unlocked for withdrawal - <0>View in block explorer<0>" + "Your funds have been unlocked for withdrawal - <0>View in block explorer<0>": "Your funds have been unlocked for withdrawal - <0>View in block explorer<0>", + "Gas fee": "Gas fee", + "Estimated gas fee for the withdrawal transaction (refreshes each 15 seconds)": "Estimated gas fee for the withdrawal transaction (refreshes each 15 seconds)", + "It seems that the current gas prices are exceeding the amount you're trying to withdraw": "It seems that the current gas prices are exceeding the amount you're trying to withdraw", + "The current gas price range": "The current gas price range", + "min": "min", + "max": "max" } diff --git a/libs/utils/src/lib/format/ether.spec.ts b/libs/utils/src/lib/format/ether.spec.ts new file mode 100644 index 000000000..a64d5788e --- /dev/null +++ b/libs/utils/src/lib/format/ether.spec.ts @@ -0,0 +1,42 @@ +import BigNumber from 'bignumber.js'; +import { EtherUnit, formatEther, unitiseEther } from './ether'; + +describe('unitiseEther', () => { + it.each([ + [1, '1', EtherUnit.wei], + [999, '999', EtherUnit.wei], + [1000, '1', EtherUnit.kwei], + [9999, '9.999', EtherUnit.kwei], + [10000, '10', EtherUnit.kwei], + [999999, '999.999', EtherUnit.kwei], + [1000000, '1', EtherUnit.mwei], + [999999999, '999.999999', EtherUnit.mwei], + [1000000000, '1', EtherUnit.gwei], + ['999999999999999999', '999999999.999999999', EtherUnit.gwei], // max gwei + [1e18, '1', EtherUnit.ether], // 1 ETH + [1234e18, '1234', EtherUnit.ether], // 1234 ETH + ])('unitises %s to [%s, %s]', (value, expectedOutput, expectedUnit) => { + const [output, unit] = unitiseEther(value); + expect(output.toFixed()).toEqual(expectedOutput); + expect(unit).toEqual(expectedUnit); + }); + + it('unitises to requested unit', () => { + const [output, unit] = unitiseEther(1, EtherUnit.kwei); + expect(output).toEqual(BigNumber(0.001)); + expect(unit).toEqual(EtherUnit.kwei); + }); +}); + +describe('formatEther', () => { + it.each([ + [1, EtherUnit.wei, '1 wei'], + [12, EtherUnit.kwei, '12 kwei'], + [123, EtherUnit.gwei, '123 gwei'], + [3, EtherUnit.ether, '3 ETH'], + [234.67776331, EtherUnit.gwei, '235 gwei'], + [12.12, EtherUnit.gwei, '12 gwei'], + ])('formats [%s, %s] to "%s"', (value, unit, expectedOutput) => { + expect(formatEther([BigNumber(value), unit])).toEqual(expectedOutput); + }); +}); diff --git a/libs/utils/src/lib/format/ether.ts b/libs/utils/src/lib/format/ether.ts new file mode 100644 index 000000000..eb56a6d40 --- /dev/null +++ b/libs/utils/src/lib/format/ether.ts @@ -0,0 +1,84 @@ +import { formatNumber, toBigNum } from './number'; +import type BigNumber from 'bignumber.js'; + +export enum EtherUnit { + /** 1 wei = 10^-18 ETH */ + wei = '0', + /** 1 kwei = 1000 wei */ + kwei = '3', + /** 1 mwei = 1000 kwei */ + mwei = '6', + /** 1 gwei = 1000 kwei */ + gwei = '9', + + // other denominations: + // microether = '12', // aka szabo, µETH + // milliether = '15', // aka finney, mETH + + /** 1 ETH = 1B gwei = 10^18 wei */ + ether = '18', +} + +export const etherUnitMapping: Record = { + [EtherUnit.wei]: 'wei', + [EtherUnit.kwei]: 'kwei', + [EtherUnit.mwei]: 'mwei', + [EtherUnit.gwei]: 'gwei', + // [EtherUnit.microether]: 'µETH', // szabo + // [EtherUnit.milliether]: 'mETH', // finney + [EtherUnit.ether]: 'ETH', +}; + +type InputValue = string | number | BigNumber; +type UnitisedTuple = [value: BigNumber, unit: EtherUnit]; + +/** + * Converts given raw value to the unitised tuple of amount and unit + */ +export const unitiseEther = ( + input: InputValue, + forceUnit?: EtherUnit +): UnitisedTuple => { + const units = Object.values(EtherUnit).reverse(); + + let value = toBigNum(input, Number(forceUnit || EtherUnit.ether)); + let unit = forceUnit || EtherUnit.ether; + + if (!forceUnit) { + for (const u of units) { + const v = toBigNum(input, Number(u)); + value = v; + unit = u; + if (v.isGreaterThanOrEqualTo(1)) break; + } + } + + return [value, unit]; +}; + +/** + * `formatNumber` wrapper for unitised ether values (attaches unit name) + */ +export const formatEther = ( + input: UnitisedTuple, + decimals = 0, + noUnit = false +) => { + const [value, unit] = input; + const num = formatNumber(value, decimals); + const unitName = noUnit ? '' : etherUnitMapping[unit]; + + return `${num} ${unitName}`.trim(); +}; + +/** + * Utility function that formats given raw amount as ETH. + * Example: + * Given value of `1` this will return `0.000000000000000001 ETH` + */ +export const asETH = (input: InputValue, noUnit = false) => + formatEther( + unitiseEther(input, EtherUnit.ether), + Number(EtherUnit.ether), + noUnit + ); diff --git a/libs/utils/src/lib/format/index.ts b/libs/utils/src/lib/format/index.ts index 7fe517d3c..e3c52efda 100644 --- a/libs/utils/src/lib/format/index.ts +++ b/libs/utils/src/lib/format/index.ts @@ -4,3 +4,4 @@ export * from './range'; export * from './size'; export * from './strings'; export * from './trigger'; +export * from './ether'; diff --git a/libs/utils/src/lib/format/number.spec.ts b/libs/utils/src/lib/format/number.spec.ts index 1039a86eb..121d00547 100644 --- a/libs/utils/src/lib/format/number.spec.ts +++ b/libs/utils/src/lib/format/number.spec.ts @@ -13,6 +13,7 @@ import { toDecimal, toNumberParts, formatNumberRounded, + toQUSD, } from './number'; describe('number utils', () => { @@ -282,3 +283,22 @@ describe('formatNumberRounded', () => { ); }); }); + +describe('toQUSD', () => { + it.each([ + [0, 0, 0], + [1, 1, 1], + [1, 10, 0.1], + [1, 100, 0.01], + // real life examples + [1000000, 1000000, 1], // USDC -> 1 USDC ~= 1 qUSD + [500000, 1000000, 0.5], // USDC => 0.6 USDC ~= 0.5 qUSD + [1e18, 1e18, 1], // VEGA -> 1 VEGA ~= 1 qUSD + [123.45e18, 1e18, 123.45], // VEGA -> 1 VEGA ~= 1 qUSD + [1e18, 5e14, 2000], // WETH -> 1 WETH ~= 2000 qUSD + [1e9, 5e14, 0.000002], // gwei -> 1 gwei ~= 0.000002 qUSD + [50000e9, 5e14, 0.1], // gwei -> 50000 gwei ~= 0.1 qUSD + ])('converts (%d, %d) to %d qUSD', (amount, quantum, expected) => { + expect(toQUSD(amount, quantum).toNumber()).toEqual(expected); + }); +}); diff --git a/libs/utils/src/lib/format/number.ts b/libs/utils/src/lib/format/number.ts index fa686107d..90d41974f 100644 --- a/libs/utils/src/lib/format/number.ts +++ b/libs/utils/src/lib/format/number.ts @@ -26,7 +26,7 @@ export function toDecimal(numberOfDecimals: number) { } export function toBigNum( - rawValue: string | number, + rawValue: string | number | BigNumber, decimals: number ): BigNumber { const divides = new BigNumber(10).exponentiatedBy(decimals); @@ -233,3 +233,24 @@ export const formatNumberRounded = ( return value; }; + +/** + * Converts given amount in one asset (determined by raw amount + * and quantum values) to qUSD. + * @param amount The raw amount + * @param quantum The quantum value of the asset. + */ +export const toQUSD = ( + amount: string | number | BigNumber, + quantum: string | number +) => { + const value = new BigNumber(amount); + let q = new BigNumber(quantum); + + if (q.isNaN() || q.isLessThanOrEqualTo(0)) { + q = new BigNumber(1); + } + + const qUSD = value.dividedBy(q); + return qUSD; +}; diff --git a/libs/web3/src/index.ts b/libs/web3/src/index.ts index eacd2a435..ce442d74b 100644 --- a/libs/web3/src/index.ts +++ b/libs/web3/src/index.ts @@ -19,6 +19,7 @@ export * from './lib/use-ethereum-transaction'; export * from './lib/use-ethereum-withdraw-approval-toasts'; export * from './lib/use-ethereum-withdraw-approvals-manager'; export * from './lib/use-ethereum-withdraw-approvals-store'; +export * from './lib/use-gas-price'; export * from './lib/use-get-withdraw-delay'; export * from './lib/use-get-withdraw-threshold'; export * from './lib/use-token-contract'; diff --git a/libs/web3/src/lib/use-gas-price.ts b/libs/web3/src/lib/use-gas-price.ts new file mode 100644 index 000000000..76b824f6c --- /dev/null +++ b/libs/web3/src/lib/use-gas-price.ts @@ -0,0 +1,111 @@ +import { useEffect, useState } from 'react'; +import { useWeb3React } from '@web3-react/core'; +import { useEthereumConfig } from './use-ethereum-config'; +import BigNumber from 'bignumber.js'; + +const DEFAULT_INTERVAL = 15000; // 15 seconds + +/** + * These are the hex values of the collateral bridge contract methods. + * + * Collateral bridge address: 0x23872549cE10B40e31D6577e0A920088B0E0666a + * Etherscan: https://etherscan.io/address/0x23872549cE10B40e31D6577e0A920088B0E0666a#writeContract + */ +export enum ContractMethod { + DEPOSIT_ASSET = '0xf7683932', + EXEMPT_DEPOSITOR = '0xb76fbb75', + GLOBAL_RESUME = '0xd72ed529', + GLOBAL_STOP = '0x9dfd3c88', + LIST_ASSET = '0x0ff3562c', + REMOVE_ASSET = '0xc76de358', + REVOKE_EXEMPT_DEPOSITOR = '0x6a1c6fa4', + SET_ASSET_LIMITS = '0x41fb776d', + SET_WITHDRAW_DELAY = '0x5a246728', + WITHDRAW_ASSET = '0x3ad90635', +} + +export type GasData = { + /** The base (minimum) price of 1 unit of gas */ + basePrice: BigNumber; + /** The maximum price of 1 unit of gas */ + maxPrice: BigNumber; + /** The amount of gas (units) needed to process a transaction */ + gas: BigNumber; +}; + +type Provider = NonNullable['provider']>; + +const retrieveGasData = async ( + provider: Provider, + account: string, + contractAddress: string, + contractMethod: ContractMethod +) => { + try { + const data = await provider.getFeeData(); + const estGasAmount = await provider.estimateGas({ + to: account, + from: contractAddress, + data: contractMethod, + }); + + if (data.lastBaseFeePerGas && data.maxFeePerGas) { + return { + // converts also form ethers BigNumber to "normal" BigNumber + basePrice: BigNumber(data.lastBaseFeePerGas.toString()), + maxPrice: BigNumber(data.maxFeePerGas.toString()), + gas: BigNumber(estGasAmount.toString()), + }; + } + } catch (err) { + // NOOP - could not get the estimated gas or the fee data from + // the network. This could happen if there's an issue with transaction + // request parameters (e.g. to/from mismatch) + } + + return undefined; +}; + +/** + * Gets the "current" gas price from the ethereum network. + */ +export const useGasPrice = ( + method: ContractMethod, + interval = DEFAULT_INTERVAL +): GasData | undefined => { + const [gas, setGas] = useState(undefined); + const { provider, account } = useWeb3React(); + const { config } = useEthereumConfig(); + + useEffect(() => { + if (!provider || !config || !account) return; + + const retrieve = async () => { + retrieveGasData( + provider, + account, + config.collateral_bridge_contract.address, + method + ).then((gasData) => { + if (gasData) { + setGas(gasData); + } + }); + }; + retrieve(); + + // Retrieves another estimation and prices in [interval] ms. + let i: ReturnType; + if (interval > 0) { + i = setInterval(() => { + retrieve(); + }, interval); + } + + return () => { + if (i) clearInterval(i); + }; + }, [account, config, interval, method, provider]); + + return gas; +}; diff --git a/libs/withdraws/src/lib/withdraw-form.tsx b/libs/withdraws/src/lib/withdraw-form.tsx index 126ec6d6e..5f23a3cae 100644 --- a/libs/withdraws/src/lib/withdraw-form.tsx +++ b/libs/withdraws/src/lib/withdraw-form.tsx @@ -27,6 +27,7 @@ import { useForm, Controller, useWatch } from 'react-hook-form'; import { WithdrawLimits } from './withdraw-limits'; import { ETHEREUM_EAGER_CONNECT, + type GasData, useWeb3ConnectStore, useWeb3Disconnect, } from '@vegaprotocol/web3'; @@ -56,6 +57,7 @@ export interface WithdrawFormProps { delay: number | undefined; onSelectAsset: (assetId: string) => void; submitWithdraw: (withdrawal: WithdrawalArgs) => void; + gasPrice?: GasData; } const WithdrawDelayNotification = ({ @@ -117,6 +119,7 @@ export const WithdrawForm = ({ delay, onSelectAsset, submitWithdraw, + gasPrice, }: WithdrawFormProps) => { const t = useT(); const ethereumAddress = useEthereumAddress(); @@ -247,6 +250,7 @@ export const WithdrawForm = ({ delay={delay} balance={balance} asset={selectedAsset} + gas={gasPrice} /> )} diff --git a/libs/withdraws/src/lib/withdraw-limits.tsx b/libs/withdraws/src/lib/withdraw-limits.tsx index 0c5911e69..7ab3ba51b 100644 --- a/libs/withdraws/src/lib/withdraw-limits.tsx +++ b/libs/withdraws/src/lib/withdraw-limits.tsx @@ -1,6 +1,6 @@ import type { Asset } from '@vegaprotocol/assets'; import { CompactNumber } from '@vegaprotocol/react-helpers'; -import { WITHDRAW_THRESHOLD_TOOLTIP_TEXT } from '@vegaprotocol/assets'; +import { WITHDRAW_THRESHOLD_TOOLTIP_TEXT, useWETH } from '@vegaprotocol/assets'; import { KeyValueTable, KeyValueTableRow, @@ -9,6 +9,16 @@ import { import BigNumber from 'bignumber.js'; import { formatDistanceToNow } from 'date-fns'; import { useT } from './use-t'; +import { type GasData } from '@vegaprotocol/web3'; +import { + asETH, + formatEther, + formatNumber, + removeDecimal, + toQUSD, + unitiseEther, +} from '@vegaprotocol/utils'; +import classNames from 'classnames'; interface WithdrawLimitsProps { amount: string; @@ -16,6 +26,7 @@ interface WithdrawLimitsProps { balance: BigNumber; delay: number | undefined; asset: Asset; + gas?: GasData; } export const WithdrawLimits = ({ @@ -24,6 +35,7 @@ export const WithdrawLimits = ({ balance, delay, asset, + gas, }: WithdrawLimitsProps) => { const t = useT(); const delayTime = @@ -64,6 +76,24 @@ export const WithdrawLimits = ({ label: t('Delay time'), value: threshold && delay ? delayTime : '-', }, + { + key: 'GAS_FEE', + tooltip: t( + 'Estimated gas fee for the withdrawal transaction (refreshes each 15 seconds)' + ), + label: t('Gas fee'), + value: gas ? ( + + ) : ( + '-' + ), + }, ]; return ( @@ -91,3 +121,117 @@ export const WithdrawLimits = ({ ); }; + +const GasPrice = ({ + gasPrice, + amount, +}: { + gasPrice: WithdrawLimitsProps['gas']; + amount: { value: string; quantum: string }; +}) => { + const t = useT(); + const { quantum: wethQuantum } = useWETH(); + const { value, quantum } = amount; + if (gasPrice) { + const { + basePrice: basePricePerGas, + maxPrice: maxPricePerGas, + gas, + } = gasPrice; + const basePrice = basePricePerGas.multipliedBy(gas); + const maxPrice = maxPricePerGas.multipliedBy(gas); + + const basePriceQUSD = toQUSD(basePrice, wethQuantum); + const maxPriceQUSD = toQUSD(maxPrice, wethQuantum); + + const withdrawalAmountQUSD = toQUSD(value, quantum); + + const isExpensive = + !withdrawalAmountQUSD.isLessThanOrEqualTo(0) && + withdrawalAmountQUSD.isLessThanOrEqualTo(maxPriceQUSD); + const expensiveClassNames = { + 'text-vega-red-500': + isExpensive && withdrawalAmountQUSD.isLessThanOrEqualTo(basePriceQUSD), + 'text-vega-orange-500': + isExpensive && + withdrawalAmountQUSD.isGreaterThan(basePriceQUSD) && + withdrawalAmountQUSD.isLessThanOrEqualTo(maxPriceQUSD), + }; + + const uBasePricePerGas = unitiseEther(basePricePerGas); + const uMaxPricePerGas = unitiseEther( + maxPricePerGas, + uBasePricePerGas[1] // forces the same unit as min price + ); + + const uBasePrice = unitiseEther(basePrice); + const uMaxPrice = unitiseEther(maxPrice, uBasePrice[1]); + + let range = ( + + {formatEther(uBasePrice, 0, true)} - {formatEther(uMaxPrice)} + + ); + // displays range as ETH when it's greater that 1000000 gwei + if (uBasePrice[0].isGreaterThan(1e6)) { + range = ( + + + {t('min')}: {asETH(basePrice)} + + + {t('max')}: {asETH(maxPrice)} + + + ); + } + + return ( +
+ + + {/* base price per gas unit */} + {formatEther(uBasePricePerGas, 0, true)} -{' '} + {formatEther(uMaxPricePerGas)} / gas + + + + {isExpensive && ( + + {t( + "It seems that the current gas prices are exceeding the amount you're trying to withdraw" + )}{' '} + + (~{formatNumber(withdrawalAmountQUSD, 2)} qUSD) + + . + + )} + + {formatNumber(gas)} gas × {asETH(basePricePerGas)}
{' '} + = {asETH(basePrice)} +
+ + {formatNumber(gas)} gas × {asETH(maxPricePerGas)}
={' '} + {asETH(maxPrice)} +
+
+ } + > + + {range} + + + + + ~{formatNumber(basePriceQUSD, 2)} - {formatNumber(maxPriceQUSD, 2)}{' '} + qUSD + + + ); + } + + return '-'; +}; diff --git a/libs/withdraws/src/lib/withdraw-manager.spec.tsx b/libs/withdraws/src/lib/withdraw-manager.spec.tsx index 696fc84d8..4ce04b002 100644 --- a/libs/withdraws/src/lib/withdraw-manager.spec.tsx +++ b/libs/withdraws/src/lib/withdraw-manager.spec.tsx @@ -38,6 +38,7 @@ jest.mock('@vegaprotocol/web3', () => ({ useGetWithdrawDelay: () => { return () => Promise.resolve(10000); }, + useGasPrice: () => undefined, })); describe('WithdrawManager', () => { diff --git a/libs/withdraws/src/lib/withdraw-manager.tsx b/libs/withdraws/src/lib/withdraw-manager.tsx index fca082d1a..0c1fd137a 100644 --- a/libs/withdraws/src/lib/withdraw-manager.tsx +++ b/libs/withdraws/src/lib/withdraw-manager.tsx @@ -4,6 +4,7 @@ import { WithdrawForm } from './withdraw-form'; import type { Asset } from '@vegaprotocol/assets'; import type { AccountFieldsFragment } from '@vegaprotocol/accounts'; import { useWithdrawAsset } from './use-withdraw-asset'; +import { ContractMethod, useGasPrice } from '@vegaprotocol/web3'; export interface WithdrawManagerProps { assets: Asset[]; @@ -20,6 +21,8 @@ export const WithdrawManager = ({ }: WithdrawManagerProps) => { const { asset, balance, min, threshold, delay, handleSelectAsset } = useWithdrawAsset(assets, accounts, assetId); + const gasPrice = useGasPrice(ContractMethod.WITHDRAW_ASSET); + return ( ); };