diff --git a/src/components/new-safe/create/steps/ReviewStep/index.tsx b/src/components/new-safe/create/steps/ReviewStep/index.tsx index 54a2bfc71e..c5ebf035f4 100644 --- a/src/components/new-safe/create/steps/ReviewStep/index.tsx +++ b/src/components/new-safe/create/steps/ReviewStep/index.tsx @@ -26,6 +26,7 @@ import { ExecutionMethodSelector, ExecutionMethod } from '@/components/tx/Execut import { useLeastRemainingRelays } from '@/hooks/useRemainingRelays' import classnames from 'classnames' import { hasRemainingRelays } from '@/utils/relaying' +import { BigNumber } from 'ethers' const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps) => { const isWrongChain = useIsWrongChain() @@ -33,7 +34,7 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps Date.now(), []) const [_, setPendingSafe] = useLocalStorage(SAFE_PENDING_CREATION_STORAGE_KEY) const [executionMethod, setExecutionMethod] = useState(ExecutionMethod.RELAY) @@ -55,9 +56,20 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps 0.001' const handleBack = () => { diff --git a/src/components/new-safe/create/steps/StatusStep/__tests__/useSafeCreation.test.ts b/src/components/new-safe/create/steps/StatusStep/__tests__/useSafeCreation.test.ts index a6a342b433..4c3b3e4c58 100644 --- a/src/components/new-safe/create/steps/StatusStep/__tests__/useSafeCreation.test.ts +++ b/src/components/new-safe/create/steps/StatusStep/__tests__/useSafeCreation.test.ts @@ -14,6 +14,8 @@ import { waitFor } from '@testing-library/react' import type Safe from '@safe-global/safe-core-sdk' import { hexZeroPad } from 'ethers/lib/utils' import type CompatibilityFallbackHandlerEthersContract from '@safe-global/safe-ethers-lib/dist/src/contracts/CompatibilityFallbackHandler/CompatibilityFallbackHandlerEthersContract' +import { FEATURES } from '@/utils/chains' +import * as gasPrice from '@/hooks/useGasPrice' const mockSafeInfo = { data: '0x', @@ -45,6 +47,7 @@ describe('useSafeCreation', () => { const mockChain = { chainId: '4', + features: [], } as unknown as ChainInfo jest.spyOn(web3, 'useWeb3').mockImplementation(() => mockProvider) @@ -56,15 +59,87 @@ describe('useSafeCreation', () => { jest .spyOn(contracts, 'getReadOnlyFallbackHandlerContract') .mockReturnValue({ getAddress: () => hexZeroPad('0x123', 20) } as CompatibilityFallbackHandlerEthersContract) + jest + .spyOn(gasPrice, 'default') + .mockReturnValue([{ maxFeePerGas: BigNumber.from(123), maxPriorityFeePerGas: undefined }, undefined, false]) }) - it('should create a safe if there is no txHash and status is AWAITING', async () => { + it('should create a safe with gas params if there is no txHash and status is AWAITING', async () => { const createSafeSpy = jest.spyOn(logic, 'createNewSafe').mockReturnValue(Promise.resolve({} as Safe)) renderHook(() => useSafeCreation(mockPendingSafe, mockSetPendingSafe, mockStatus, mockSetStatus, false)) await waitFor(() => { expect(createSafeSpy).toHaveBeenCalled() + + const { gasPrice, maxFeePerGas, maxPriorityFeePerGas } = createSafeSpy.mock.calls[0][1].options || {} + + expect(gasPrice).toBe('123') + + expect(maxFeePerGas).toBeUndefined() + expect(maxPriorityFeePerGas).toBeUndefined() + }) + }) + + it('should create a safe with EIP-1559 gas params if there is no txHash and status is AWAITING', async () => { + jest + .spyOn(gasPrice, 'default') + .mockReturnValue([ + { maxFeePerGas: BigNumber.from(123), maxPriorityFeePerGas: BigNumber.from(456) }, + undefined, + false, + ]) + + jest.spyOn(chain, 'useCurrentChain').mockImplementation( + () => + ({ + chainId: '4', + features: [FEATURES.EIP1559], + } as unknown as ChainInfo), + ) + const createSafeSpy = jest.spyOn(logic, 'createNewSafe').mockReturnValue(Promise.resolve({} as Safe)) + + renderHook(() => useSafeCreation(mockPendingSafe, mockSetPendingSafe, mockStatus, mockSetStatus, false)) + + await waitFor(() => { + expect(createSafeSpy).toHaveBeenCalled() + + const { gasPrice, maxFeePerGas, maxPriorityFeePerGas } = createSafeSpy.mock.calls[0][1].options || {} + + expect(maxFeePerGas).toBe('123') + expect(maxPriorityFeePerGas).toBe('456') + + expect(gasPrice).toBeUndefined() + }) + }) + + it('should create a safe with no gas params if the gas estimation threw, there is no txHash and status is AWAITING', async () => { + jest.spyOn(gasPrice, 'default').mockReturnValue([undefined, Error('Error for testing'), false]) + + const createSafeSpy = jest.spyOn(logic, 'createNewSafe').mockReturnValue(Promise.resolve({} as Safe)) + + renderHook(() => useSafeCreation(mockPendingSafe, mockSetPendingSafe, mockStatus, mockSetStatus, false)) + + await waitFor(() => { + expect(createSafeSpy).toHaveBeenCalled() + + const { gasPrice, maxFeePerGas, maxPriorityFeePerGas } = createSafeSpy.mock.calls[0][1].options || {} + + expect(gasPrice).toBeUndefined() + expect(maxFeePerGas).toBeUndefined() + expect(maxPriorityFeePerGas).toBeUndefined() + }) + }) + + it('should not create a safe if there is no txHash, status is AWAITING but gas is loading', async () => { + jest.spyOn(gasPrice, 'default').mockReturnValue([undefined, undefined, true]) + + const createSafeSpy = jest.spyOn(logic, 'createNewSafe').mockReturnValue(Promise.resolve({} as Safe)) + + renderHook(() => useSafeCreation(mockPendingSafe, mockSetPendingSafe, mockStatus, mockSetStatus, false)) + + await waitFor(() => { + expect(createSafeSpy).not.toHaveBeenCalled() }) }) diff --git a/src/components/new-safe/create/steps/StatusStep/useSafeCreation.ts b/src/components/new-safe/create/steps/StatusStep/useSafeCreation.ts index 35a73878a7..ec4823a5fa 100644 --- a/src/components/new-safe/create/steps/StatusStep/useSafeCreation.ts +++ b/src/components/new-safe/create/steps/StatusStep/useSafeCreation.ts @@ -20,6 +20,10 @@ import { useAppDispatch } from '@/store' import { closeByGroupKey } from '@/store/notificationsSlice' import { CREATE_SAFE_EVENTS, trackEvent } from '@/services/analytics' import { waitForCreateSafeTx } from '@/services/tx/txMonitor' +import useGasPrice from '@/hooks/useGasPrice' +import { hasFeature } from '@/utils/chains' +import { FEATURES } from '@safe-global/safe-gateway-typescript-sdk' +import type { DeploySafeProps } from '@safe-global/safe-core-sdk' export enum SafeCreationStatus { AWAITING, @@ -48,6 +52,12 @@ export const useSafeCreation = ( const provider = useWeb3() const web3ReadOnly = useWeb3ReadOnly() const chain = useCurrentChain() + const [gasPrice, , gasPriceLoading] = useGasPrice() + + const maxFeePerGas = gasPrice?.maxFeePerGas + const maxPriorityFeePerGas = gasPrice?.maxPriorityFeePerGas + + const isEIP1559 = chain && hasFeature(chain, FEATURES.EIP1559) const createSafeCallback = useCallback( async (txHash: string, tx: PendingSafeTx) => { @@ -59,7 +69,7 @@ export const useSafeCreation = ( ) const handleCreateSafe = useCallback(async () => { - if (!pendingSafe || !provider || !chain || !wallet || isCreating) return + if (!pendingSafe || !provider || !chain || !wallet || isCreating || gasPriceLoading) return setIsCreating(true) dispatch(closeByGroupKey({ groupKey: SAFE_CREATION_ERROR_KEY })) @@ -87,7 +97,14 @@ export const useSafeCreation = ( chain.chainId, ) - await createNewSafe(provider, safeParams) + const options: DeploySafeProps['options'] = isEIP1559 + ? { maxFeePerGas: maxFeePerGas?.toString(), maxPriorityFeePerGas: maxPriorityFeePerGas?.toString() } + : { gasPrice: maxFeePerGas?.toString() } + + await createNewSafe(provider, { + ...safeParams, + options, + }) setStatus(SafeCreationStatus.SUCCESS) } } catch (err) { @@ -106,7 +123,11 @@ export const useSafeCreation = ( chain, createSafeCallback, dispatch, + gasPriceLoading, isCreating, + isEIP1559, + maxFeePerGas, + maxPriorityFeePerGas, pendingSafe, provider, setPendingSafe, diff --git a/src/components/tx/AdvancedParams/useAdvancedParams.ts b/src/components/tx/AdvancedParams/useAdvancedParams.ts index a03773b7b5..1d876384b4 100644 --- a/src/components/tx/AdvancedParams/useAdvancedParams.ts +++ b/src/components/tx/AdvancedParams/useAdvancedParams.ts @@ -9,7 +9,7 @@ export const useAdvancedParams = ({ safeTxGas, }: AdvancedParameters): [AdvancedParameters, (params: AdvancedParameters) => void] => { const [manualParams, setManualParams] = useState() - const { maxFeePerGas, maxPriorityFeePerGas } = useGasPrice() + const [gasPrice] = useGasPrice() const userNonce = useUserNonce() const advancedParams: AdvancedParameters = useMemo( @@ -17,11 +17,11 @@ export const useAdvancedParams = ({ nonce: manualParams?.nonce ?? nonce, userNonce: manualParams?.userNonce ?? userNonce, gasLimit: manualParams?.gasLimit ?? gasLimit, - maxFeePerGas: manualParams?.maxFeePerGas ?? maxFeePerGas, - maxPriorityFeePerGas: manualParams?.maxPriorityFeePerGas ?? maxPriorityFeePerGas, + maxFeePerGas: manualParams?.maxFeePerGas ?? gasPrice?.maxFeePerGas, + maxPriorityFeePerGas: manualParams?.maxPriorityFeePerGas ?? gasPrice?.maxPriorityFeePerGas, safeTxGas: manualParams?.safeTxGas ?? safeTxGas, }), - [manualParams, nonce, userNonce, gasLimit, maxFeePerGas, maxPriorityFeePerGas, safeTxGas], + [manualParams, nonce, userNonce, gasLimit, gasPrice?.maxFeePerGas, gasPrice?.maxPriorityFeePerGas, safeTxGas], ) return [advancedParams, setManualParams] diff --git a/src/components/tx/modals/BatchExecuteModal/ReviewBatchExecute.tsx b/src/components/tx/modals/BatchExecuteModal/ReviewBatchExecute.tsx index b38e0603d9..8c505df00c 100644 --- a/src/components/tx/modals/BatchExecuteModal/ReviewBatchExecute.tsx +++ b/src/components/tx/modals/BatchExecuteModal/ReviewBatchExecute.tsx @@ -1,4 +1,5 @@ import useAsync from '@/hooks/useAsync' +import { FEATURES } from '@safe-global/safe-gateway-typescript-sdk' import type { TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' import { getMultiSendCallOnlyContract } from '@/services/contracts/safeContracts' import { useCurrentChain } from '@/hooks/useChains' @@ -21,6 +22,9 @@ import useOnboard from '@/hooks/wallets/useOnboard' import { WrongChainWarning } from '@/components/tx/WrongChainWarning' import { useWeb3 } from '@/hooks/wallets/web3' import { hasRemainingRelays } from '@/utils/relaying' +import useGasPrice from '@/hooks/useGasPrice' +import { hasFeature } from '@/utils/chains' +import type { PayableOverrides } from 'ethers' const ReviewBatchExecute = ({ data, onSubmit }: { data: BatchExecuteData; onSubmit: (data: null) => void }) => { const [isSubmittable, setIsSubmittable] = useState(true) @@ -29,6 +33,12 @@ const ReviewBatchExecute = ({ data, onSubmit }: { data: BatchExecuteData; onSubm const chain = useCurrentChain() const { safe } = useSafeInfo() const [relays] = useRelaysBySafe() + const [gasPrice, , gasPriceLoading] = useGasPrice() + + const maxFeePerGas = gasPrice?.maxFeePerGas + const maxPriorityFeePerGas = gasPrice?.maxPriorityFeePerGas + + const isEIP1559 = chain && hasFeature(chain, FEATURES.EIP1559) // Chain has relaying feature and available relays const canRelay = hasRemainingRelays(relays) @@ -58,7 +68,11 @@ const ReviewBatchExecute = ({ data, onSubmit }: { data: BatchExecuteData; onSubm }, [txsWithDetails, multiSendTxs]) const onExecute = async () => { - if (!onboard || !multiSendTxData || !multiSendContract || !txsWithDetails) return + if (!onboard || !multiSendTxData || !multiSendContract || !txsWithDetails || gasPriceLoading) return + + const overrides: PayableOverrides = isEIP1559 + ? { maxFeePerGas: maxFeePerGas?.toString(), maxPriorityFeePerGas: maxPriorityFeePerGas?.toString() } + : { gasPrice: maxFeePerGas?.toString() } await dispatchBatchExecution( txsWithDetails, @@ -67,6 +81,7 @@ const ReviewBatchExecute = ({ data, onSubmit }: { data: BatchExecuteData; onSubm onboard, safe.chainId, safe.address.value, + overrides, ) onSubmit(null) } @@ -100,7 +115,7 @@ const ReviewBatchExecute = ({ data, onSubmit }: { data: BatchExecuteData; onSubm } } - const submitDisabled = loading || !isSubmittable + const submitDisabled = loading || !isSubmittable || gasPriceLoading return (
diff --git a/src/hooks/__tests__/useGasPrice.test.ts b/src/hooks/__tests__/useGasPrice.test.ts index 987413397c..d3a3d5dd24 100644 --- a/src/hooks/__tests__/useGasPrice.test.ts +++ b/src/hooks/__tests__/useGasPrice.test.ts @@ -74,6 +74,9 @@ describe('useGasPrice', () => { // render the hook const { result } = renderHook(() => useGasPrice()) + // assert the hook is loading + expect(result.current[2]).toBe(true) + // wait for the hook to fetch the gas price await act(async () => { await Promise.resolve() @@ -81,11 +84,14 @@ describe('useGasPrice', () => { expect(fetch).toHaveBeenCalledWith('https://api.etherscan.io/api?module=gastracker&action=gasoracle') + // assert the hook is not loading + expect(result.current[2]).toBe(false) + // assert the gas price is correct - expect(result.current.maxFeePerGas?.toString()).toBe('47000000000') + expect(result.current[0]?.maxFeePerGas?.toString()).toBe('47000000000') // assert the priority fee is correct - expect(result.current.maxPriorityFeePerGas?.toString()).toEqual('4975') + expect(result.current[0]?.maxPriorityFeePerGas?.toString()).toEqual('4975') }) it('should return the fetched gas price from the second oracle if the first one fails', async () => { @@ -110,6 +116,9 @@ describe('useGasPrice', () => { // render the hook const { result } = renderHook(() => useGasPrice()) + // assert the hook is loading + expect(result.current[2]).toBe(true) + // wait for the hook to fetch the gas price await act(async () => { await Promise.resolve() @@ -118,11 +127,14 @@ describe('useGasPrice', () => { expect(fetch).toHaveBeenCalledWith('https://api.etherscan.io/api?module=gastracker&action=gasoracle') expect(fetch).toHaveBeenCalledWith('https://ethgasstation.info/json/ethgasAPI.json') + // assert the hook is not loading + expect(result.current[2]).toBe(false) + // assert the gas price is correct - expect(result.current.maxFeePerGas?.toString()).toBe('60000000000') + expect(result.current[0]?.maxFeePerGas?.toString()).toBe('60000000000') // assert the priority fee is correct - expect(result.current.maxPriorityFeePerGas?.toString()).toEqual('4975') + expect(result.current[0]?.maxPriorityFeePerGas?.toString()).toEqual('4975') }) it('should fallback to a fixed gas price if the oracles fail', async () => { @@ -137,6 +149,9 @@ describe('useGasPrice', () => { // render the hook const { result } = renderHook(() => useGasPrice()) + // assert the hook is loading + expect(result.current[2]).toBe(true) + // wait for the hook to fetch the gas price await act(async () => { await Promise.resolve() @@ -145,11 +160,14 @@ describe('useGasPrice', () => { expect(fetch).toHaveBeenCalledWith('https://api.etherscan.io/api?module=gastracker&action=gasoracle') expect(fetch).toHaveBeenCalledWith('https://ethgasstation.info/json/ethgasAPI.json') + // assert the hook is not loading + expect(result.current[2]).toBe(false) + // assert the gas price is correct - expect(result.current.maxFeePerGas?.toString()).toBe('24000000000') + expect(result.current[0]?.maxFeePerGas?.toString()).toBe('24000000000') // assert the priority fee is correct - expect(result.current.maxPriorityFeePerGas?.toString()).toEqual('4975') + expect(result.current[0]?.maxPriorityFeePerGas?.toString()).toEqual('4975') }) it('should keep the previous gas price if the hook re-renders', async () => { @@ -185,25 +203,37 @@ describe('useGasPrice', () => { // render the hook const { result } = renderHook(() => useGasPrice()) - expect(result.current.maxFeePerGas).toBe(undefined) + // assert the hook is loading + expect(result.current[2]).toBe(true) + + expect(result.current[0]?.maxFeePerGas).toBe(undefined) // wait for the hook to fetch the gas price await act(async () => { await Promise.resolve() }) - expect(result.current.maxFeePerGas?.toString()).toBe('21000000000') + // assert the hook is not loading + expect(result.current[2]).toBe(false) + + expect(result.current[0]?.maxFeePerGas?.toString()).toBe('21000000000') // render the hook again const { result: result2 } = renderHook(() => useGasPrice()) - expect(result.current.maxFeePerGas?.toString()).toBe('21000000000') + // assert the hook is not loading (as a value exists) + expect(result.current[2]).toBe(false) + + expect(result.current[0]?.maxFeePerGas?.toString()).toBe('21000000000') // wait for the hook to fetch the gas price await act(async () => { await Promise.resolve() }) - expect(result2.current.maxFeePerGas?.toString()).toBe('22000000000') + // assert the hook is not loading + expect(result.current[2]).toBe(false) + + expect(result2.current[0]?.maxFeePerGas?.toString()).toBe('22000000000') }) }) diff --git a/src/hooks/useGasPrice.ts b/src/hooks/useGasPrice.ts index 056f78bc3f..2f7127e974 100644 --- a/src/hooks/useGasPrice.ts +++ b/src/hooks/useGasPrice.ts @@ -1,9 +1,7 @@ -import { useMemo } from 'react' import { BigNumber } from 'ethers' -import type { FeeData } from '@ethersproject/providers' import type { GasPrice, GasPriceOracle } from '@safe-global/safe-gateway-typescript-sdk' import { GAS_PRICE_TYPE } from '@safe-global/safe-gateway-typescript-sdk' -import useAsync from '@/hooks/useAsync' +import useAsync, { type AsyncResult } from '@/hooks/useAsync' import { useCurrentChain } from './useChains' import useIntervalCounter from './useIntervalCounter' import { useWeb3ReadOnly } from '../hooks/wallets/web3' @@ -54,41 +52,42 @@ const getGasPrice = async (gasPriceConfigs: GasPrice): Promise { +const useGasPrice = (): AsyncResult<{ + maxFeePerGas: BigNumber | undefined + maxPriorityFeePerGas: BigNumber | undefined +}> => { const chain = useCurrentChain() const gasPriceConfigs = chain?.gasPrice const [counter] = useIntervalCounter(REFRESH_DELAY) const provider = useWeb3ReadOnly() const isEIP1559 = !!chain && hasFeature(chain, FEATURES.EIP1559) - // Fetch gas price from oracles or get a fixed value - const [gasPrice] = useAsync( - () => { - if (gasPriceConfigs) { - return getGasPrice(gasPriceConfigs) + const [gasPrice, gasPriceError, gasPriceLoading] = useAsync( + async () => { + const [gasPrice, feeData] = await Promise.all([ + // Fetch gas price from oracles or get a fixed value + gasPriceConfigs ? getGasPrice(gasPriceConfigs) : undefined, + + // Fetch the gas fees from the blockchain itself + provider?.getFeeData(), + ]) + + // Prepare the return values + const maxFee = gasPrice || (isEIP1559 ? feeData?.maxFeePerGas : feeData?.gasPrice) || undefined + const maxPrioFee = (isEIP1559 && feeData?.maxPriorityFeePerGas) || undefined + + return { + maxFeePerGas: maxFee, + maxPriorityFeePerGas: maxPrioFee, } }, - [gasPriceConfigs, counter], + [gasPriceConfigs, provider, counter], false, ) - // Fetch the gas fees from the blockchain itself - const [feeData] = useAsync(() => provider?.getFeeData(), [provider, counter], false) + const isLoading = gasPriceLoading || (!gasPrice && !gasPriceError) - // Prepare the return values - const maxFee = gasPrice || (isEIP1559 ? feeData?.maxFeePerGas : feeData?.gasPrice) || undefined - const maxPrioFee = (isEIP1559 && feeData?.maxPriorityFeePerGas) || undefined - - return useMemo( - () => ({ - maxFeePerGas: maxFee, - maxPriorityFeePerGas: maxPrioFee, - }), - [maxFee, maxPrioFee], - ) + return [gasPrice, gasPriceError, isLoading] } export default useGasPrice diff --git a/src/services/tx/tx-sender/dispatch.ts b/src/services/tx/tx-sender/dispatch.ts index ae390acf7c..0a05153bda 100644 --- a/src/services/tx/tx-sender/dispatch.ts +++ b/src/services/tx/tx-sender/dispatch.ts @@ -5,7 +5,7 @@ import { didReprice, didRevert } from '@/utils/ethers-utils' import type MultiSendCallOnlyEthersContract from '@safe-global/safe-ethers-lib/dist/src/contracts/MultiSendCallOnly/MultiSendCallOnlyEthersContract' import type { SpendingLimitTxParams } from '@/components/tx/modals/TokenTransferModal/ReviewSpendingLimitTx' import { getSpendingLimitContract } from '@/services/contracts/spendingLimitContracts' -import type { ContractTransaction } from 'ethers' +import type { ContractTransaction, PayableOverrides } from 'ethers' import type { RequestId } from '@safe-global/safe-apps-sdk' import proposeTx from '../proposeTransaction' import { txDispatch, TxEvent } from '../txEvents' @@ -174,6 +174,7 @@ export const dispatchBatchExecution = async ( onboard: OnboardAPI, chainId: SafeInfo['chainId'], safeAddress: string, + overrides?: PayableOverrides, ) => { const groupKey = multiSendTxData @@ -183,7 +184,8 @@ export const dispatchBatchExecution = async ( const wallet = await assertWalletChain(onboard, chainId) const provider = createWeb3(wallet.provider) - result = await multiSendContract.contract.connect(provider.getSigner()).multiSend(multiSendTxData) + result = await multiSendContract.contract.connect(provider.getSigner()).multiSend(multiSendTxData, overrides) + txs.forEach(({ txId }) => { txDispatch(TxEvent.EXECUTING, { txId, groupKey }) })