From 516f1a3b959b3b40eac99fb9b4713ca908c8afad Mon Sep 17 00:00:00 2001 From: Sam Walker Date: Thu, 30 Jul 2026 02:23:16 -0400 Subject: [PATCH 1/9] fix: replace underpriced dapp-suggested gas fees with suggested estimates --- packages/transaction-controller/CHANGELOG.md | 8 ++ .../src/utils/feature-flags.ts | 43 +++++++ .../src/utils/gas-fees.test.ts | 111 ++++++++++++++++++ .../src/utils/gas-fees.ts | 106 +++++++++++++++-- 4 files changed, 261 insertions(+), 7 deletions(-) diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 2d462fae3ca..4f5250ba45c 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Replace underpriced dapp-suggested gas fees with the wallet's suggested estimates ([#XXXX](https://github.com/MetaMask/core/pull/XXXX)) + - 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. + ## [69.3.0] ### Added diff --git a/packages/transaction-controller/src/utils/feature-flags.ts b/packages/transaction-controller/src/utils/feature-flags.ts index d7bf191c5e0..b7852ba559e 100644 --- a/packages/transaction-controller/src/utils/feature-flags.ts +++ b/packages/transaction-controller/src/utils/feature-flags.ts @@ -186,6 +186,25 @@ 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; + }; }; }; @@ -465,6 +484,30 @@ 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 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..a7632548931 100644 --- a/packages/transaction-controller/src/utils/gas-fees.test.ts +++ b/packages/transaction-controller/src/utils/gas-fees.test.ts @@ -8,10 +8,13 @@ import { TransactionType, UserFeeLevel, } from '../types.js'; +import { getReplaceUnderpricedDappGasFeesEnabled } 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 +29,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 +104,9 @@ function createGasFeeFlowMock(): jest.Mocked { describe('gas-fees', () => { let updateGasFeeRequest: jest.Mocked; const rpcRequestMock = jest.mocked(rpcRequest); + const getReplaceUnderpricedDappGasFeesEnabledMock = jest.mocked( + getReplaceUnderpricedDappGasFeesEnabled, + ); let gasFeeFlowMock: jest.Mocked; /** @@ -753,6 +760,110 @@ 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 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, + ); + }); + }); }); }); diff --git a/packages/transaction-controller/src/utils/gas-fees.ts b/packages/transaction-controller/src/utils/gas-fees.ts index 82f00daeca7..9becc87316c 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,7 @@ import { TransactionType, UserFeeLevel, } from '../types.js'; +import { getReplaceUnderpricedDappGasFeesEnabled } from './feature-flags.js'; import { getGasFeeFlow } from './gas-flow.js'; import { rpcRequest } from './provider.js'; import { SWAP_TRANSACTION_TYPES } from './swaps.js'; @@ -55,6 +57,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'); @@ -153,6 +157,56 @@ export function gweiDecimalToWeiDecimal(gweiDecimal: string | number): string { return weiValue.toString().split('.')[0]; } +/** + * 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; + } + + try { + return BigInt(dappMaxFeePerGas) < BigInt(lowMaxFeePerGas); + } catch { + return false; + } +} + /** * Determine the maxFeePerGas value for the transaction. * @@ -172,12 +226,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 +245,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 +295,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 +305,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 +403,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 +479,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 +522,7 @@ async function getSuggestedGasFees( ...(response.estimates[savedGasFeeEstimateLevel] ?? response.estimates.medium), isEstimateLevelApplied: true, + low: response.estimates.low, }; case GasFeeEstimateType.Legacy: return { From 6eb86a60126e76434d5957af4e20d974d204e5b8 Mon Sep 17 00:00:00 2001 From: Sam Walker Date: Thu, 30 Jul 2026 02:43:11 -0400 Subject: [PATCH 2/9] chore: add PR number to changelog entry --- packages/transaction-controller/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 4f5250ba45c..0880afab0fb 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Replace underpriced dapp-suggested gas fees with the wallet's suggested estimates ([#XXXX](https://github.com/MetaMask/core/pull/XXXX)) +- 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. From 0e03ffd2b1d7d9891b07a192e28ce2a71498c045 Mon Sep 17 00:00:00 2001 From: Sam Walker Date: Thu, 30 Jul 2026 02:51:04 -0400 Subject: [PATCH 3/9] chore: fix formatting --- packages/transaction-controller/src/utils/gas-fees.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/transaction-controller/src/utils/gas-fees.test.ts b/packages/transaction-controller/src/utils/gas-fees.test.ts index a7632548931..92af7fddd17 100644 --- a/packages/transaction-controller/src/utils/gas-fees.test.ts +++ b/packages/transaction-controller/src/utils/gas-fees.test.ts @@ -811,8 +811,7 @@ describe('gas-fees', () => { }); it('unless request maxFeePerGas is at or above low estimate', async () => { - updateGasFeeRequest.txMeta.txParams.maxFeePerGas = - GAS_LOW_HEX_WEI_MOCK; + updateGasFeeRequest.txMeta.txParams.maxFeePerGas = GAS_LOW_HEX_WEI_MOCK; await updateGasFees(updateGasFeeRequest); From 6fa10b868747202ba9bebb60b30caeeda8509977 Mon Sep 17 00:00:00 2001 From: Sam Walker Date: Thu, 30 Jul 2026 04:22:53 -0400 Subject: [PATCH 4/9] fix: ignore underpriced saved gas fee preferences --- packages/transaction-controller/CHANGELOG.md | 5 + .../src/utils/feature-flags.ts | 45 ++++++++ .../src/utils/gas-fees.test.ts | 100 +++++++++++++++++- .../src/utils/gas-fees.ts | 65 +++++++++++- 4 files changed, 212 insertions(+), 3 deletions(-) diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 0880afab0fb..61a6aefc168 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -14,6 +14,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 ([#YYYY](https://github.com/MetaMask/core/pull/YYYY)) + - 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. ## [69.3.0] diff --git a/packages/transaction-controller/src/utils/feature-flags.ts b/packages/transaction-controller/src/utils/feature-flags.ts index b7852ba559e..a1c4d215813 100644 --- a/packages/transaction-controller/src/utils/feature-flags.ts +++ b/packages/transaction-controller/src/utils/feature-flags.ts @@ -205,6 +205,27 @@ export type TransactionControllerFeatureFlags = { */ 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; + }; }; }; @@ -508,6 +529,30 @@ export function getReplaceUnderpricedDappGasFeesEnabled( ); } +/** + * 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 92af7fddd17..e6debc29e08 100644 --- a/packages/transaction-controller/src/utils/gas-fees.test.ts +++ b/packages/transaction-controller/src/utils/gas-fees.test.ts @@ -8,7 +8,10 @@ import { TransactionType, UserFeeLevel, } from '../types.js'; -import { getReplaceUnderpricedDappGasFeesEnabled } from './feature-flags.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'; @@ -107,6 +110,9 @@ describe('gas-fees', () => { const getReplaceUnderpricedDappGasFeesEnabledMock = jest.mocked( getReplaceUnderpricedDappGasFeesEnabled, ); + const getReplaceUnderpricedSavedGasFeesEnabledMock = jest.mocked( + getReplaceUnderpricedSavedGasFeesEnabled, + ); let gasFeeFlowMock: jest.Mocked; /** @@ -863,6 +869,98 @@ describe('gas-fees', () => { ); }); }); + + 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 9becc87316c..1746ba62c77 100644 --- a/packages/transaction-controller/src/utils/gas-fees.ts +++ b/packages/transaction-controller/src/utils/gas-fees.ts @@ -21,7 +21,10 @@ import { TransactionType, UserFeeLevel, } from '../types.js'; -import { getReplaceUnderpricedDappGasFeesEnabled } from './feature-flags.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'; @@ -85,7 +88,7 @@ export async function updateGasFees( txMeta.type as TransactionType, ); - const savedGasFees = + let savedGasFees = shouldIgnoreSavedGasFees || hasInitialGasFeeParams(initialParams) ? undefined : request.getSavedGasFees(txMeta); @@ -97,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, @@ -157,6 +171,53 @@ 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; + } + + try { + return ( + BigInt(gweiDecimalToWeiHex(savedGasFees.maxBaseFee)) < + BigInt(lowMaxFeePerGas) + ); + } 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 From 63124d228f195be20be6485ed1d6d3b894c93b83 Mon Sep 17 00:00:00 2001 From: Sam Walker Date: Thu, 30 Jul 2026 04:24:00 -0400 Subject: [PATCH 5/9] chore: add PR number to changelog entry --- packages/transaction-controller/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 61a6aefc168..c43ffb40acd 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -14,7 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 ([#YYYY](https://github.com/MetaMask/core/pull/YYYY)) +- Ignore underpriced saved (advanced) gas fee preferences in favour of the wallet's suggested estimates ([#9705](https://github.com/MetaMask/core/pull/9705)) - 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. From e8ed74507b7b8e798c01b1eda8c562e8527209fb Mon Sep 17 00:00:00 2001 From: Sam Walker Date: Thu, 30 Jul 2026 04:25:31 -0400 Subject: [PATCH 6/9] chore: reference combined PR in changelog --- packages/transaction-controller/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index c43ffb40acd..663edc3ca24 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -14,7 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 ([#9705](https://github.com/MetaMask/core/pull/9705)) +- 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. From 2bc4f99dd69de191334f73f6b22fcc5eee4923b5 Mon Sep 17 00:00:00 2001 From: Sam Walker Date: Thu, 30 Jul 2026 04:40:02 -0400 Subject: [PATCH 7/9] test: restore coverage thresholds --- .../src/utils/feature-flags.test.ts | 48 +++++++++++++++++++ .../src/utils/gas-fees.test.ts | 46 ++++++++++++++++++ .../src/utils/gas-fees.ts | 28 +++++++---- 3 files changed, 113 insertions(+), 9 deletions(-) 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/gas-fees.test.ts b/packages/transaction-controller/src/utils/gas-fees.test.ts index e6debc29e08..f385dbbb40a 100644 --- a/packages/transaction-controller/src/utils/gas-fees.test.ts +++ b/packages/transaction-controller/src/utils/gas-fees.test.ts @@ -855,6 +855,34 @@ describe('gas-fees', () => { ); }); + 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); @@ -870,6 +898,24 @@ describe('gas-fees', () => { }); }); + it('uses gasPrice fallback if flow returns unsupported estimate type', async () => { + mockGasFeeFlowMockResponse({ + estimates: { + type: 'unsupportedType', + }, + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any 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); diff --git a/packages/transaction-controller/src/utils/gas-fees.ts b/packages/transaction-controller/src/utils/gas-fees.ts index 1746ba62c77..eb0c2ab3ed1 100644 --- a/packages/transaction-controller/src/utils/gas-fees.ts +++ b/packages/transaction-controller/src/utils/gas-fees.ts @@ -208,11 +208,25 @@ function shouldIgnoreUnderpricedSavedGasFees( 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(gweiDecimalToWeiHex(savedGasFees.maxBaseFee)) < - BigInt(lowMaxFeePerGas) - ); + return BigInt(valueHex) < BigInt(lowMaxFeePerGasHex); } catch { return false; } @@ -261,11 +275,7 @@ function shouldIgnoreDappGasFees(request: GetGasFeeRequest): boolean { return false; } - try { - return BigInt(dappMaxFeePerGas) < BigInt(lowMaxFeePerGas); - } catch { - return false; - } + return isFeeBelowLowEstimate(dappMaxFeePerGas, lowMaxFeePerGas); } /** From 7f376164f72d23e19e04ea45345583c1844f4949 Mon Sep 17 00:00:00 2001 From: Sam Walker Date: Thu, 30 Jul 2026 17:23:37 -0400 Subject: [PATCH 8/9] fix: fail open when estimates unavailable on dapp fee comparison path --- .../src/utils/gas-fees.test.ts | 22 ++++++++++++++++--- .../src/utils/gas-fees.ts | 7 ++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/packages/transaction-controller/src/utils/gas-fees.test.ts b/packages/transaction-controller/src/utils/gas-fees.test.ts index f385dbbb40a..cd05178a9cb 100644 --- a/packages/transaction-controller/src/utils/gas-fees.test.ts +++ b/packages/transaction-controller/src/utils/gas-fees.test.ts @@ -896,6 +896,24 @@ describe('gas-fees', () => { 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 () => { @@ -903,9 +921,7 @@ describe('gas-fees', () => { estimates: { type: 'unsupportedType', }, - // TODO: Replace `any` with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any as GasFeeFlowResponse); + } as unknown as GasFeeFlowResponse); rpcRequestMock.mockResolvedValue(GAS_HEX_WEI_MOCK); diff --git a/packages/transaction-controller/src/utils/gas-fees.ts b/packages/transaction-controller/src/utils/gas-fees.ts index eb0c2ab3ed1..97e97fa42d6 100644 --- a/packages/transaction-controller/src/utils/gas-fees.ts +++ b/packages/transaction-controller/src/utils/gas-fees.ts @@ -618,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, From 95e622d6bc48a4df2aa6fed0079724ae8b99cf80 Mon Sep 17 00:00:00 2001 From: Sam Walker Date: Mon, 3 Aug 2026 22:39:02 -0400 Subject: [PATCH 9/9] chore: restore changelog entries to unreleased section after 69.4.0 release --- packages/transaction-controller/CHANGELOG.md | 23 +++++++++----------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index f12926a97bf..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 @@ -30,19 +40,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/network-controller` from `^34.0.0` to `^35.0.0` ([#9735](https://github.com/MetaMask/core/pull/9735)) - Bump `@metamask/remote-feature-flag-controller` from `^4.2.2` to `^5.0.0` ([#9735](https://github.com/MetaMask/core/pull/9735)) -### Changed - -- 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. - ## [69.3.0] ### Added