From c5ce968f4ebc285f4a7aef720a1eaf05f7dd176f Mon Sep 17 00:00:00 2001 From: Ben Goldberg Date: Thu, 21 Mar 2024 15:48:14 -0700 Subject: [PATCH] NFT offers/mints gas estimation improvements (#5448) --- src/components/gas/GasSpeedButton.js | 9 +- src/graphql/queries/metadata.graphql | 11 +- src/handlers/nftOffers.ts | 110 -------------------- src/references/ethereum-units.json | 2 +- src/screens/NFTSingleOfferSheet/index.tsx | 118 +++++++++++++--------- src/screens/mints/MintSheet.tsx | 52 +++++----- 6 files changed, 111 insertions(+), 191 deletions(-) delete mode 100644 src/handlers/nftOffers.ts diff --git a/src/components/gas/GasSpeedButton.js b/src/components/gas/GasSpeedButton.js index 5bdf2bb63a2..9c6de6271cb 100644 --- a/src/components/gas/GasSpeedButton.js +++ b/src/components/gas/GasSpeedButton.js @@ -137,6 +137,7 @@ const GasSpeedButton = ({ validateGasParams, flashbotTransaction = false, crossChainServiceTime, + loading = false, }) => { const { colors } = useTheme(); const { navigate, goBack } = useNavigation(); @@ -178,7 +179,7 @@ const GasSpeedButton = ({ const formatGasPrice = useCallback( animatedValue => { - if (animatedValue === null || isNaN(animatedValue)) { + if (animatedValue === null || loading || isNaN(animatedValue)) { return 0; } !gasPriceReady && setGasPriceReady(true); @@ -199,7 +200,7 @@ const GasSpeedButton = ({ }`; } }, - [gasPriceReady, isLegacyGasNetwork, nativeCurrencySymbol, nativeCurrency] + [loading, gasPriceReady, isLegacyGasNetwork, nativeCurrencySymbol, nativeCurrency] ); const openCustomOptionsRef = useRef(); @@ -232,7 +233,7 @@ const GasSpeedButton = ({ const renderGasPriceText = useCallback( animatedNumber => { - const priceText = animatedNumber === 0 ? lang.t('swap.loading') : animatedNumber; + const priceText = animatedNumber === 0 || loading ? lang.t('swap.loading') : animatedNumber; return ( ); }, - [theme, colors] + [loading, theme, colors] ); // I'M SHITTY CODE BUT GOT THINGS DONE REFACTOR ME ASAP diff --git a/src/graphql/queries/metadata.graphql b/src/graphql/queries/metadata.graphql index 6b335ac8252..ed9dd5de131 100644 --- a/src/graphql/queries/metadata.graphql +++ b/src/graphql/queries/metadata.graphql @@ -157,8 +157,8 @@ fragment simulationError on TransactionError { type } -query simulateTransactions($chainId: Int!, $transactions: [Transaction!], $domain: String!) { - simulateTransactions(chainID: $chainId, transactions: $transactions, domain: $domain) { +query simulateTransactions($chainId: Int!, $transactions: [Transaction!], $domain: String, $currency: String) { + simulateTransactions(chainID: $chainId, transactions: $transactions, domain: $domain, currency: $currency) { error { ...simulationError } @@ -166,6 +166,13 @@ query simulateTransactions($chainId: Int!, $transactions: [Transaction!], $domai result description } + gas { + used + estimate + } + report { + url + } simulation { in { ...change diff --git a/src/handlers/nftOffers.ts b/src/handlers/nftOffers.ts deleted file mode 100644 index b96b4bebc84..00000000000 --- a/src/handlers/nftOffers.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { Block, StaticJsonRpcProvider } from '@ethersproject/providers'; -import { estimateGasWithPadding, getProviderForNetwork, toHexNoLeadingZeros } from './web3'; -import { getRemoteConfig } from '@/model/remoteConfig'; -import { MaxUint256 } from '@ethersproject/constants'; -import { RainbowError, logger } from '@/logger'; -import { Network } from '@/helpers'; -import { getClosestGasEstimate } from './swap'; -import { ethUnits } from '@/references'; -import { NftOffer } from '@/graphql/__generated__/arc'; - -type TxData = { - to: string; - from: string; - data: string; -}; - -const getStateDiff = async (provider: StaticJsonRpcProvider, approval: TxData): Promise => { - const { number: blockNumber } = await (provider.getBlock as () => Promise)(); - const { trace_call_block_number_offset } = getRemoteConfig(); - - // trace_call default params - const callParams = [ - { - data: approval.data, - from: approval.from, - to: approval.to, - }, - ['stateDiff'], - toHexNoLeadingZeros(blockNumber - Number(trace_call_block_number_offset || 20)), - ]; - - const trace = await provider.send('trace_call', callParams); - - if (trace.stateDiff) { - const slotAddress = Object.keys(trace.stateDiff[approval.to]?.storage)?.[0]; - if (slotAddress) { - const formattedStateDiff = { - [approval.to]: { - stateDiff: { - [slotAddress]: MaxUint256.toHexString(), - }, - }, - }; - return formattedStateDiff; - } - } - logger.warn('Failed to get stateDiff for NFT Offer', { - trace: JSON.stringify(trace, null, 2), - }); -}; - -export const estimateNFTOfferGas = async ( - offer: NftOffer, - approval: TxData | undefined, - sale: TxData | undefined -): Promise => { - // rough gas estimate - const fallbackGas = - offer.network === Network.mainnet - ? ethUnits.mainnet_nft_offer_gas_fee_fallback.toString() - : ethUnits.l2_nft_offer_gas_fee_fallback.toString(); - const provider = await getProviderForNetwork(offer.network); - if (!sale) { - if (offer.marketplace.name !== 'Blur') { - // expecting sale tx data for all marketplaces except Blur - logger.warn('No sale tx data for NFT Offer'); - } - return fallbackGas; - } - if (!approval) { - return await estimateGasWithPadding(sale, null, null, provider); - } - if (offer.network !== Network.mainnet) { - return fallbackGas; - } - try { - const stateDiff = await getStateDiff(provider, approval); - - const gasLimit = await getClosestGasEstimate(async (gas: number) => { - const callParams = [ - { - data: sale.data, - from: sale.from, - gas: toHexNoLeadingZeros(gas), - gasPrice: toHexNoLeadingZeros(`100000000000`), - to: sale.to, - }, - 'latest', - ]; - - try { - await provider.send('eth_call', [...callParams, stateDiff]); - logger.info(`Estimate worked with gasLimit: ${gas}`); - return true; - } catch (e) { - logger.info(`Estimate failed with gasLimit: ${gas}. Trying with different amounts...`); - return false; - } - }); - - if (gasLimit && gasLimit >= ethUnits.basic_swap) { - return gasLimit.toString(); - } else { - logger.error(new RainbowError('Could not find a gas estimate for NFT Offer')); - } - } catch (e) { - logger.error(new RainbowError(`Blew up trying to get state diff for NFT Offer.\nerror: ${e}`)); - } - return fallbackGas; -}; diff --git a/src/references/ethereum-units.json b/src/references/ethereum-units.json index b417239b39f..56409c4e4ae 100644 --- a/src/references/ethereum-units.json +++ b/src/references/ethereum-units.json @@ -45,6 +45,6 @@ "mether": 1000000000000000000000000, "gether": 1000000000000000000000000000, "tether": 1000000000000000000000000000000, - "mainnet_nft_offer_gas_fee_fallback": 600000, + "mainnet_nft_offer_gas_fee_fallback": 300000, "l2_nft_offer_gas_fee_fallback": 2000000 } diff --git a/src/screens/NFTSingleOfferSheet/index.tsx b/src/screens/NFTSingleOfferSheet/index.tsx index 70596d13ea1..05cce58e4f8 100644 --- a/src/screens/NFTSingleOfferSheet/index.tsx +++ b/src/screens/NFTSingleOfferSheet/index.tsx @@ -38,7 +38,6 @@ import { privateKeyToAccount } from 'viem/accounts'; import { createWalletClient, http } from 'viem'; import { RainbowError, logger } from '@/logger'; -import { estimateNFTOfferGas } from '@/handlers/nftOffers'; import { useTheme } from '@/theme'; import { Network } from '@/helpers'; import { getNetworkObj } from '@/networks'; @@ -51,6 +50,9 @@ import RainbowCoinIcon from '@/components/coin-icon/RainbowCoinIcon'; import { addNewTransaction } from '@/state/pendingTransactions'; import { getUniqueId } from '@/utils/ethereumUtils'; import { getNextNonce } from '@/state/nonces'; +import { metadataPOSTClient } from '@/graphql'; +import { ethUnits } from '@/references'; +import { Transaction } from '@/graphql/__generated__/metadataPOST'; const NFT_IMAGE_HEIGHT = 160; const TWO_HOURS_MS = 2 * 60 * 60 * 1000; @@ -99,6 +101,7 @@ export function NFTSingleOfferSheet() { currency: nativeCurrency, }); + const [isGasReady, setIsGasReady] = useState(false); const [height, setHeight] = useState(0); const [isAccepting, setIsAccepting] = useState(false); const txsRef = useRef([]); @@ -176,68 +179,82 @@ export function NFTSingleOfferSheet() { }, [offer.validUntil]); const estimateGas = useCallback(() => { - const networkObj = getNetworkObj(network); - const signer = createWalletClient({ - // @ts-ignore - account: accountAddress, - chain: networkObj, - transport: http(networkObj.rpc), - }); - getClient()?.actions.acceptOffer({ - items: [ - { - token: `${offer.nft.contractAddress}:${offer.nft.tokenId}`, - quantity: 1, - }, - ], - options: feeParam - ? { - feesOnTop: [feeParam], - } - : undefined, - chainId: networkObj.id, - precheck: true, - wallet: signer, - onProgress: async (steps: Execute['steps']) => { - let sale; - let approval; - steps.forEach(step => - step.items?.forEach(async item => { - if (item.data?.data && item.data?.to && item.data?.from) { - if (step.id === 'sale') { - sale = { - to: item.data.to, - from: item.data.from, - data: item.data.data, - }; - } else if (step.id === 'nft-approval') { - approval = { + try { + const networkObj = getNetworkObj(network); + const signer = createWalletClient({ + // @ts-ignore + account: accountAddress, + chain: networkObj, + transport: http(networkObj.rpc), + }); + getClient()?.actions.acceptOffer({ + items: [ + { + token: `${offer.nft.contractAddress}:${offer.nft.tokenId}`, + quantity: 1, + }, + ], + options: feeParam + ? { + feesOnTop: [feeParam], + } + : undefined, + chainId: networkObj.id, + precheck: true, + wallet: signer, + onProgress: async (steps: Execute['steps']) => { + let reservoirEstimate = 0; + const txs: Transaction[] = []; + const fallbackEstimate = + offer.network === Network.mainnet ? ethUnits.mainnet_nft_offer_gas_fee_fallback : ethUnits.l2_nft_offer_gas_fee_fallback; + steps.forEach(step => + step.items?.forEach(item => { + if (item?.data?.to && item?.data?.from && item?.data?.data) { + txs.push({ to: item.data.to, from: item.data.from, data: item.data.data, - }; + value: item.data.value ?? '0x0', + }); } - } - }) - ); - const gas = await estimateNFTOfferGas(offer, approval, sale); - if (gas) { - updateTxFee(gas, null); - startPollingGasFees(network); - } - }, - }); - }, [accountAddress, feeParam, network, offer, startPollingGasFees, updateTxFee]); + // @ts-ignore missing from reservoir type + const txEstimate = item.gasEstimate; + if (typeof txEstimate === 'number') { + reservoirEstimate += txEstimate; + } + }) + ); + const txSimEstimate = parseInt( + ( + await metadataPOSTClient.simulateTransactions({ + chainId: networkObj.id, + transactions: txs, + }) + )?.simulateTransactions?.[0]?.gas?.estimate ?? '0x0', + 16 + ); + const estimate = txSimEstimate || reservoirEstimate || fallbackEstimate; + if (estimate) { + updateTxFee(estimate, null); + setIsGasReady(true); + } + }, + }); + } catch { + logger.error(new RainbowError('NFT Offer: Failed to estimate gas')); + } + }, [accountAddress, feeParam, network, offer, updateTxFee]); // estimate gas useEffect(() => { if (!isReadOnlyWallet && !isExpired) { + startPollingGasFees(network); estimateGas(); } return () => { stopPollingGasFees(); }; - }, [estimateGas, isExpired, isReadOnlyWallet, stopPollingGasFees]); + }, [estimateGas, isExpired, isReadOnlyWallet, network, startPollingGasFees, stopPollingGasFees, updateTxFee]); const acceptOffer = useCallback(async () => { logger.info(`Initiating sale of NFT ${offer.nft.contractAddress}:${offer.nft.tokenId}`); @@ -706,6 +723,7 @@ export function NFTSingleOfferSheet() { asset={{ color: offer.nft.predominantColor || buttonColorFallback, }} + loading={!isGasReady} horizontalPadding={0} currentNetwork={offer.network} theme={theme.isDarkMode ? 'dark' : 'light'} diff --git a/src/screens/mints/MintSheet.tsx b/src/screens/mints/MintSheet.tsx index 52a4e5effc3..f3f473b2d6f 100644 --- a/src/screens/mints/MintSheet.tsx +++ b/src/screens/mints/MintSheet.tsx @@ -26,7 +26,7 @@ import { ButtonPressAnimation } from '@/components/animations'; import { useFocusEffect, useRoute } from '@react-navigation/native'; import { ReservoirCollection } from '@/graphql/__generated__/arcDev'; import { format } from 'date-fns'; -import { NewTransaction, RainbowTransaction } from '@/entities'; +import { NewTransaction } from '@/entities'; import * as i18n from '@/languages'; import { analyticsV2 } from '@/analytics'; import { event } from '@/analytics/event'; @@ -49,13 +49,14 @@ import { } from '@/helpers/utilities'; import { RainbowError, logger } from '@/logger'; import { QuantityButton } from './components/QuantityButton'; -import { estimateGas, getProviderForNetwork } from '@/handlers/web3'; import { getRainbowFeeAddress } from '@/resources/reservoir/utils'; import { IS_ANDROID, IS_IOS } from '@/env'; import { EthCoinIcon } from '@/components/coin-icon/EthCoinIcon'; import { addNewTransaction } from '@/state/pendingTransactions'; import { getUniqueId } from '@/utils/ethereumUtils'; import { getNextNonce } from '@/state/nonces'; +import { metadataPOSTClient } from '@/graphql'; +import { Transaction } from '@/graphql/__generated__/metadataPOST'; const NFT_IMAGE_HEIGHT = 250; // inset * 2 -> 28 *2 @@ -138,6 +139,7 @@ const MintSheet = () => { const [ensName, setENSName] = useState(''); const [mintStatus, setMintStatus] = useState<'none' | 'minting' | 'minted' | 'error'>('none'); const txRef = useRef(); + const [isGasReady, setIsGasReady] = useState(false); const { data: ensAvatar } = useENSAvatar(ensName, { enabled: Boolean(ensName), @@ -245,7 +247,6 @@ const MintSheet = () => { useEffect(() => { const estimateMintGas = async () => { const networkObj = getNetworkObj(currentNetwork); - const provider = await getProviderForNetwork(currentNetwork); const signer = createWalletClient({ account: accountAddress, chain: networkObj, @@ -258,35 +259,37 @@ const MintSheet = () => { chainId: networkObj.id, precheck: true, onProgress: async (steps: Execute['steps']) => { + const txs: Transaction[] = []; steps.forEach(step => { if (step.error) { logger.error(new RainbowError(`NFT Mints: Gas Step Error: ${step.error}`)); return; } - step.items?.forEach(async item => { - // could add safety here if unable to calc gas limit - const tx = { - to: item.data?.to, - from: item.data?.from, - data: item.data?.data, - value: item.data?.value, - }; - const gas = await estimateGas(tx, provider); - let l1GasFeeOptimism = null; - // add l1Fee for OP Chains - if (getNetworkObj(currentNetwork).gas.OptimismTxFee) { - l1GasFeeOptimism = await ethereumUtils.calculateL1FeeOptimism(tx as RainbowTransaction, provider); - } - if (gas) { - setGasError(false); - if (l1GasFeeOptimism) { - updateTxFee(gas, null, l1GasFeeOptimism); - } else { - updateTxFee(gas, null); - } + step.items?.forEach(item => { + if (item?.data?.to && item?.data?.from && item?.data?.data) { + txs.push({ + to: item.data?.to, + from: item.data?.from, + data: item.data?.data, + value: item.data?.value ?? '0x0', + }); } }); }); + const txSimEstimate = parseInt( + ( + await metadataPOSTClient.simulateTransactions({ + chainId: networkObj.id, + transactions: txs, + }) + )?.simulateTransactions?.[0]?.gas?.estimate ?? '0x0', + 16 + ); + if (txSimEstimate) { + setGasError(false); + updateTxFee(txSimEstimate, null); + setIsGasReady(true); + } }, }); } catch (e) { @@ -629,6 +632,7 @@ const MintSheet = () => { horizontalPadding={0} currentNetwork={currentNetwork} theme={'dark'} + loading={!isGasReady} marginBottom={0} />