From 69d8cef5815369c2bc3d68664a539270795a66d1 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 | 8 +- .../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, 239 insertions(+), 26 deletions(-) diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index e08cc10730..e8c410e175 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.0.0] ### Changed @@ -20,8 +24,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 9b55f8c3a9..170e06c9e3 100644 --- a/packages/transaction-controller/src/TransactionController.test.ts +++ b/packages/transaction-controller/src/TransactionController.test.ts @@ -8037,6 +8037,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 646399c373..ce576719d8 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; /** @@ -699,7 +726,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; @@ -793,7 +822,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({})); @@ -927,6 +957,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 c4996acf19..7fa97cdcc3 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 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, + ); +}