diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 4fce40ac34c..4b2327c9356 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -10,6 +10,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Bump `@metamask/network-controller` from `^35.0.0` to `^35.0.1` ([#9758](https://github.com/MetaMask/core/pull/9758)) +- Replace underpriced dapp-suggested gas fees with the wallet's suggested estimates ([#9704](https://github.com/MetaMask/core/pull/9704)) + - If the new `replaceUnderpricedDappGasFees` feature flag is enabled for the chain, dapp-suggested EIP-1559 fees with a `maxFeePerGas` below the current low estimate are replaced with the suggested medium values, as they are unlikely to result in inclusion in a block before fee values change. + - The `userFeeLevel` for such transactions is set to `medium` instead of `dappSuggested`, so the values are kept updated while the transaction is unapproved. + - The original dapp-suggested values remain available via `dappSuggestedGasFees` on the transaction metadata. + - Disabled by default. +- Ignore underpriced saved (advanced) gas fee preferences in favour of the wallet's suggested estimates ([#9704](https://github.com/MetaMask/core/pull/9704)) + - If the new `replaceUnderpricedSavedGasFees` feature flag is enabled for the chain, saved custom fees with a `maxBaseFee` below the current low estimate are ignored and the suggested medium values are used instead, as they are unlikely to result in inclusion in a block before fee values change. + - Level-based saved preferences track current estimates and are never ignored. + - The `userFeeLevel` for such transactions is set to `medium` instead of `custom`, so the values are kept updated while the transaction is unapproved. + - Disabled by default. ### Fixed diff --git a/packages/transaction-controller/src/utils/feature-flags.test.ts b/packages/transaction-controller/src/utils/feature-flags.test.ts index 74437d7f93a..bbbac8a27f8 100644 --- a/packages/transaction-controller/src/utils/feature-flags.test.ts +++ b/packages/transaction-controller/src/utils/feature-flags.test.ts @@ -22,6 +22,8 @@ import { getTransactionHistoryLimit, FeatureFlag, getTimeoutAttempts, + getReplaceUnderpricedDappGasFeesEnabled, + getReplaceUnderpricedSavedGasFeesEnabled, } from './feature-flags.js'; import { isValidSignature } from './signature.js'; @@ -822,6 +824,52 @@ describe('Feature Flags Utils', () => { }); }); + describe.each([ + [ + 'getReplaceUnderpricedDappGasFeesEnabled', + getReplaceUnderpricedDappGasFeesEnabled, + 'replaceUnderpricedDappGasFees' as const, + ], + [ + 'getReplaceUnderpricedSavedGasFeesEnabled', + getReplaceUnderpricedSavedGasFeesEnabled, + 'replaceUnderpricedSavedGasFees' as const, + ], + ])('%s', (_name, getter, flagKey) => { + it('returns false if no feature flags set', () => { + mockFeatureFlags({}); + + expect(getter(CHAIN_ID_MOCK, controllerMessenger)).toBe(false); + }); + + it('returns default value if no chain-specific config', () => { + mockFeatureFlags({ + [FeatureFlag.Transactions]: { + [flagKey]: { + default: true, + }, + }, + }); + + expect(getter(CHAIN_ID_MOCK, controllerMessenger)).toBe(true); + }); + + it('returns chain-specific value when available', () => { + mockFeatureFlags({ + [FeatureFlag.Transactions]: { + [flagKey]: { + default: true, + perChainConfig: { + [CHAIN_ID_MOCK]: false, + }, + }, + }, + }); + + expect(getter(CHAIN_ID_MOCK, controllerMessenger)).toBe(false); + }); + }); + describe('getTimeoutAttempts', () => { it('returns undefined if no feature flags set', () => { mockFeatureFlags({}); diff --git a/packages/transaction-controller/src/utils/feature-flags.ts b/packages/transaction-controller/src/utils/feature-flags.ts index d7bf191c5e0..a1c4d215813 100644 --- a/packages/transaction-controller/src/utils/feature-flags.ts +++ b/packages/transaction-controller/src/utils/feature-flags.ts @@ -186,6 +186,46 @@ export type TransactionControllerFeatureFlags = { */ default?: number; }; + + /** + * Replacement of underpriced dapp-suggested gas fees. + * If enabled, dapp-suggested EIP-1559 fees with a `maxFeePerGas` below the + * current low estimate are replaced with the wallet's suggested fees, as + * they are unlikely to be included in a block before fee values change. + */ + replaceUnderpricedDappGasFees?: { + /** Enablement on a per-chain basis. */ + perChainConfig?: { + [chainId: Hex]: boolean; + }; + + /** + * Default enablement. + * This value is used when no specific value is found for a chain ID. + */ + default?: boolean; + }; + + /** + * Replacement of underpriced saved (advanced) gas fee preferences. + * If enabled, saved custom fees with a `maxBaseFee` below the current low + * estimate are ignored in favour of the wallet's suggested fees, as they + * are unlikely to be included in a block before fee values change. + * Level-based saved preferences track current estimates and are never + * ignored. + */ + replaceUnderpricedSavedGasFees?: { + /** Enablement on a per-chain basis. */ + perChainConfig?: { + [chainId: Hex]: boolean; + }; + + /** + * Default enablement. + * This value is used when no specific value is found for a chain ID. + */ + default?: boolean; + }; }; }; @@ -465,6 +505,54 @@ export function getTimeoutAttempts( ); } +/** + * Retrieves whether underpriced dapp-suggested gas fees should be replaced + * with the wallet's suggested fees. + * + * @param chainId - The chain ID. + * @param messenger - The controller messenger instance. + * @returns Whether the replacement is enabled. + */ +export function getReplaceUnderpricedDappGasFeesEnabled( + chainId: Hex, + messenger: TransactionControllerMessenger, +): boolean { + const featureFlags = getFeatureFlags(messenger); + + const replaceUnderpricedDappGasFeesFlags = + featureFlags?.[FeatureFlag.Transactions]?.replaceUnderpricedDappGasFees; + + return ( + replaceUnderpricedDappGasFeesFlags?.perChainConfig?.[chainId] ?? + replaceUnderpricedDappGasFeesFlags?.default ?? + false + ); +} + +/** + * Retrieves whether underpriced saved (advanced) gas fee preferences should be + * ignored in favour of the wallet's suggested fees. + * + * @param chainId - The chain ID. + * @param messenger - The controller messenger instance. + * @returns Whether the replacement is enabled. + */ +export function getReplaceUnderpricedSavedGasFeesEnabled( + chainId: Hex, + messenger: TransactionControllerMessenger, +): boolean { + const featureFlags = getFeatureFlags(messenger); + + const replaceUnderpricedSavedGasFeesFlags = + featureFlags?.[FeatureFlag.Transactions]?.replaceUnderpricedSavedGasFees; + + return ( + replaceUnderpricedSavedGasFeesFlags?.perChainConfig?.[chainId] ?? + replaceUnderpricedSavedGasFeesFlags?.default ?? + false + ); +} + /** * Retrieves the relevant feature flags from the remote feature flag controller. * diff --git a/packages/transaction-controller/src/utils/gas-fees.test.ts b/packages/transaction-controller/src/utils/gas-fees.test.ts index 49b9bc5f6d7..cd05178a9cb 100644 --- a/packages/transaction-controller/src/utils/gas-fees.test.ts +++ b/packages/transaction-controller/src/utils/gas-fees.test.ts @@ -8,10 +8,16 @@ import { TransactionType, UserFeeLevel, } from '../types.js'; +import { + getReplaceUnderpricedDappGasFeesEnabled, + getReplaceUnderpricedSavedGasFeesEnabled, +} from './feature-flags.js'; import type { UpdateGasFeesRequest } from './gas-fees.js'; import { gweiDecimalToWeiDecimal, updateGasFees } from './gas-fees.js'; import { rpcRequest } from './provider.js'; +jest.mock('./feature-flags'); + jest.mock('./provider', () => ({ rpcRequest: jest.fn(), })); @@ -26,6 +32,7 @@ const GAS_HEX_MOCK = toHex(GAS_MOCK); const GAS_HEX_WEI_MOCK = toHex(GAS_MOCK * 1e9); const GAS_LOW_HEX_WEI_MOCK = toHex(GAS_LOW_MOCK * 1e9); const GAS_HIGH_HEX_WEI_MOCK = toHex(GAS_HIGH_MOCK * 1e9); +const UNDERPRICED_GAS_HEX_WEI_MOCK = toHex((GAS_LOW_MOCK - 1) * 1e9); const ORIGIN_MOCK = 'test.com'; const MESSENGER_MOCK = {} as unknown as TransactionControllerMessenger; const NETWORK_CLIENT_ID_MOCK = 'testNetworkClientId' as NetworkClientId; @@ -100,6 +107,12 @@ function createGasFeeFlowMock(): jest.Mocked { describe('gas-fees', () => { let updateGasFeeRequest: jest.Mocked; const rpcRequestMock = jest.mocked(rpcRequest); + const getReplaceUnderpricedDappGasFeesEnabledMock = jest.mocked( + getReplaceUnderpricedDappGasFeesEnabled, + ); + const getReplaceUnderpricedSavedGasFeesEnabledMock = jest.mocked( + getReplaceUnderpricedSavedGasFeesEnabled, + ); let gasFeeFlowMock: jest.Mocked; /** @@ -753,6 +766,263 @@ describe('gas-fees', () => { ); }); }); + + describe('replaces underpriced dapp-suggested gas fees', () => { + beforeEach(() => { + getReplaceUnderpricedDappGasFeesEnabledMock.mockReturnValue(true); + + updateGasFeeRequest.txMeta.origin = ORIGIN_MOCK; + updateGasFeeRequest.txMeta.txParams.maxFeePerGas = + UNDERPRICED_GAS_HEX_WEI_MOCK; + updateGasFeeRequest.txMeta.txParams.maxPriorityFeePerGas = + UNDERPRICED_GAS_HEX_WEI_MOCK; + + mockGasFeeFlowMockResponse(FLOW_RESPONSE_FEE_MARKET_MOCK); + }); + + it('with suggested medium values if request maxFeePerGas below low estimate', async () => { + await updateGasFees(updateGasFeeRequest); + + expect(updateGasFeeRequest.txMeta.txParams.maxFeePerGas).toBe( + GAS_HEX_WEI_MOCK, + ); + expect(updateGasFeeRequest.txMeta.txParams.maxPriorityFeePerGas).toBe( + GAS_HEX_WEI_MOCK, + ); + }); + + it('and sets userFeeLevel to medium', async () => { + await updateGasFees(updateGasFeeRequest); + + expect(updateGasFeeRequest.txMeta.userFeeLevel).toBe( + UserFeeLevel.MEDIUM, + ); + }); + + it('with suggested medium values if request gasPrice below low estimate and no other fee properties', async () => { + delete updateGasFeeRequest.txMeta.txParams.maxFeePerGas; + delete updateGasFeeRequest.txMeta.txParams.maxPriorityFeePerGas; + updateGasFeeRequest.txMeta.txParams.gasPrice = + UNDERPRICED_GAS_HEX_WEI_MOCK; + + await updateGasFees(updateGasFeeRequest); + + expect(updateGasFeeRequest.txMeta.txParams.maxFeePerGas).toBe( + GAS_HEX_WEI_MOCK, + ); + expect(updateGasFeeRequest.txMeta.txParams.maxPriorityFeePerGas).toBe( + GAS_HEX_WEI_MOCK, + ); + expect(updateGasFeeRequest.txMeta.txParams.gasPrice).toBeUndefined(); + }); + + it('unless request maxFeePerGas is at or above low estimate', async () => { + updateGasFeeRequest.txMeta.txParams.maxFeePerGas = GAS_LOW_HEX_WEI_MOCK; + + await updateGasFees(updateGasFeeRequest); + + expect(updateGasFeeRequest.txMeta.txParams.maxFeePerGas).toBe( + GAS_LOW_HEX_WEI_MOCK, + ); + expect(updateGasFeeRequest.txMeta.txParams.maxPriorityFeePerGas).toBe( + UNDERPRICED_GAS_HEX_WEI_MOCK, + ); + expect(updateGasFeeRequest.txMeta.userFeeLevel).toBe( + UserFeeLevel.DAPP_SUGGESTED, + ); + }); + + it('unless feature flag is disabled', async () => { + getReplaceUnderpricedDappGasFeesEnabledMock.mockReturnValue(false); + + await updateGasFees(updateGasFeeRequest); + + expect(updateGasFeeRequest.txMeta.txParams.maxFeePerGas).toBe( + UNDERPRICED_GAS_HEX_WEI_MOCK, + ); + expect(updateGasFeeRequest.txMeta.userFeeLevel).toBe( + UserFeeLevel.DAPP_SUGGESTED, + ); + }); + + it('unless transaction is internal', async () => { + updateGasFeeRequest.txMeta.isInternal = true; + + await updateGasFees(updateGasFeeRequest); + + expect(updateGasFeeRequest.txMeta.txParams.maxFeePerGas).toBe( + UNDERPRICED_GAS_HEX_WEI_MOCK, + ); + }); + + it('unless request has gasPrice alongside maxPriorityFeePerGas', async () => { + delete updateGasFeeRequest.txMeta.txParams.maxFeePerGas; + updateGasFeeRequest.txMeta.txParams.gasPrice = + UNDERPRICED_GAS_HEX_WEI_MOCK; + + await updateGasFees(updateGasFeeRequest); + + expect(updateGasFeeRequest.txMeta.txParams.maxFeePerGas).toBe( + GAS_HEX_WEI_MOCK, + ); + expect(updateGasFeeRequest.txMeta.txParams.maxPriorityFeePerGas).toBe( + UNDERPRICED_GAS_HEX_WEI_MOCK, + ); + }); + + it('unless request maxFeePerGas cannot be parsed', async () => { + updateGasFeeRequest.txMeta.txParams.maxFeePerGas = 'invalid-hex'; + + await updateGasFees(updateGasFeeRequest); + + expect(updateGasFeeRequest.txMeta.txParams.maxFeePerGas).toBe( + 'invalid-hex', + ); + expect(updateGasFeeRequest.txMeta.userFeeLevel).toBe( + UserFeeLevel.DAPP_SUGGESTED, + ); + }); + + it('unless estimates are unavailable', async () => { + gasFeeFlowMock.getGasFees.mockRejectedValue(new Error('TestError')); + rpcRequestMock.mockResolvedValue(GAS_HEX_WEI_MOCK); + + await updateGasFees(updateGasFeeRequest); + + expect(updateGasFeeRequest.txMeta.txParams.maxFeePerGas).toBe( + UNDERPRICED_GAS_HEX_WEI_MOCK, + ); + expect(updateGasFeeRequest.txMeta.userFeeLevel).toBe( + UserFeeLevel.DAPP_SUGGESTED, + ); + }); + + it('unless estimates and gas price fallback are both unavailable', async () => { + gasFeeFlowMock.getGasFees.mockRejectedValue(new Error('TestError')); + rpcRequestMock.mockRejectedValue(new Error('TestError')); + + await updateGasFees(updateGasFeeRequest); + + expect(rpcRequestMock).not.toHaveBeenCalled(); + expect(updateGasFeeRequest.txMeta.txParams.maxFeePerGas).toBe( + UNDERPRICED_GAS_HEX_WEI_MOCK, + ); + expect(updateGasFeeRequest.txMeta.txParams.maxPriorityFeePerGas).toBe( + UNDERPRICED_GAS_HEX_WEI_MOCK, + ); + expect(updateGasFeeRequest.txMeta.userFeeLevel).toBe( + UserFeeLevel.DAPP_SUGGESTED, + ); + }); + }); + + it('uses gasPrice fallback if flow returns unsupported estimate type', async () => { + mockGasFeeFlowMockResponse({ + estimates: { + type: 'unsupportedType', + }, + } as unknown as GasFeeFlowResponse); + + rpcRequestMock.mockResolvedValue(GAS_HEX_WEI_MOCK); + + await updateGasFees(updateGasFeeRequest); + + expect(updateGasFeeRequest.txMeta.txParams.maxFeePerGas).toBe( + GAS_HEX_WEI_MOCK, + ); + }); + + describe('replaces underpriced saved gas fees', () => { + beforeEach(() => { + getReplaceUnderpricedSavedGasFeesEnabledMock.mockReturnValue(true); + mockGasFeeFlowMockResponse(FLOW_RESPONSE_FEE_MARKET_MOCK); + }); + + it('with suggested medium values if saved maxBaseFee below low estimate', async () => { + updateGasFeeRequest.getSavedGasFees.mockReturnValueOnce({ + maxBaseFee: String(GAS_LOW_MOCK - 1), + priorityFee: '1', + }); + + await updateGasFees(updateGasFeeRequest); + + expect(updateGasFeeRequest.txMeta.txParams.maxFeePerGas).toBe( + GAS_HEX_WEI_MOCK, + ); + expect(updateGasFeeRequest.txMeta.txParams.maxPriorityFeePerGas).toBe( + GAS_HEX_WEI_MOCK, + ); + expect(updateGasFeeRequest.txMeta.userFeeLevel).toBe( + UserFeeLevel.MEDIUM, + ); + }); + + it('unless saved maxBaseFee is at or above low estimate', async () => { + updateGasFeeRequest.getSavedGasFees.mockReturnValueOnce({ + maxBaseFee: String(GAS_LOW_MOCK), + priorityFee: '1', + }); + + await updateGasFees(updateGasFeeRequest); + + expect(updateGasFeeRequest.txMeta.txParams.maxFeePerGas).toBe( + GAS_LOW_HEX_WEI_MOCK, + ); + expect(updateGasFeeRequest.txMeta.userFeeLevel).toBe( + UserFeeLevel.CUSTOM, + ); + }); + + it('unless saved preference is level-based', async () => { + updateGasFeeRequest.getSavedGasFees.mockReturnValueOnce({ + level: GasFeeEstimateLevel.Low, + }); + + await updateGasFees(updateGasFeeRequest); + + expect(updateGasFeeRequest.txMeta.txParams.maxFeePerGas).toBe( + GAS_LOW_HEX_WEI_MOCK, + ); + expect(updateGasFeeRequest.txMeta.userFeeLevel).toBe( + GasFeeEstimateLevel.Low, + ); + }); + + it('unless feature flag is disabled', async () => { + getReplaceUnderpricedSavedGasFeesEnabledMock.mockReturnValue(false); + updateGasFeeRequest.getSavedGasFees.mockReturnValueOnce({ + maxBaseFee: String(GAS_LOW_MOCK - 1), + priorityFee: '1', + }); + + await updateGasFees(updateGasFeeRequest); + + expect(updateGasFeeRequest.txMeta.txParams.maxFeePerGas).toBe( + UNDERPRICED_GAS_HEX_WEI_MOCK, + ); + expect(updateGasFeeRequest.txMeta.userFeeLevel).toBe( + UserFeeLevel.CUSTOM, + ); + }); + + it('unless estimates are unavailable', async () => { + gasFeeFlowMock.getGasFees.mockRejectedValue(new Error('TestError')); + rpcRequestMock.mockResolvedValue(GAS_HEX_WEI_MOCK); + updateGasFeeRequest.getSavedGasFees.mockReturnValueOnce({ + maxBaseFee: String(GAS_LOW_MOCK - 1), + priorityFee: '1', + }); + + await updateGasFees(updateGasFeeRequest); + + expect(updateGasFeeRequest.txMeta.txParams.maxFeePerGas).toBe( + UNDERPRICED_GAS_HEX_WEI_MOCK, + ); + expect(updateGasFeeRequest.txMeta.userFeeLevel).toBe( + UserFeeLevel.CUSTOM, + ); + }); + }); }); }); diff --git a/packages/transaction-controller/src/utils/gas-fees.ts b/packages/transaction-controller/src/utils/gas-fees.ts index 82f00daeca7..97e97fa42d6 100644 --- a/packages/transaction-controller/src/utils/gas-fees.ts +++ b/packages/transaction-controller/src/utils/gas-fees.ts @@ -9,6 +9,7 @@ import { add0x, createModuleLogger } from '@metamask/utils'; import { projectLogger } from '../logger.js'; import type { TransactionControllerMessenger } from '../TransactionController.js'; import type { + FeeMarketGasFeeEstimateForLevel, SavedGasFees, TransactionParams, TransactionMeta, @@ -20,6 +21,10 @@ import { TransactionType, UserFeeLevel, } from '../types.js'; +import { + getReplaceUnderpricedDappGasFeesEnabled, + getReplaceUnderpricedSavedGasFeesEnabled, +} from './feature-flags.js'; import { getGasFeeFlow } from './gas-flow.js'; import { rpcRequest } from './provider.js'; import { SWAP_TRANSACTION_TYPES } from './swaps.js'; @@ -55,6 +60,8 @@ type SuggestedGasFees = { * or when the gas fee flow failed and a raw RPC fallback was used. */ isEstimateLevelApplied?: boolean; + + low?: FeeMarketGasFeeEstimateForLevel; }; const log = createModuleLogger(projectLogger, 'gas-fees'); @@ -81,7 +88,7 @@ export async function updateGasFees( txMeta.type as TransactionType, ); - const savedGasFees = + let savedGasFees = shouldIgnoreSavedGasFees || hasInitialGasFeeParams(initialParams) ? undefined : request.getSavedGasFees(txMeta); @@ -93,6 +100,17 @@ export async function updateGasFees( log('Suggested gas fees', suggestedGasFees); + if ( + shouldIgnoreUnderpricedSavedGasFees(savedGasFees, suggestedGasFees, request) + ) { + log( + 'Ignoring saved custom gas fees below low estimate', + savedGasFees?.maxBaseFee, + suggestedGasFees.low?.maxFeePerGas, + ); + savedGasFees = undefined; + } + const getGasFeeRequest: GetGasFeeRequest = { ...request, initialParams, @@ -153,6 +171,113 @@ export function gweiDecimalToWeiDecimal(gweiDecimal: string | number): string { return weiValue.toString().split('.')[0]; } +/** + * Determine whether saved (advanced) gas fee preferences should be ignored in + * favour of the suggested gas fees, as the saved custom `maxBaseFee` is below + * the current low estimate and hence unlikely to result in timely inclusion in + * a block. Level-based saved preferences track current estimates and are never + * ignored. + * + * @param savedGasFees - The saved gas fee preferences. + * @param suggestedGasFees - The suggested gas fees. + * @param request - The request object. + * @returns Whether the saved gas fee preferences should be ignored. + */ +function shouldIgnoreUnderpricedSavedGasFees( + savedGasFees: SavedGasFees | undefined, + suggestedGasFees: SuggestedGasFees, + request: UpdateGasFeesRequest, +): boolean { + const { eip1559, messenger, txMeta } = request; + + if ( + !eip1559 || + !savedGasFees?.maxBaseFee || + !getReplaceUnderpricedSavedGasFeesEnabled(txMeta.chainId, messenger) + ) { + return false; + } + + const lowMaxFeePerGas = suggestedGasFees.low?.maxFeePerGas; + + const hasReplacement = + Boolean(suggestedGasFees.maxFeePerGas) && + Boolean(suggestedGasFees.maxPriorityFeePerGas); + + if (!lowMaxFeePerGas || !hasReplacement) { + return false; + } + + return isFeeBelowLowEstimate( + gweiDecimalToWeiHex(savedGasFees.maxBaseFee), + lowMaxFeePerGas, + ); +} + +/** + * Determine whether a fee value is below the low estimate. + * + * @param valueHex - The fee value as a hex string. + * @param lowMaxFeePerGasHex - The low estimate `maxFeePerGas` as a hex string. + * @returns Whether the fee value is below the low estimate. + */ +function isFeeBelowLowEstimate( + valueHex: string, + lowMaxFeePerGasHex: string, +): boolean { + try { + return BigInt(valueHex) < BigInt(lowMaxFeePerGasHex); + } catch { + return false; + } +} + +/** + * Determine whether dapp-suggested gas fees should be ignored in favour of the + * suggested gas fees, as the dapp-suggested `maxFeePerGas` is below the current + * low estimate and hence unlikely to result in timely inclusion in a block. + * + * @param request - The request object. + * @returns Whether the dapp-suggested gas fees should be ignored. + */ +function shouldIgnoreDappGasFees(request: GetGasFeeRequest): boolean { + const { + eip1559, + initialParams, + messenger, + savedGasFees, + suggestedGasFees, + txMeta, + } = request; + + if ( + !eip1559 || + savedGasFees || + txMeta.isInternal || + !getReplaceUnderpricedDappGasFeesEnabled(txMeta.chainId, messenger) + ) { + return false; + } + + const dappMaxFeePerGas = + initialParams.maxFeePerGas ?? + (initialParams.gasPrice && !initialParams.maxPriorityFeePerGas + ? initialParams.gasPrice + : undefined); + + const lowMaxFeePerGas = suggestedGasFees.low?.maxFeePerGas; + + const hasReplacement = + Boolean(suggestedGasFees.maxFeePerGas) && + Boolean(suggestedGasFees.maxPriorityFeePerGas); + + if (!dappMaxFeePerGas || !lowMaxFeePerGas || !hasReplacement) { + return false; + } + + return isFeeBelowLowEstimate(dappMaxFeePerGas, lowMaxFeePerGas); +} + /** * Determine the maxFeePerGas value for the transaction. * @@ -172,12 +297,18 @@ function getMaxFeePerGas(request: GetGasFeeRequest): string | undefined { return maxFeePerGas; } - if (initialParams.maxFeePerGas) { + const ignoreDappGasFees = shouldIgnoreDappGasFees(request); + + if (initialParams.maxFeePerGas && !ignoreDappGasFees) { log('Using maxFeePerGas from request', initialParams.maxFeePerGas); return initialParams.maxFeePerGas; } - if (initialParams.gasPrice && !initialParams.maxPriorityFeePerGas) { + if ( + initialParams.gasPrice && + !initialParams.maxPriorityFeePerGas && + !ignoreDappGasFees + ) { log( 'Setting maxFeePerGas to gasPrice from request', initialParams.gasPrice, @@ -185,6 +316,14 @@ function getMaxFeePerGas(request: GetGasFeeRequest): string | undefined { return initialParams.gasPrice; } + if (ignoreDappGasFees) { + log( + 'Ignoring dapp-suggested maxFeePerGas below low estimate', + initialParams.maxFeePerGas ?? initialParams.gasPrice, + suggestedGasFees.low?.maxFeePerGas, + ); + } + if (suggestedGasFees.maxFeePerGas) { log('Using suggested maxFeePerGas', suggestedGasFees.maxFeePerGas); return suggestedGasFees.maxFeePerGas; @@ -227,7 +366,9 @@ function getMaxPriorityFeePerGas( return maxPriorityFeePerGas; } - if (initialParams.maxPriorityFeePerGas) { + const ignoreDappGasFees = shouldIgnoreDappGasFees(request); + + if (initialParams.maxPriorityFeePerGas && !ignoreDappGasFees) { log( 'Using maxPriorityFeePerGas from request', initialParams.maxPriorityFeePerGas, @@ -235,7 +376,11 @@ function getMaxPriorityFeePerGas( return initialParams.maxPriorityFeePerGas; } - if (initialParams.gasPrice && !initialParams.maxFeePerGas) { + if ( + initialParams.gasPrice && + !initialParams.maxFeePerGas && + !ignoreDappGasFees + ) { log( 'Setting maxPriorityFeePerGas to gasPrice from request', initialParams.gasPrice, @@ -329,6 +474,13 @@ function getUserFeeLevel(request: GetGasFeeRequest): string | undefined { return canUseSavedLevel ? savedGasFees.level : UserFeeLevel.CUSTOM; } + // Underpriced dapp-suggested fees are replaced with the suggested medium + // values, so also use the medium fee level to keep the values updated while + // the transaction is unapproved. + if (shouldIgnoreDappGasFees(request)) { + return UserFeeLevel.MEDIUM; + } + if ( !initialParams.maxFeePerGas && !initialParams.maxPriorityFeePerGas && @@ -398,11 +550,21 @@ async function getSuggestedGasFees( } = request; const { networkClientId } = txMeta; + const hasCompleteDappFees = + eip1559 && + Boolean(txMeta.txParams.maxFeePerGas) && + Boolean(txMeta.txParams.maxPriorityFeePerGas); + + // Estimates are still required for transactions with complete dapp-suggested + // fees if they may be replaced due to being underpriced. + const isEstimateRequiredForDappFees = + hasCompleteDappFees && + !txMeta.isInternal && + getReplaceUnderpricedDappGasFeesEnabled(txMeta.chainId, messenger); + if ( (!eip1559 && txMeta.txParams.gasPrice) || - (eip1559 && - txMeta.txParams.maxFeePerGas && - txMeta.txParams.maxPriorityFeePerGas) + (hasCompleteDappFees && !isEstimateRequiredForDappFees) ) { return {}; } @@ -431,6 +593,7 @@ async function getSuggestedGasFees( ...(response.estimates[savedGasFeeEstimateLevel] ?? response.estimates.medium), isEstimateLevelApplied: true, + low: response.estimates.low, }; case GasFeeEstimateType.Legacy: return { @@ -455,6 +618,13 @@ async function getSuggestedGasFees( log('Failed to get suggested gas fees', error); } + // Estimates on this path are only needed to check complete dapp-suggested + // fees, so no fallback is required and any failure must not block + // transaction creation. + if (hasCompleteDappFees) { + return {}; + } + const gasPriceHex = (await rpcRequest({ messenger, networkClientId,