feat(trading): gas fee estimation for withdraw transaction (#5668)

This commit is contained in:
Art 2024-02-01 09:07:13 +01:00 committed by GitHub
parent a49139f127
commit 2002731c52
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 464 additions and 3 deletions

View File

@ -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<AssetFieldsFragment, 'symbol' | 'decimals' | 'quantum'>;
/**
* 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
};
};

View File

@ -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"
}

View File

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

View File

@ -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, string> = {
[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
);

View File

@ -4,3 +4,4 @@ export * from './range';
export * from './size';
export * from './strings';
export * from './trigger';
export * from './ether';

View File

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

View File

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

View File

@ -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';

View File

@ -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<ReturnType<typeof useWeb3React>['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<GasData | undefined>(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<typeof setInterval>;
if (interval > 0) {
i = setInterval(() => {
retrieve();
}, interval);
}
return () => {
if (i) clearInterval(i);
};
}, [account, config, interval, method, provider]);
return gas;
};

View File

@ -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}
/>
</div>
)}

View File

@ -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 ? (
<GasPrice
gasPrice={gas}
amount={{
value: removeDecimal(amount, asset.decimals),
quantum: asset.quantum,
}}
/>
) : (
'-'
),
},
];
return (
@ -91,3 +121,117 @@ export const WithdrawLimits = ({
</KeyValueTable>
);
};
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 = (
<span>
{formatEther(uBasePrice, 0, true)} - {formatEther(uMaxPrice)}
</span>
);
// displays range as ETH when it's greater that 1000000 gwei
if (uBasePrice[0].isGreaterThan(1e6)) {
range = (
<span className="flex flex-col font-mono md:text-[11px]">
<span>
{t('min')}: {asETH(basePrice)}
</span>
<span>
{t('max')}: {asETH(maxPrice)}
</span>
</span>
);
}
return (
<div className={classNames('flex flex-col items-end self-end')}>
<Tooltip description={t('The current gas price range')}>
<span>
{/* base price per gas unit */}
{formatEther(uBasePricePerGas, 0, true)} -{' '}
{formatEther(uMaxPricePerGas)} / gas
</span>
</Tooltip>
<Tooltip
description={
<div className="flex flex-col gap-1">
{isExpensive && (
<span className={classNames(expensiveClassNames)}>
{t(
"It seems that the current gas prices are exceeding the amount you're trying to withdraw"
)}{' '}
<strong>
(~{formatNumber(withdrawalAmountQUSD, 2)} qUSD)
</strong>
.
</span>
)}
<span>
{formatNumber(gas)} gas &times; {asETH(basePricePerGas)} <br />{' '}
= {asETH(basePrice)}
</span>
<span>
{formatNumber(gas)} gas &times; {asETH(maxPricePerGas)} <br /> ={' '}
{asETH(maxPrice)}
</span>
</div>
}
>
<span className={classNames(expensiveClassNames, 'text-xs')}>
{range}
</span>
</Tooltip>
<span className="text-muted text-xs">
~{formatNumber(basePriceQUSD, 2)} - {formatNumber(maxPriceQUSD, 2)}{' '}
qUSD
</span>
</div>
);
}
return '-';
};

View File

@ -38,6 +38,7 @@ jest.mock('@vegaprotocol/web3', () => ({
useGetWithdrawDelay: () => {
return () => Promise.resolve(10000);
},
useGasPrice: () => undefined,
}));
describe('WithdrawManager', () => {

View File

@ -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 (
<WithdrawForm
selectedAsset={asset}
@ -30,6 +33,7 @@ export const WithdrawManager = ({
submitWithdraw={submit}
threshold={threshold}
delay={delay}
gasPrice={gasPrice}
/>
);
};