diff --git a/package.json b/package.json index 7ebe730f14..771bb70146 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "@safe-global/safe-core-sdk-utils": "^1.7.4", "@safe-global/safe-deployments": "^1.25.0", "@safe-global/safe-ethers-lib": "^1.9.4", - "@safe-global/safe-gateway-typescript-sdk": "^3.8.0", + "@safe-global/safe-gateway-typescript-sdk": "^3.9.0", "@safe-global/safe-modules-deployments": "^1.0.0", "@safe-global/safe-react-components": "^2.0.6", "@sentry/react": "^7.28.1", diff --git a/src/hooks/__tests__/useGasPrice.test.ts b/src/hooks/__tests__/useGasPrice.test.ts index d3a3d5dd24..d34ead9250 100644 --- a/src/hooks/__tests__/useGasPrice.test.ts +++ b/src/hooks/__tests__/useGasPrice.test.ts @@ -1,6 +1,7 @@ import { BigNumber } from 'ethers' import { act, renderHook } from '@/tests/test-utils' import useGasPrice from '@/hooks/useGasPrice' +import { useCurrentChain } from '../useChains' // mock useWeb3Readonly jest.mock('../wallets/web3', () => { @@ -8,8 +9,8 @@ jest.mock('../wallets/web3', () => { getFeeData: jest.fn(() => Promise.resolve({ gasPrice: undefined, - maxFeePerGas: BigNumber.from('0x956e'), - maxPriorityFeePerGas: BigNumber.from('0x136f'), + maxFeePerGas: BigNumber.from('0x956e'), //38254 + maxPriorityFeePerGas: BigNumber.from('0x136f'), //4975 }), ), } @@ -17,32 +18,30 @@ jest.mock('../wallets/web3', () => { useWeb3ReadOnly: jest.fn(() => provider), } }) - +const currentChain = { + chainId: '4', + gasPrice: [ + { + type: 'oracle', + uri: 'https://api.etherscan.io/api?module=gastracker&action=gasoracle', + gasParameter: 'FastGasPrice', + gweiFactor: '1000000000.000000000', + }, + { + type: 'oracle', + uri: 'https://ethgasstation.info/json/ethgasAPI.json', + gasParameter: 'fast', + gweiFactor: '200000000.000000000', + }, + { + type: 'fixed', + weiValue: '24000000000', + }, + ], + features: ['EIP1559'], +} // Mock useCurrentChain jest.mock('@/hooks/useChains', () => { - const currentChain = { - chainId: '4', - gasPrice: [ - { - type: 'ORACLE', - uri: 'https://api.etherscan.io/api?module=gastracker&action=gasoracle', - gasParameter: 'FastGasPrice', - gweiFactor: '1000000000.000000000', - }, - { - type: 'ORACLE', - uri: 'https://ethgasstation.info/json/ethgasAPI.json', - gasParameter: 'fast', - gweiFactor: '200000000.000000000', - }, - { - type: 'FIXED', - weiValue: '24000000000', - }, - ], - features: ['EIP1559'], - } - return { useCurrentChain: jest.fn(() => currentChain), } @@ -52,6 +51,7 @@ describe('useGasPrice', () => { beforeEach(() => { jest.useFakeTimers() jest.clearAllMocks() + ;(useCurrentChain as jest.Mock).mockReturnValue(currentChain) }) it('should return the fetched gas price from the first oracle', async () => { @@ -170,6 +170,56 @@ describe('useGasPrice', () => { expect(result.current[0]?.maxPriorityFeePerGas?.toString()).toEqual('4975') }) + it('should be able to set a fixed EIP 1559 gas price', async () => { + ;(useCurrentChain as jest.Mock).mockReturnValue({ + chainId: '10', + gasPrice: [ + { + type: 'fixed1559', + maxFeePerGas: '100000000', + maxPriorityFeePerGas: '100000', + }, + ], + features: ['EIP1559'], + }) + + const { result } = renderHook(() => useGasPrice()) + + await act(async () => { + await Promise.resolve() + }) + // assert the hook is not loading + expect(result.current[2]).toBe(false) + + // assert fixed gas price as minimum of 0.1 gwei + expect(result.current[0]?.maxFeePerGas?.toString()).toBe('100000000') + + // assert fixed priority fee + expect(result.current[0]?.maxPriorityFeePerGas?.toString()).toBe('100000') + }) + + it("should use the previous block's fee data if there are no oracles", async () => { + ;(useCurrentChain as jest.Mock).mockReturnValue({ + chainId: '1', + gasPrice: [], + features: ['EIP1559'], + }) + + const { result } = renderHook(() => useGasPrice()) + + await act(async () => { + await Promise.resolve() + }) + // assert the hook is not loading + expect(result.current[2]).toBe(false) + + // assert gas price from provider + expect(result.current[0]?.maxFeePerGas?.toString()).toBe('38254') + + // assert priority fee from provider + expect(result.current[0]?.maxPriorityFeePerGas?.toString()).toBe('4975') + }) + it('should keep the previous gas price if the hook re-renders', async () => { // Mock fetch Object.defineProperty(window, 'fetch', { diff --git a/src/hooks/useDecodeTx.ts b/src/hooks/useDecodeTx.ts index d7be985024..50b546eae7 100644 --- a/src/hooks/useDecodeTx.ts +++ b/src/hooks/useDecodeTx.ts @@ -15,8 +15,8 @@ const useDecodeTx = (tx?: SafeTransaction): AsyncResult => const [data = nativeTransfer, error, loading] = useAsync(() => { if (!encodedData || isEmptyData) return - return getDecodedData(chainId, encodedData) - }, [chainId, encodedData, isEmptyData]) + return getDecodedData(chainId, encodedData, tx.data.to) + }, [chainId, encodedData, isEmptyData, tx?.data.to]) return [data, error, loading] } diff --git a/src/hooks/useGasPrice.ts b/src/hooks/useGasPrice.ts index 0d5b3a4663..01ee5a4f28 100644 --- a/src/hooks/useGasPrice.ts +++ b/src/hooks/useGasPrice.ts @@ -1,5 +1,11 @@ import { BigNumber } from 'ethers' -import type { GasPrice, GasPriceOracle } from '@safe-global/safe-gateway-typescript-sdk' +import type { FeeData } from '@ethersproject/abstract-provider' +import type { + GasPrice, + GasPriceFixed, + GasPriceFixedEIP1559, + GasPriceOracle, +} from '@safe-global/safe-gateway-typescript-sdk' import { GAS_PRICE_TYPE } from '@safe-global/safe-gateway-typescript-sdk' import useAsync, { type AsyncResult } from '@/hooks/useAsync' import { useCurrentChain } from './useChains' @@ -9,6 +15,20 @@ import { Errors, logError } from '@/services/exceptions' import { FEATURES, hasFeature } from '@/utils/chains' import { asError } from '@/services/exceptions/utils' +type EstimatedGasPrice = + | { + gasPrice: BigNumber + } + | { + maxFeePerGas: BigNumber + maxPriorityFeePerGas: BigNumber + } + +type GasFeeParams = { + maxFeePerGas: BigNumber | null | undefined + maxPriorityFeePerGas: BigNumber | null | undefined +} + // Update gas fees every 20 seconds const REFRESH_DELAY = 20e3 @@ -27,17 +47,40 @@ const fetchGasOracle = async (gasPriceOracle: GasPriceOracle): Promise => { - let error: Error | undefined +// These typeguards are necessary because the GAS_PRICE_TYPE enum uses uppercase while the config service uses lowercase values +const isGasPriceFixed = (gasPriceConfig: GasPrice[number]): gasPriceConfig is GasPriceFixed => { + return gasPriceConfig.type.toUpperCase() == GAS_PRICE_TYPE.FIXED +} + +const isGasPriceFixed1559 = (gasPriceConfig: GasPrice[number]): gasPriceConfig is GasPriceFixedEIP1559 => { + return gasPriceConfig.type.toUpperCase() == GAS_PRICE_TYPE.FIXED_1559 +} + +const isGasPriceOracle = (gasPriceConfig: GasPrice[number]): gasPriceConfig is GasPriceOracle => { + return gasPriceConfig.type.toUpperCase() == GAS_PRICE_TYPE.ORACLE +} +const getGasPrice = async (gasPriceConfigs: GasPrice): Promise => { + let error: Error | undefined for (const config of gasPriceConfigs) { - if (config.type == GAS_PRICE_TYPE.FIXED) { - return BigNumber.from(config.weiValue) + if (isGasPriceFixed(config)) { + return { + gasPrice: BigNumber.from(config.weiValue), + } } - if (config.type == GAS_PRICE_TYPE.ORACLE) { + if (isGasPriceFixed1559(config)) { + return { + maxFeePerGas: BigNumber.from(config.maxFeePerGas), + maxPriorityFeePerGas: BigNumber.from(config.maxPriorityFeePerGas), + } + } + + if (isGasPriceOracle(config)) { try { - return await fetchGasOracle(config) + return { + gasPrice: await fetchGasOracle(config), + } } catch (_err) { error = asError(_err) logError(Errors._611, error.message) @@ -53,10 +96,35 @@ const getGasPrice = async (gasPriceConfigs: GasPrice): Promise => { +const getGasParameters = ( + estimation: EstimatedGasPrice | undefined, + feeData: FeeData | undefined, + isEIP1559: boolean, +): GasFeeParams => { + if (!estimation) { + return { + maxFeePerGas: isEIP1559 ? feeData?.maxFeePerGas : feeData?.gasPrice, + maxPriorityFeePerGas: isEIP1559 ? feeData?.maxPriorityFeePerGas : undefined, + } + } + + if (isEIP1559 && 'maxFeePerGas' in estimation && 'maxPriorityFeePerGas' in estimation) { + return estimation + } + + if ('gasPrice' in estimation) { + return { + maxFeePerGas: estimation.gasPrice, + maxPriorityFeePerGas: isEIP1559 ? feeData?.maxPriorityFeePerGas : undefined, + } + } + + return { + maxFeePerGas: undefined, + maxPriorityFeePerGas: undefined, + } +} +const useGasPrice = (): AsyncResult => { const chain = useCurrentChain() const gasPriceConfigs = chain?.gasPrice const [counter] = useIntervalCounter(REFRESH_DELAY) @@ -65,7 +133,7 @@ const useGasPrice = (): AsyncResult<{ const [gasPrice, gasPriceError, gasPriceLoading] = useAsync( async () => { - const [gasPrice, feeData] = await Promise.all([ + const [gasEstimation, feeData] = await Promise.all([ // Fetch gas price from oracles or get a fixed value gasPriceConfigs ? getGasPrice(gasPriceConfigs) : undefined, @@ -74,13 +142,7 @@ const useGasPrice = (): AsyncResult<{ ]) // Prepare the return values - const maxFee = gasPrice || (isEIP1559 ? feeData?.maxFeePerGas : feeData?.gasPrice) || undefined - const maxPrioFee = (isEIP1559 && feeData?.maxPriorityFeePerGas) || undefined - - return { - maxFeePerGas: maxFee, - maxPriorityFeePerGas: maxPrioFee, - } + return getGasParameters(gasEstimation, feeData, isEIP1559) }, // eslint-disable-next-line react-hooks/exhaustive-deps [gasPriceConfigs, provider, counter, isEIP1559], diff --git a/yarn.lock b/yarn.lock index 60b96001bf..a0a800470d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3295,7 +3295,18 @@ "@safe-global/safe-gateway-typescript-sdk" "^3.5.3" viem "^1.0.0" -"@safe-global/safe-core-sdk-types@^1.9.1", "@safe-global/safe-core-sdk-types@^1.9.2": +"@safe-global/safe-core-sdk-types@^1.9.1": + version "1.10.1" + resolved "https://registry.yarnpkg.com/@safe-global/safe-core-sdk-types/-/safe-core-sdk-types-1.10.1.tgz#94331b982671d2f2b8cc23114c58baf63d460c81" + integrity sha512-BKvuYTLOlY16Rq6qCXglmnL6KxInDuXMFqZMaCzwDKiEh+uoHu3xCumG5tVtWOkCgBF4XEZXMqwZUiLcon7IsA== + dependencies: + "@ethersproject/bignumber" "^5.7.0" + "@ethersproject/contracts" "^5.7.0" + "@safe-global/safe-deployments" "^1.20.2" + web3-core "^1.8.1" + web3-utils "^1.8.1" + +"@safe-global/safe-core-sdk-types@^1.9.2": version "1.9.2" resolved "https://registry.yarnpkg.com/@safe-global/safe-core-sdk-types/-/safe-core-sdk-types-1.9.2.tgz#c8ae3500f5f16a9380f0270ab543f7f0718c9848" integrity sha512-TVBoCf3bry3y6vmJXACDNOaQnHWTh8Q9G8P3wZCgUBxMc676hP9HEvF1Xrvwe0wMxevMIKyBnEV4FpZUJGSefg== @@ -3328,6 +3339,13 @@ semver "^7.3.8" web3-utils "^1.8.1" +"@safe-global/safe-deployments@^1.20.2": + version "1.26.0" + resolved "https://registry.yarnpkg.com/@safe-global/safe-deployments/-/safe-deployments-1.26.0.tgz#b83615b3b5a66e736e08f8ecf2801ed988e9e007" + integrity sha512-Tw89O4/paT19ieMoiWQbqRApb0Bef/DxweS9rxodXAM5EQModkbyFXGZca+YxXE67sLvWjLr2jJUOxwze8mhGw== + dependencies: + semver "^7.3.7" + "@safe-global/safe-deployments@^1.25.0": version "1.25.0" resolved "https://registry.yarnpkg.com/@safe-global/safe-deployments/-/safe-deployments-1.25.0.tgz#882f0703cd4dd86cc19238319d77459ded09ec88" @@ -3351,10 +3369,10 @@ dependencies: cross-fetch "^3.1.5" -"@safe-global/safe-gateway-typescript-sdk@^3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@safe-global/safe-gateway-typescript-sdk/-/safe-gateway-typescript-sdk-3.8.0.tgz#6a71eeab0ecd447a585531ef87cf987da30b78a0" - integrity sha512-CiGWIHgIaOdICpDxp05Jw3OPslWTu8AnL0PhrCT1xZgIO86NlMMLzkGbeycJ4FHpTjA999O791Oxp4bZPIjgHA== +"@safe-global/safe-gateway-typescript-sdk@^3.9.0": + version "3.9.0" + resolved "https://registry.yarnpkg.com/@safe-global/safe-gateway-typescript-sdk/-/safe-gateway-typescript-sdk-3.9.0.tgz#5aa36c05b865f6fe754d1d460f83bc9bf3a0145e" + integrity sha512-DxRM/sBBQhv955dPtdo0z2Bf2fXxrzoRUnGyTa3+4Z0RAhcyiqnffRP1Bt3tyuvlyfZnFL0RsvkqDcAIKzq3RQ== dependencies: cross-fetch "^3.1.5"