diff --git a/.env.example b/.env.example index 247e73171..30aebd69a 100644 --- a/.env.example +++ b/.env.example @@ -128,3 +128,8 @@ REACT_APP_DISABLED_CHAINS_FOR_AVAILABLE_ROUTES= # Comma-separated list of wallet addresses to block from UI REACT_APP_WALLET_BLACKLIST= + +# Gas estimation padding multiplier. +# JSON with format: {[chainId]: } +# e.g: { "1": 1.1, "10": 1.05 } +REACT_APP_GAS_ESTIMATION_MULTIPLIER_PER_CHAIN={"1": 1.1} diff --git a/src/utils/constants.ts b/src/utils/constants.ts index cda9b1eaa..227602a1e 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -376,9 +376,6 @@ export const tokenList = [ ]; // process.env variables -export const gasEstimationMultiplier = Number( - process.env.REACT_APP_GAS_ESTIMATION_MULTIPLIER || 2 -); export const rewardsApiUrl = process.env.REACT_APP_REWARDS_API_URL || "https://api.across.to"; export const airdropWindowIndex = Number( @@ -643,9 +640,10 @@ export const rewardTiers = [ export const secondsPerYear = 31557600; export const secondsPerDay = 86400; // 60 sec/min * 60 min/hr * 24 hr/day -export const gasMultiplier = process.env.REACT_APP_GAS_ESTIMATION_MULTIPLIER - ? Number(process.env.REACT_APP_GAS_ESTIMATION_MULTIPLIER) - : undefined; +export const gasMultiplierPerChain: Record = process.env + .REACT_APP_GAS_ESTIMATION_MULTIPLIER_PER_CHAIN + ? JSON.parse(process.env.REACT_APP_GAS_ESTIMATION_MULTIPLIER_PER_CHAIN) + : {}; export const suggestedFeesDeviationBufferMultiplier = !Number.isNaN( Number( @@ -698,3 +696,6 @@ export const disabledChainIdsForAvailableRoutes = ( export const walletBlacklist = (process.env.REACT_APP_WALLET_BLACKLIST || "") .split(",") .map((address) => address.toLowerCase()); + +// Fallback gas costs for when the gas estimation fails +export const fallbackEstimatedGasCosts = ethers.utils.parseEther("0.01"); diff --git a/src/utils/transactions.ts b/src/utils/transactions.ts index 456c29a0a..6f6548d7c 100644 --- a/src/utils/transactions.ts +++ b/src/utils/transactions.ts @@ -1,6 +1,10 @@ import { Contract, ContractTransaction, ethers } from "ethers"; import { parseEther } from "ethers/lib/utils"; -import { fixedPointAdjustment, gasMultiplier } from "./constants"; +import { + fixedPointAdjustment, + gasMultiplierPerChain, + hubPoolChainId, +} from "./constants"; /** * This function takes a raw transaction and a signer and returns the result of signing the transaction. @@ -26,34 +30,53 @@ export async function sendSignedTransaction( type Transaction = Promise; +export async function getPaddedGasEstimation( + chainId: number, + contract: Contract, + method: string, + ...args: any[] +) { + const gasMultiplier = gasMultiplierPerChain[chainId]; + /* If the gas multiplier hasn't been set, run this function as a normal tx */ + if (!gasMultiplier) { + return contract.estimateGas[method](...args); + } else { + // Estimate the gas with the provided estimateGas logic + const gasEstimation = await contract.estimateGas[method](...args); + // Factor in the padding + const gasToRecommend = gasEstimation + .mul(parseEther(String(gasMultiplier))) + .div(fixedPointAdjustment); + return gasToRecommend; + } +} + /** * Pads the gas estimation by a fixed amount dictated in the `REACT_SEND_TXN_GAS_ESTIMATION_MULTIPLIER` env var * @param contract The contract that this transaction will originate from * @param method The specific call method * @returns A completed or failed transaction */ -export function sendWithPaddedGas(contract: Contract, method: string) { +export function sendWithPaddedGas( + contract: Contract, + method: string, + chainId: number = hubPoolChainId +) { /** * Executes a given smart contract method with padded gas. * @param args The arguments to supply this smart contract call * @returns A contract transaction result. */ const fn = async (...args: any[]): Transaction => { - /* If the gas multiplier hasn't been set, run this function as a normal tx */ - if (!gasMultiplier) { - return contract[method](...args) as Promise; - } else { - // Estimate the gas with the provided estimateGas logic - const gasEstimation = await contract.estimateGas[method](...args); - // Factor in the padding - const gasToRecommend = gasEstimation - .mul(parseEther(String(gasMultiplier))) - .div(fixedPointAdjustment); - // Call the tx with the padded gas - return contract[method](...args, { - gasLimit: gasToRecommend, - }); - } + const gasToRecommend = await getPaddedGasEstimation( + chainId, + contract, + method, + args + ); + return contract[method](...args, { + gasLimit: gasToRecommend, + }); }; return fn; } diff --git a/src/views/Bridge/hooks/useAmountInput.ts b/src/views/Bridge/hooks/useAmountInput.ts index 73dccce7c..72b46d2a7 100644 --- a/src/views/Bridge/hooks/useAmountInput.ts +++ b/src/views/Bridge/hooks/useAmountInput.ts @@ -9,6 +9,7 @@ import { validateBridgeAmount, areTokensInterchangeable, } from "../utils"; +import { useMaxBalance } from "./useMaxBalance"; export function useAmountInput(selectedRoute: Route) { const [userAmountInput, setUserAmountInput] = useState(""); @@ -25,19 +26,21 @@ export function useAmountInput(selectedRoute: Route) { selectedRoute.fromChain ); + const { data: maxBalance } = useMaxBalance(selectedRoute); + const token = getConfig().getTokenInfoBySymbol( selectedRoute.fromChain, selectedRoute.fromTokenSymbol ); const handleClickMaxBalance = useCallback(() => { - if (balance) { - setUserAmountInput(utils.formatUnits(balance, token.decimals)); + if (maxBalance) { + setUserAmountInput(utils.formatUnits(maxBalance, token.decimals)); addToAmpliQueue(() => { trackMaxButtonClicked("bridgeForm"); }); } - }, [balance, token.decimals, addToAmpliQueue]); + }, [maxBalance, token.decimals, addToAmpliQueue]); const handleChangeAmountInput = useCallback((changedInput: string) => { setUserAmountInput(changedInput); @@ -78,13 +81,14 @@ export function useAmountInput(selectedRoute: Route) { userAmountInput, parsedAmount, balance, + maxBalance, }; } export function useValidAmount( parsedAmount?: BigNumber, isAmountTooLow?: boolean, - currentBalance?: BigNumber, + maxBalance?: BigNumber, maxDeposit?: BigNumber ) { const [validationError, setValidationError] = useState< @@ -95,11 +99,11 @@ export function useValidAmount( const { error } = validateBridgeAmount( parsedAmount, isAmountTooLow, - currentBalance, + maxBalance, maxDeposit ); setValidationError(error); - }, [parsedAmount, isAmountTooLow, currentBalance, maxDeposit]); + }, [parsedAmount, isAmountTooLow, maxBalance, maxDeposit]); return { amountValidationError: validationError, diff --git a/src/views/Bridge/hooks/useBridge.ts b/src/views/Bridge/hooks/useBridge.ts index 12d551549..6b4edcab7 100644 --- a/src/views/Bridge/hooks/useBridge.ts +++ b/src/views/Bridge/hooks/useBridge.ts @@ -47,6 +47,7 @@ export function useBridge() { userAmountInput, parsedAmount, balance, + maxBalance, } = useAmountInput(selectedRoute); const { toAccount, setCustomToAddress } = useToAccount(selectedRoute.toChain); @@ -70,7 +71,7 @@ export function useBridge() { const { amountValidationError, isAmountValid } = useValidAmount( parsedAmount, quotedFees?.isAmountTooLow, - balance, + maxBalance, quotedLimits?.maxDeposit ); diff --git a/src/views/Bridge/hooks/useMaxBalance.ts b/src/views/Bridge/hooks/useMaxBalance.ts new file mode 100644 index 000000000..fefe08913 --- /dev/null +++ b/src/views/Bridge/hooks/useMaxBalance.ts @@ -0,0 +1,88 @@ +import { useQuery } from "react-query"; +import { BigNumber, providers, constants, utils } from "ethers"; + +import { useBalanceBySymbol, useConnection } from "hooks"; +import { + getConfig, + Route, + max, + getProvider, + fallbackEstimatedGasCosts, +} from "utils"; +import { getPaddedGasEstimation } from "utils/transactions"; + +const config = getConfig(); + +export function useMaxBalance(selectedRoute: Route) { + const { balance } = useBalanceBySymbol( + selectedRoute.fromTokenSymbol, + selectedRoute.fromChain + ); + const { account, signer } = useConnection(); + + return useQuery( + [ + "max-balance", + selectedRoute.fromTokenSymbol, + selectedRoute.fromChain, + account, + ], + async () => { + let maxBridgeAmount: BigNumber; + + if (account && balance && signer) { + maxBridgeAmount = + selectedRoute.fromTokenSymbol !== "ETH" + ? balance + : // For ETH, we need to take the gas costs into account before setting the max. bridgable amount + await estimateGasCostsForDeposit(selectedRoute, signer) + .then((estimatedGasCosts) => + max(balance.sub(estimatedGasCosts), 0) + ) + .catch((err) => { + console.error(err); + return max(balance.sub(fallbackEstimatedGasCosts), 0); + }); + } else { + maxBridgeAmount = constants.Zero; + } + + return maxBridgeAmount; + }, + { + enabled: Boolean(account && balance && signer), + } + ); +} + +async function estimateGasCostsForDeposit( + selectedRoute: Route, + signer: providers.JsonRpcSigner +) { + const provider = getProvider(selectedRoute.fromChain); + const spokePool = config.getSpokePool(selectedRoute.fromChain, signer); + const tokenInfo = config.getTokenInfoByAddress( + selectedRoute.fromChain, + selectedRoute.fromTokenAddress + ); + const amount = utils.parseUnits("0.000001", tokenInfo.decimals); + const argsForEstimation = { + recipient: await signer.getAddress(), + originToken: tokenInfo.address, + amount, + destinationChain: selectedRoute.toChain, + relayerFeePct: 0, + quoteTimestamp: BigNumber.from(Math.floor(Date.now() / 1000)).sub(60 * 60), + message: "0x", + maxCount: constants.MaxUint256, + }; + const paddedGasEstimation = await getPaddedGasEstimation( + selectedRoute.fromChain, + spokePool, + "deposit", + ...Object.values(argsForEstimation), + { value: selectedRoute.isNative ? amount : 0 } + ); + const gasPrice = await provider.getGasPrice(); + return gasPrice.mul(paddedGasEstimation); +}