From ba63c2b41bc5e76602f7237b66f136676a8a2eb2 Mon Sep 17 00:00:00 2001 From: Pedro Figueiredo Date: Wed, 3 Jun 2026 17:39:07 +0100 Subject: [PATCH] feat(transaction-controller): support saved gas fee levels --- packages/transaction-controller/CHANGELOG.md | 1 + .../src/TransactionController.ts | 11 ++- packages/transaction-controller/src/types.ts | 8 +- .../src/utils/gas-fees.test.ts | 93 ++++++++++++++++++- .../src/utils/gas-fees.ts | 82 ++++++++++++---- 5 files changed, 172 insertions(+), 23 deletions(-) diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 54e87d9566..9fd0fa5025 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/accounts-controller` from `^38.1.1` to `^38.1.2` ([#8912](https://github.com/MetaMask/core/pull/8912)) - Bump `@metamask/core-backend` from `^6.3.0` to `^6.3.1` ([#8912](https://github.com/MetaMask/core/pull/8912)) - Bump `@metamask/remote-feature-flag-controller` from `^4.2.1` to `^4.2.2` ([#8986](https://github.com/MetaMask/core/pull/8986)) +- **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)) ### Fixed diff --git a/packages/transaction-controller/src/TransactionController.ts b/packages/transaction-controller/src/TransactionController.ts index d75a550ae7..8654ef523b 100644 --- a/packages/transaction-controller/src/TransactionController.ts +++ b/packages/transaction-controller/src/TransactionController.ts @@ -366,7 +366,9 @@ 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. @@ -826,7 +828,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; @@ -963,7 +967,8 @@ export class TransactionController extends BaseController< this.#getNetworkState = getNetworkState; this.#getPermittedAccounts = getPermittedAccounts; this.#getSavedGasFees = - getSavedGasFees ?? ((_chainId): SavedGasFees | undefined => undefined); + getSavedGasFees ?? + ((_transactionMeta): SavedGasFees | undefined => undefined); this.#getSimulationConfig = getSimulationConfig ?? ((): ReturnType => Promise.resolve({})); diff --git a/packages/transaction-controller/src/types.ts b/packages/transaction-controller/src/types.ts index 6694b67dba..238d4c7584 100644 --- a/packages/transaction-controller/src/types.ts +++ b/packages/transaction-controller/src/types.ts @@ -1258,13 +1258,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 b72854dc08..39fc2093fa 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; @@ -183,6 +202,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 3598fac809..0115979997 100644 --- a/packages/transaction-controller/src/utils/gas-fees.ts +++ b/packages/transaction-controller/src/utils/gas-fees.ts @@ -15,7 +15,11 @@ import type { TransactionType, GasFeeFlow, } from '../types'; -import { GasFeeEstimateType, UserFeeLevel } from '../types'; +import { + GasFeeEstimateLevel, + GasFeeEstimateType, + UserFeeLevel, +} from '../types'; import { getGasFeeFlow } from './gas-flow'; import { rpcRequest } from './provider'; import { SWAP_TRANSACTION_TYPES } from './swaps'; @@ -26,7 +30,9 @@ export type UpdateGasFeesRequest = { getGasFeeEstimates: ( options: FetchGasFeeEstimateOptions, ) => Promise; - getSavedGasFees: (chainId: Hex) => SavedGasFees | undefined; + getSavedGasFees: ( + transactionMeta: TransactionMeta, + ) => SavedGasFees | undefined; messenger: TransactionControllerMessenger; txMeta: TransactionMeta; }; @@ -59,11 +65,15 @@ export async function updateGasFees( const isSwap = SWAP_TRANSACTION_TYPES.includes( txMeta.type as TransactionType, ); - const savedGasFees = isSwap - ? undefined - : request.getSavedGasFees(txMeta.chainId); + const savedGasFees = + isSwap || hasInitialGasFeeParams(initialParams) + ? undefined + : request.getSavedGasFees(txMeta); - const suggestedGasFees = await getSuggestedGasFees(request); + const suggestedGasFees = await getSuggestedGasFees({ + ...request, + savedGasFees, + }); log('Suggested gas fees', suggestedGasFees); @@ -140,7 +150,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; @@ -192,7 +202,7 @@ function getMaxPriorityFeePerGas( return undefined; } - if (savedGasFees) { + if (savedGasFees?.priorityFee) { const maxPriorityFeePerGas = gweiDecimalToWeiHex(savedGasFees.priorityFee); log( 'Using maxPriorityFeePerGas from savedGasFees.priorityFee', @@ -244,12 +254,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; @@ -275,11 +291,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 ( @@ -339,10 +355,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 ( @@ -370,13 +392,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 }; @@ -401,3 +429,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, + ); +}