From 5b511882357f8fb27bc357ab2f9a960f78602c50 Mon Sep 17 00:00:00 2001 From: Pedro Figueiredo Date: Wed, 3 Jun 2026 17:39:07 +0100 Subject: [PATCH 1/5] feat(transaction-controller): support saved gas fee levels --- packages/transaction-controller/CHANGELOG.md | 5 +- .../src/TransactionController.test.ts | 22 +++++ .../src/TransactionController.ts | 52 ++++++++++- packages/transaction-controller/src/types.ts | 8 +- .../src/utils/gas-fees.test.ts | 93 ++++++++++++++++++- .../src/utils/gas-fees.ts | 82 ++++++++++++---- 6 files changed, 236 insertions(+), 26 deletions(-) diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index f60308e2b81..4364d84a71f 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **BREAKING:** Expand saved gas fee support to allow transaction-scoped lookup, saved gas fee estimate levels, and legacy gas price values. Consumers that provide `getSavedGasFees` must now accept `TransactionMeta` instead of a chain ID. ([#8993](https://github.com/MetaMask/core/pull/8993)) - Bump `@metamask/messenger` from `^1.2.0` to `^2.0.0` ([#9392](https://github.com/MetaMask/core/pull/9392)) ### Fixed @@ -97,8 +98,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed - **BREAKING:** Remove incoming transaction support from `TransactionController` ([#9012](https://github.com/MetaMask/core/pull/9012)) - - Removed constructor option `incomingTransactions`. - - Removed public methods `startIncomingTransactionPolling`, `stopIncomingTransactionPolling`, `updateIncomingTransactions`. + - The constructor option `incomingTransactions` is ignored for backwards compatibility. + - Removed public method `updateIncomingTransactions`; `startIncomingTransactionPolling` and `stopIncomingTransactionPolling` are retained as no-ops for backwards compatibility. - Removed event `TransactionController:incomingTransactionsReceived`. - Removed exported constant `INCOMING_TRANSACTIONS_SUPPORTED_CHAIN_IDS`. - Removed exported types `TransactionControllerIncomingTransactionsReceivedEvent`, `TransactionControllerStartIncomingTransactionPollingAction`, `TransactionControllerStopIncomingTransactionPollingAction`, `TransactionControllerUpdateIncomingTransactionsAction`, `TransactionResponse`, `GetAccountTransactionsRequest`, `GetAccountTransactionsResponse`. diff --git a/packages/transaction-controller/src/TransactionController.test.ts b/packages/transaction-controller/src/TransactionController.test.ts index 14fe25b561a..a1c1d072381 100644 --- a/packages/transaction-controller/src/TransactionController.test.ts +++ b/packages/transaction-controller/src/TransactionController.test.ts @@ -8189,6 +8189,28 @@ describe('TransactionController', () => { } `); }); + + it('accepts ignored incoming transaction compatibility options', () => { + expect(() => + setupController({ + options: { + incomingTransactions: { + client: 'extension-test', + includeTokenTransfers: false, + isEnabled: () => false, + updateTransactions: true, + }, + }, + }), + ).not.toThrow(); + }); + + it('keeps incoming transaction polling compatibility methods as no-ops', () => { + const { controller } = setupController(); + + expect(() => controller.startIncomingTransactionPolling()).not.toThrow(); + expect(() => controller.stopIncomingTransactionPolling()).not.toThrow(); + }); }); describe('messenger actions', () => { diff --git a/packages/transaction-controller/src/TransactionController.ts b/packages/transaction-controller/src/TransactionController.ts index 93a2084b7f4..b0ff4cd3533 100644 --- a/packages/transaction-controller/src/TransactionController.ts +++ b/packages/transaction-controller/src/TransactionController.ts @@ -3,6 +3,7 @@ import type { TypedTxData } from '@ethereumjs/tx'; import type { AccountsControllerGetSelectedAccountAction, AccountsControllerGetStateAction, + AccountsControllerSelectedAccountChangeEvent, } from '@metamask/accounts-controller'; import type { AcceptResultCallbacks, @@ -21,7 +22,11 @@ import { convertHexToDecimal, } from '@metamask/controller-utils'; import type { TraceCallback, TraceContext } from '@metamask/controller-utils'; -import type { AccountActivityServiceTransactionUpdatedEvent } from '@metamask/core-backend'; +import type { + AccountActivityServiceStatusChangedEvent, + AccountActivityServiceTransactionUpdatedEvent, + BackendWebSocketServiceConnectionStateChangedEvent, +} from '@metamask/core-backend'; import type { FetchGasFeeEstimateOptions, GasFeeControllerFetchGasFeeEstimatesAction, @@ -313,6 +318,18 @@ export type TransactionControllerActions = | TransactionControllerGetStateAction | TransactionControllerMethodActions; +/** + * @deprecated Incoming transaction support has been removed. These options are ignored. + */ +export type IncomingTransactionCompatibilityOptions = { + client?: string; + includeTokenTransfers?: boolean; + isEnabled?: () => boolean; + updateTransactions?: boolean; + /** @deprecated Ignored as incoming transaction support has been removed. */ + etherscanApiKeysByChainId?: Record; +}; + /** TransactionController constructor options. */ export type TransactionControllerOptions = { /** Whether to disable additional processing on swaps transactions. */ @@ -322,13 +339,20 @@ export type TransactionControllerOptions = { getPermittedAccounts?: (origin?: string) => Promise; /** Gets the saved gas fee config. */ - getSavedGasFees?: (chainId: Hex) => SavedGasFees | undefined; + getSavedGasFees?: ( + transactionMeta: TransactionMeta, + ) => SavedGasFees | undefined; /** * Gets the transaction simulation configuration. */ getSimulationConfig?: GetSimulationConfig; + /** + * @deprecated Incoming transaction support has been removed. This option is ignored. + */ + incomingTransactions?: IncomingTransactionCompatibilityOptions; + /** * Callback to determine whether gas fee updates should be enabled for a given transaction. * Returns true to enable updates, false to disable them. @@ -422,7 +446,10 @@ export type AllowedActions = * The external events available to the {@link TransactionController}. */ export type AllowedEvents = + | AccountActivityServiceStatusChangedEvent | AccountActivityServiceTransactionUpdatedEvent + | AccountsControllerSelectedAccountChangeEvent + | BackendWebSocketServiceConnectionStateChangedEvent | NetworkControllerStateChangeEvent; /** @@ -700,7 +727,9 @@ export class TransactionController extends BaseController< readonly #getPermittedAccounts?: (origin?: string) => Promise; - readonly #getSavedGasFees: (chainId: Hex) => SavedGasFees | undefined; + readonly #getSavedGasFees: ( + transactionMeta: TransactionMeta, + ) => SavedGasFees | undefined; readonly #getSimulationConfig: GetSimulationConfig; @@ -794,7 +823,8 @@ export class TransactionController extends BaseController< ((): ReturnType => Promise.resolve({})); this.#getPermittedAccounts = getPermittedAccounts; this.#getSavedGasFees = - getSavedGasFees ?? ((_chainId): SavedGasFees | undefined => undefined); + getSavedGasFees ?? + ((_transactionMeta): SavedGasFees | undefined => undefined); this.#getSimulationConfig = getSimulationConfig ?? ((): ReturnType => Promise.resolve({})); @@ -928,6 +958,20 @@ export class TransactionController extends BaseController< this.#stopAllTracking(); } + /** + * @deprecated Incoming transaction support has been removed. This method is retained as a no-op for backwards compatibility. + */ + startIncomingTransactionPolling(): void { + noop(); + } + + /** + * @deprecated Incoming transaction support has been removed. This method is retained as a no-op for backwards compatibility. + */ + stopIncomingTransactionPolling(): void { + noop(); + } + /** * Handle new method data request. * diff --git a/packages/transaction-controller/src/types.ts b/packages/transaction-controller/src/types.ts index c4996acf19b..7fa97cdcc32 100644 --- a/packages/transaction-controller/src/types.ts +++ b/packages/transaction-controller/src/types.ts @@ -1257,13 +1257,15 @@ export type DappSuggestedGasFees = { }; /** - * Gas values saved by the user for a specific chain. + * Gas values saved by the user for a specific chain and account. */ // Convert to a `type` in a future major version. // eslint-disable-next-line @typescript-eslint/consistent-type-definitions export interface SavedGasFees { - maxBaseFee: string; - priorityFee: string; + level?: UserFeeLevel | GasFeeEstimateLevel; + maxBaseFee?: string; + priorityFee?: string; + gasPrice?: string; } /** diff --git a/packages/transaction-controller/src/utils/gas-fees.test.ts b/packages/transaction-controller/src/utils/gas-fees.test.ts index e90dc0a1f66..faa6e324bb1 100644 --- a/packages/transaction-controller/src/utils/gas-fees.test.ts +++ b/packages/transaction-controller/src/utils/gas-fees.test.ts @@ -2,7 +2,12 @@ import type { NetworkClientId } from '@metamask/network-controller'; import type { TransactionControllerMessenger } from '../TransactionController'; import type { GasFeeFlow, GasFeeFlowResponse } from '../types'; -import { GasFeeEstimateType, TransactionType, UserFeeLevel } from '../types'; +import { + GasFeeEstimateLevel, + GasFeeEstimateType, + TransactionType, + UserFeeLevel, +} from '../types'; import type { UpdateGasFeesRequest } from './gas-fees'; import { gweiDecimalToWeiDecimal, updateGasFees } from './gas-fees'; import { rpcRequest } from './provider'; @@ -15,8 +20,12 @@ jest.mock('./provider', () => ({ console.error = jest.fn(); const GAS_MOCK = 123; +const GAS_LOW_MOCK = 111; +const GAS_HIGH_MOCK = 789; 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 ORIGIN_MOCK = 'test.com'; const MESSENGER_MOCK = {} as unknown as TransactionControllerMessenger; const NETWORK_CLIENT_ID_MOCK = 'testNetworkClientId' as NetworkClientId; @@ -35,17 +44,27 @@ const UPDATE_GAS_FEES_REQUEST_MOCK = { const FLOW_RESPONSE_FEE_MARKET_MOCK = { estimates: { type: GasFeeEstimateType.FeeMarket, + low: { + maxFeePerGas: GAS_LOW_HEX_WEI_MOCK, + maxPriorityFeePerGas: GAS_LOW_HEX_WEI_MOCK, + }, medium: { maxFeePerGas: GAS_HEX_WEI_MOCK, maxPriorityFeePerGas: GAS_HEX_WEI_MOCK, }, + high: { + maxFeePerGas: GAS_HIGH_HEX_WEI_MOCK, + maxPriorityFeePerGas: GAS_HIGH_HEX_WEI_MOCK, + }, }, } as GasFeeFlowResponse; const FLOW_RESPONSE_LEGACY_MOCK = { estimates: { type: GasFeeEstimateType.Legacy, + low: GAS_LOW_HEX_WEI_MOCK, medium: GAS_HEX_WEI_MOCK, + high: GAS_HIGH_HEX_WEI_MOCK, }, } as GasFeeFlowResponse; @@ -222,6 +241,78 @@ describe('gas-fees', () => { expect(updateGasFeeRequest.getGasFeeEstimates).not.toHaveBeenCalled(); }); + it('calls getSavedGasFees with the transaction metadata', async () => { + updateGasFeeRequest.txMeta.type = TransactionType.simpleSend; + + await updateGasFees(updateGasFeeRequest); + + expect(updateGasFeeRequest.getSavedGasFees).toHaveBeenCalledWith( + updateGasFeeRequest.txMeta, + ); + }); + + it('does not call getSavedGasFees if initial gas fee params are provided', async () => { + updateGasFeeRequest.txMeta.type = TransactionType.simpleSend; + updateGasFeeRequest.txMeta.txParams.maxFeePerGas = GAS_HEX_MOCK; + updateGasFeeRequest.txMeta.txParams.maxPriorityFeePerGas = GAS_HEX_MOCK; + + await updateGasFees(updateGasFeeRequest); + + expect(updateGasFeeRequest.getSavedGasFees).not.toHaveBeenCalled(); + }); + + it('uses saved fee market estimate level if saved gas fees include a level', async () => { + updateGasFeeRequest.txMeta.type = TransactionType.simpleSend; + updateGasFeeRequest.getSavedGasFees.mockReturnValueOnce({ + level: GasFeeEstimateLevel.High, + }); + mockGasFeeFlowMockResponse(FLOW_RESPONSE_FEE_MARKET_MOCK); + + await updateGasFees(updateGasFeeRequest); + + expect(updateGasFeeRequest.txMeta.txParams.maxFeePerGas).toBe( + GAS_HIGH_HEX_WEI_MOCK, + ); + expect(updateGasFeeRequest.txMeta.txParams.maxPriorityFeePerGas).toBe( + GAS_HIGH_HEX_WEI_MOCK, + ); + expect(updateGasFeeRequest.txMeta.userFeeLevel).toBe( + GasFeeEstimateLevel.High, + ); + }); + + it('uses saved legacy estimate level if saved gas fees include a level', async () => { + updateGasFeeRequest.eip1559 = false; + updateGasFeeRequest.txMeta.type = TransactionType.simpleSend; + updateGasFeeRequest.getSavedGasFees.mockReturnValueOnce({ + level: GasFeeEstimateLevel.Low, + }); + mockGasFeeFlowMockResponse(FLOW_RESPONSE_LEGACY_MOCK); + + await updateGasFees(updateGasFeeRequest); + + expect(updateGasFeeRequest.txMeta.txParams.gasPrice).toBe( + GAS_LOW_HEX_WEI_MOCK, + ); + expect(updateGasFeeRequest.txMeta.userFeeLevel).toBe( + GasFeeEstimateLevel.Low, + ); + }); + + it('uses saved gasPrice if saved gas fees include a legacy custom value', async () => { + updateGasFeeRequest.eip1559 = false; + updateGasFeeRequest.txMeta.type = TransactionType.simpleSend; + updateGasFeeRequest.getSavedGasFees.mockReturnValueOnce({ + level: UserFeeLevel.CUSTOM, + gasPrice: '10', + }); + + await updateGasFees(updateGasFeeRequest); + + expect(updateGasFeeRequest.txMeta.txParams.gasPrice).toBe('0x2540be400'); + expect(updateGasFeeRequest.txMeta.userFeeLevel).toBe(UserFeeLevel.CUSTOM); + }); + describe('sets maxFeePerGas', () => { it('to undefined if not eip1559', async () => { updateGasFeeRequest.eip1559 = false; diff --git a/packages/transaction-controller/src/utils/gas-fees.ts b/packages/transaction-controller/src/utils/gas-fees.ts index 7c3755f7035..1cf4d33a6fa 100644 --- a/packages/transaction-controller/src/utils/gas-fees.ts +++ b/packages/transaction-controller/src/utils/gas-fees.ts @@ -14,7 +14,11 @@ import type { TransactionMeta, GasFeeFlow, } from '../types'; -import { GasFeeEstimateType, UserFeeLevel } from '../types'; +import { + GasFeeEstimateLevel, + GasFeeEstimateType, + UserFeeLevel, +} from '../types'; import { getGasFeeFlow } from './gas-flow'; import { rpcRequest } from './provider'; @@ -24,7 +28,9 @@ export type UpdateGasFeesRequest = { getGasFeeEstimates: ( options: FetchGasFeeEstimateOptions, ) => Promise; - getSavedGasFees: (chainId: Hex) => SavedGasFees | undefined; + getSavedGasFees: ( + transactionMeta: TransactionMeta, + ) => SavedGasFees | undefined; messenger: TransactionControllerMessenger; txMeta: TransactionMeta; }; @@ -58,11 +64,15 @@ export async function updateGasFees( // transactions (e.g. swaps and bridges) have their fees dictated by the // aggregator or relay, so applying saved gas fees could underprice them and // cause them to fail or get stuck. - const savedGasFees = txMeta.isInternal - ? undefined - : request.getSavedGasFees(txMeta.chainId); + const savedGasFees = + txMeta.isInternal || hasInitialGasFeeParams(initialParams) + ? undefined + : request.getSavedGasFees(txMeta); - const suggestedGasFees = await getSuggestedGasFees(request); + const suggestedGasFees = await getSuggestedGasFees({ + ...request, + savedGasFees, + }); log('Suggested gas fees', suggestedGasFees); @@ -139,7 +149,7 @@ function getMaxFeePerGas(request: GetGasFeeRequest): string | undefined { return undefined; } - if (savedGasFees) { + if (savedGasFees?.maxBaseFee) { const maxFeePerGas = gweiDecimalToWeiHex(savedGasFees.maxBaseFee); log('Using maxFeePerGas from savedGasFees', maxFeePerGas); return maxFeePerGas; @@ -191,7 +201,7 @@ function getMaxPriorityFeePerGas( return undefined; } - if (savedGasFees) { + if (savedGasFees?.priorityFee) { const maxPriorityFeePerGas = gweiDecimalToWeiHex(savedGasFees.priorityFee); log( 'Using maxPriorityFeePerGas from savedGasFees.priorityFee', @@ -243,12 +253,18 @@ function getMaxPriorityFeePerGas( * @returns The gasPrice value. */ function getGasPrice(request: GetGasFeeRequest): string | undefined { - const { eip1559, initialParams, suggestedGasFees } = request; + const { eip1559, initialParams, savedGasFees, suggestedGasFees } = request; if (eip1559) { return undefined; } + if (savedGasFees?.gasPrice) { + const gasPrice = gweiDecimalToWeiHex(savedGasFees.gasPrice); + log('Using gasPrice from savedGasFees.gasPrice', gasPrice); + return gasPrice; + } + if (initialParams.gasPrice) { log('Using gasPrice from request', initialParams.gasPrice); return initialParams.gasPrice; @@ -274,11 +290,11 @@ function getGasPrice(request: GetGasFeeRequest): string | undefined { * @param request - The request object. * @returns The user fee level. */ -function getUserFeeLevel(request: GetGasFeeRequest): UserFeeLevel | undefined { +function getUserFeeLevel(request: GetGasFeeRequest): string | undefined { const { initialParams, savedGasFees, suggestedGasFees, txMeta } = request; if (savedGasFees) { - return UserFeeLevel.CUSTOM; + return savedGasFees.level ?? UserFeeLevel.CUSTOM; } if ( @@ -338,10 +354,16 @@ function updateDefaultGasEstimates(txMeta: TransactionMeta): void { * @returns The suggested gas fees. */ async function getSuggestedGasFees( - request: UpdateGasFeesRequest, + request: UpdateGasFeesRequest & { savedGasFees?: SavedGasFees }, ): Promise { - const { eip1559, gasFeeFlows, getGasFeeEstimates, messenger, txMeta } = - request; + const { + eip1559, + gasFeeFlows, + getGasFeeEstimates, + messenger, + savedGasFees, + txMeta, + } = request; const { networkClientId } = txMeta; if ( @@ -369,13 +391,19 @@ async function getSuggestedGasFees( }); const gasFeeEstimateType = response.estimates?.type; + const savedGasFeeEstimateLevel = getSavedGasFeeEstimateLevel(savedGasFees); switch (gasFeeEstimateType) { case GasFeeEstimateType.FeeMarket: - return response.estimates.medium; + return ( + response.estimates[savedGasFeeEstimateLevel] ?? + response.estimates.medium + ); case GasFeeEstimateType.Legacy: return { - gasPrice: response.estimates.medium, + gasPrice: + response.estimates[savedGasFeeEstimateLevel] ?? + response.estimates.medium, }; case GasFeeEstimateType.GasPrice: return { gasPrice: response.estimates.gasPrice }; @@ -400,3 +428,25 @@ async function getSuggestedGasFees( return { gasPrice }; } + +function hasInitialGasFeeParams(initialParams: TransactionParams): boolean { + return [ + initialParams.maxFeePerGas, + initialParams.maxPriorityFeePerGas, + initialParams.gasPrice, + ].some(Boolean); +} + +function getSavedGasFeeEstimateLevel( + savedGasFees: SavedGasFees | undefined, +): GasFeeEstimateLevel { + return isGasFeeEstimateLevel(savedGasFees?.level) + ? savedGasFees.level + : GasFeeEstimateLevel.Medium; +} + +function isGasFeeEstimateLevel(level: unknown): level is GasFeeEstimateLevel { + return Object.values(GasFeeEstimateLevel).includes( + level as GasFeeEstimateLevel, + ); +} From 8b75c35b6d955da22cc2dbd50a5dbf13ea992215 Mon Sep 17 00:00:00 2001 From: Pedro Figueiredo Date: Fri, 3 Jul 2026 14:04:30 +0100 Subject: [PATCH 2/5] fix(transaction-controller): treat saved gas fee overrides as custom even when a level is set Revert an unrelated edit to the already-released 68.0.0 changelog entry that crept in from a prior rebase, and fix getUserFeeLevel to return CUSTOM whenever savedGasFees includes an explicit maxBaseFee, priorityFee, or gasPrice override, even if a level is also present. Without this, GasFeePoller would treat the transaction as fully level-tracked and silently overwrite the custom override on the next automatic gas fee update. Co-Authored-By: Claude Sonnet 5 --- packages/transaction-controller/CHANGELOG.md | 4 ++-- .../src/utils/gas-fees.test.ts | 18 ++++++++++++++++++ .../src/utils/gas-fees.ts | 12 +++++++++++- 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 4364d84a71f..293bfadb29f 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -98,8 +98,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed - **BREAKING:** Remove incoming transaction support from `TransactionController` ([#9012](https://github.com/MetaMask/core/pull/9012)) - - The constructor option `incomingTransactions` is ignored for backwards compatibility. - - Removed public method `updateIncomingTransactions`; `startIncomingTransactionPolling` and `stopIncomingTransactionPolling` are retained as no-ops for backwards compatibility. + - Removed constructor option `incomingTransactions`. + - Removed public methods `startIncomingTransactionPolling`, `stopIncomingTransactionPolling`, `updateIncomingTransactions`. - Removed event `TransactionController:incomingTransactionsReceived`. - Removed exported constant `INCOMING_TRANSACTIONS_SUPPORTED_CHAIN_IDS`. - Removed exported types `TransactionControllerIncomingTransactionsReceivedEvent`, `TransactionControllerStartIncomingTransactionPollingAction`, `TransactionControllerStopIncomingTransactionPollingAction`, `TransactionControllerUpdateIncomingTransactionsAction`, `TransactionResponse`, `GetAccountTransactionsRequest`, `GetAccountTransactionsResponse`. diff --git a/packages/transaction-controller/src/utils/gas-fees.test.ts b/packages/transaction-controller/src/utils/gas-fees.test.ts index faa6e324bb1..9e6ab7b2a3a 100644 --- a/packages/transaction-controller/src/utils/gas-fees.test.ts +++ b/packages/transaction-controller/src/utils/gas-fees.test.ts @@ -299,6 +299,24 @@ describe('gas-fees', () => { ); }); + it('sets userFeeLevel to custom if saved gas fees include both a level and a custom override', async () => { + updateGasFeeRequest.txMeta.type = TransactionType.simpleSend; + updateGasFeeRequest.getSavedGasFees.mockReturnValueOnce({ + level: GasFeeEstimateLevel.High, + maxBaseFee: '123', + }); + mockGasFeeFlowMockResponse(FLOW_RESPONSE_FEE_MARKET_MOCK); + + await updateGasFees(updateGasFeeRequest); + + expect(updateGasFeeRequest.txMeta.txParams.maxFeePerGas).toBe( + '0x1ca35f0e00', // 123 gwei + ); + expect(updateGasFeeRequest.txMeta.userFeeLevel).toBe( + UserFeeLevel.CUSTOM, + ); + }); + it('uses saved gasPrice if saved gas fees include a legacy custom value', async () => { updateGasFeeRequest.eip1559 = false; updateGasFeeRequest.txMeta.type = TransactionType.simpleSend; diff --git a/packages/transaction-controller/src/utils/gas-fees.ts b/packages/transaction-controller/src/utils/gas-fees.ts index 1cf4d33a6fa..7fb4917cf8b 100644 --- a/packages/transaction-controller/src/utils/gas-fees.ts +++ b/packages/transaction-controller/src/utils/gas-fees.ts @@ -294,7 +294,17 @@ function getUserFeeLevel(request: GetGasFeeRequest): string | undefined { const { initialParams, savedGasFees, suggestedGasFees, txMeta } = request; if (savedGasFees) { - return savedGasFees.level ?? UserFeeLevel.CUSTOM; + const hasCustomOverride = + savedGasFees.maxBaseFee !== undefined || + savedGasFees.priorityFee !== undefined || + savedGasFees.gasPrice !== undefined; + + // A custom override on any field means the fee is no longer purely + // level-derived, so it must not be tracked as a live estimate level by + // the gas fee poller, which would otherwise overwrite the override. + return hasCustomOverride + ? UserFeeLevel.CUSTOM + : (savedGasFees.level ?? UserFeeLevel.CUSTOM); } if ( From 231bfed2c88be2c8f5bc34ef3826c6ab6e7601ba Mon Sep 17 00:00:00 2001 From: Pedro Figueiredo Date: Tue, 7 Jul 2026 13:40:08 +0100 Subject: [PATCH 3/5] fix(transaction-controller): fix prettier formatting in gas-fees.test.ts --- packages/transaction-controller/src/utils/gas-fees.test.ts | 4 +--- 1 file changed, 1 insertion(+), 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 9e6ab7b2a3a..90a7466660d 100644 --- a/packages/transaction-controller/src/utils/gas-fees.test.ts +++ b/packages/transaction-controller/src/utils/gas-fees.test.ts @@ -312,9 +312,7 @@ describe('gas-fees', () => { expect(updateGasFeeRequest.txMeta.txParams.maxFeePerGas).toBe( '0x1ca35f0e00', // 123 gwei ); - expect(updateGasFeeRequest.txMeta.userFeeLevel).toBe( - UserFeeLevel.CUSTOM, - ); + expect(updateGasFeeRequest.txMeta.userFeeLevel).toBe(UserFeeLevel.CUSTOM); }); it('uses saved gasPrice if saved gas fees include a legacy custom value', async () => { From 7435162b8a59c15cbd486a628632d8bbfad41b1d Mon Sep 17 00:00:00 2001 From: Pedro Figueiredo Date: Tue, 7 Jul 2026 14:14:15 +0100 Subject: [PATCH 4/5] fix(transaction-controller): only track saved estimate level in userFeeLevel when actually applied Cursor Bugbot flagged that a saved gas fee level (e.g. 'high') could be reflected in txMeta.userFeeLevel even when the resolved fee wasn't actually derived from that level - specifically when the gas fee flow returns a flat eth_gasPrice-type estimate, or when the flow throws and a raw eth_gasPrice RPC fallback is used. In both cases there are no per-level values to select from, so labelling userFeeLevel with the saved level was misleading. Track whether the suggested fee was actually derived from a per-level lookup and only preserve the saved level in userFeeLevel when it was; otherwise fall back to CUSTOM. --- .../src/utils/gas-fees.test.ts | 28 +++++++++++++ .../src/utils/gas-fees.ts | 40 ++++++++++++++----- 2 files changed, 57 insertions(+), 11 deletions(-) diff --git a/packages/transaction-controller/src/utils/gas-fees.test.ts b/packages/transaction-controller/src/utils/gas-fees.test.ts index 90a7466660d..0885e3dad40 100644 --- a/packages/transaction-controller/src/utils/gas-fees.test.ts +++ b/packages/transaction-controller/src/utils/gas-fees.test.ts @@ -315,6 +315,34 @@ describe('gas-fees', () => { expect(updateGasFeeRequest.txMeta.userFeeLevel).toBe(UserFeeLevel.CUSTOM); }); + it('sets userFeeLevel to custom if saved gas fees include a level but flow returns a flat gas price estimate', async () => { + updateGasFeeRequest.txMeta.type = TransactionType.simpleSend; + updateGasFeeRequest.getSavedGasFees.mockReturnValueOnce({ + level: GasFeeEstimateLevel.High, + }); + mockGasFeeFlowMockResponse(FLOW_RESPONSE_GAS_PRICE_MOCK); + + await updateGasFees(updateGasFeeRequest); + + expect(updateGasFeeRequest.txMeta.userFeeLevel).toBe(UserFeeLevel.CUSTOM); + }); + + it('sets userFeeLevel to custom if saved gas fees include a level but getGasFeeEstimates throws', async () => { + updateGasFeeRequest.txMeta.type = TransactionType.simpleSend; + updateGasFeeRequest.getSavedGasFees.mockReturnValueOnce({ + level: GasFeeEstimateLevel.High, + }); + updateGasFeeRequest.getGasFeeEstimates.mockReset(); + updateGasFeeRequest.getGasFeeEstimates.mockRejectedValueOnce( + new Error('TestError'), + ); + rpcRequestMock.mockResolvedValueOnce(GAS_HEX_MOCK); + + await updateGasFees(updateGasFeeRequest); + + expect(updateGasFeeRequest.txMeta.userFeeLevel).toBe(UserFeeLevel.CUSTOM); + }); + it('uses saved gasPrice if saved gas fees include a legacy custom value', async () => { updateGasFeeRequest.eip1559 = false; updateGasFeeRequest.txMeta.type = TransactionType.simpleSend; diff --git a/packages/transaction-controller/src/utils/gas-fees.ts b/packages/transaction-controller/src/utils/gas-fees.ts index 7fb4917cf8b..33f12cf9878 100644 --- a/packages/transaction-controller/src/utils/gas-fees.ts +++ b/packages/transaction-controller/src/utils/gas-fees.ts @@ -45,6 +45,14 @@ type SuggestedGasFees = { maxFeePerGas?: string; maxPriorityFeePerGas?: string; gasPrice?: string; + + /** + * Whether the suggested fee was derived from a specific estimate level, + * such as `low`/`medium`/`high`. This is `false` for estimate types that + * do not support per-level pricing, such as a flat `eth_gasPrice` value, + * or when the gas fee flow failed and a raw RPC fallback was used. + */ + isEstimateLevelApplied?: boolean; }; const log = createModuleLogger(projectLogger, 'gas-fees'); @@ -299,12 +307,17 @@ function getUserFeeLevel(request: GetGasFeeRequest): string | undefined { savedGasFees.priorityFee !== undefined || savedGasFees.gasPrice !== undefined; - // A custom override on any field means the fee is no longer purely - // level-derived, so it must not be tracked as a live estimate level by - // the gas fee poller, which would otherwise overwrite the override. - return hasCustomOverride - ? UserFeeLevel.CUSTOM - : (savedGasFees.level ?? UserFeeLevel.CUSTOM); + const canUseSavedLevel = + !hasCustomOverride && + savedGasFees.level !== undefined && + suggestedGasFees.isEstimateLevelApplied; + + // A custom override on any field, or an estimate type that does not + // support per-level pricing (e.g. a flat eth_gasPrice value), means the + // fee is no longer purely level-derived, so it must not be tracked as a + // live estimate level by the gas fee poller, which would otherwise + // overwrite the override or misrepresent the fee as matching the level. + return canUseSavedLevel ? savedGasFees.level : UserFeeLevel.CUSTOM; } if ( @@ -405,18 +418,23 @@ async function getSuggestedGasFees( switch (gasFeeEstimateType) { case GasFeeEstimateType.FeeMarket: - return ( - response.estimates[savedGasFeeEstimateLevel] ?? - response.estimates.medium - ); + return { + ...(response.estimates[savedGasFeeEstimateLevel] ?? + response.estimates.medium), + isEstimateLevelApplied: true, + }; case GasFeeEstimateType.Legacy: return { gasPrice: response.estimates[savedGasFeeEstimateLevel] ?? response.estimates.medium, + isEstimateLevelApplied: true, }; case GasFeeEstimateType.GasPrice: - return { gasPrice: response.estimates.gasPrice }; + return { + gasPrice: response.estimates.gasPrice, + isEstimateLevelApplied: false, + }; default: throw new Error( // TODO: Either fix this lint violation or explain why it's necessary to ignore. From a173b00763d8d82780e2b057a6d0c3f99bd66f7f Mon Sep 17 00:00:00 2001 From: Pedro Figueiredo Date: Fri, 10 Jul 2026 13:59:43 +0100 Subject: [PATCH 5/5] fix(transaction-controller): move unreleased changelog entry from released 68.3.0 section to Unreleased The rebase auto-merge placed our saved-gas-fee-levels changelog entry under the already-released [68.3.0] section instead of [Unreleased], since that's where the surrounding context lines used to live before 68.3.0 was cut. Move it back to [Unreleased]. --- packages/transaction-controller/CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 293bfadb29f..7d1b1c515e8 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **BREAKING:** Expand saved gas fee support to allow transaction-scoped lookup, saved gas fee estimate levels, and legacy gas price values. Consumers that provide `getSavedGasFees` must now accept `TransactionMeta` instead of a chain ID. ([#8993](https://github.com/MetaMask/core/pull/8993)) + ## [68.4.0] ### Added @@ -23,7 +27,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **BREAKING:** Expand saved gas fee support to allow transaction-scoped lookup, saved gas fee estimate levels, and legacy gas price values. Consumers that provide `getSavedGasFees` must now accept `TransactionMeta` instead of a chain ID. ([#8993](https://github.com/MetaMask/core/pull/8993)) - Bump `@metamask/messenger` from `^1.2.0` to `^2.0.0` ([#9392](https://github.com/MetaMask/core/pull/9392)) ### Fixed