diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 394e2df4d4f..0d8ca7cae26 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Adds `RandomisedEstimationsGasFeeFlow` to gas fee flows in `TransactionController` ([#5511](https://github.com/MetaMask/core/pull/5511)) + - Added flow only will be activated if chainId is defined in feature flags. + ### Fixed - Fix simulation of type-4 transactions ([#5552](https://github.com/MetaMask/core/pull/5552)) diff --git a/packages/transaction-controller/src/TransactionController.test.ts b/packages/transaction-controller/src/TransactionController.test.ts index 7a108a3d605..a6e386ef774 100644 --- a/packages/transaction-controller/src/TransactionController.test.ts +++ b/packages/transaction-controller/src/TransactionController.test.ts @@ -39,6 +39,7 @@ import { getAccountAddressRelationship } from './api/accounts-api'; import { CHAIN_IDS } from './constants'; import { DefaultGasFeeFlow } from './gas-flows/DefaultGasFeeFlow'; import { LineaGasFeeFlow } from './gas-flows/LineaGasFeeFlow'; +import { RandomisedEstimationsGasFeeFlow } from './gas-flows/RandomisedEstimationsGasFeeFlow'; import { TestGasFeeFlow } from './gas-flows/TestGasFeeFlow'; import { GasFeePoller } from './helpers/GasFeePoller'; import { IncomingTransactionHelper } from './helpers/IncomingTransactionHelper'; @@ -115,6 +116,7 @@ const ORIGIN_MOCK = 'test.com'; jest.mock('@metamask/eth-query'); jest.mock('./api/accounts-api'); jest.mock('./gas-flows/DefaultGasFeeFlow'); +jest.mock('./gas-flows/RandomisedEstimationsGasFeeFlow'); jest.mock('./gas-flows/LineaGasFeeFlow'); jest.mock('./gas-flows/TestGasFeeFlow'); jest.mock('./helpers/GasFeePoller'); @@ -514,6 +516,9 @@ describe('TransactionController', () => { ); const defaultGasFeeFlowClassMock = jest.mocked(DefaultGasFeeFlow); const lineaGasFeeFlowClassMock = jest.mocked(LineaGasFeeFlow); + const randomisedEstimationsGasFeeFlowClassMock = jest.mocked( + RandomisedEstimationsGasFeeFlow, + ); const testGasFeeFlowClassMock = jest.mocked(TestGasFeeFlow); const gasFeePollerClassMock = jest.mocked(GasFeePoller); const getSimulationDataMock = jest.mocked(getSimulationData); @@ -536,6 +541,7 @@ describe('TransactionController', () => { let multichainTrackingHelperMock: jest.Mocked; let defaultGasFeeFlowMock: jest.Mocked; let lineaGasFeeFlowMock: jest.Mocked; + let randomisedEstimationsGasFeeFlowMock: jest.Mocked; let testGasFeeFlowMock: jest.Mocked; let gasFeePollerMock: jest.Mocked; let methodDataHelperMock: jest.Mocked; @@ -919,6 +925,13 @@ describe('TransactionController', () => { return lineaGasFeeFlowMock; }); + randomisedEstimationsGasFeeFlowClassMock.mockImplementation(() => { + randomisedEstimationsGasFeeFlowMock = { + matchesTransaction: () => false, + } as unknown as jest.Mocked; + return randomisedEstimationsGasFeeFlowMock; + }); + testGasFeeFlowClassMock.mockImplementation(() => { testGasFeeFlowMock = { matchesTransaction: () => false, @@ -971,7 +984,11 @@ describe('TransactionController', () => { expect(gasFeePollerClassMock).toHaveBeenCalledTimes(1); expect(gasFeePollerClassMock).toHaveBeenCalledWith( expect.objectContaining({ - gasFeeFlows: [lineaGasFeeFlowMock, defaultGasFeeFlowMock], + gasFeeFlows: [ + randomisedEstimationsGasFeeFlowMock, + lineaGasFeeFlowMock, + defaultGasFeeFlowMock, + ], }), ); }); @@ -2018,9 +2035,14 @@ describe('TransactionController', () => { expect(updateGasFeesMock).toHaveBeenCalledWith({ eip1559: true, ethQuery: expect.any(Object), - gasFeeFlows: [lineaGasFeeFlowMock, defaultGasFeeFlowMock], + gasFeeFlows: [ + randomisedEstimationsGasFeeFlowMock, + lineaGasFeeFlowMock, + defaultGasFeeFlowMock, + ], getGasFeeEstimates: expect.any(Function), getSavedGasFees: expect.any(Function), + messenger: expect.any(Object), txMeta: expect.any(Object), }); }); diff --git a/packages/transaction-controller/src/TransactionController.ts b/packages/transaction-controller/src/TransactionController.ts index 68aa626983e..a513ee5c327 100644 --- a/packages/transaction-controller/src/TransactionController.ts +++ b/packages/transaction-controller/src/TransactionController.ts @@ -60,6 +60,7 @@ import { import { DefaultGasFeeFlow } from './gas-flows/DefaultGasFeeFlow'; import { LineaGasFeeFlow } from './gas-flows/LineaGasFeeFlow'; import { OptimismLayer1GasFeeFlow } from './gas-flows/OptimismLayer1GasFeeFlow'; +import { RandomisedEstimationsGasFeeFlow } from './gas-flows/RandomisedEstimationsGasFeeFlow'; import { ScrollLayer1GasFeeFlow } from './gas-flows/ScrollLayer1GasFeeFlow'; import { TestGasFeeFlow } from './gas-flows/TestGasFeeFlow'; import { AccountsApiRemoteTransactionSource } from './helpers/AccountsApiRemoteTransactionSource'; @@ -912,6 +913,7 @@ export class TransactionController extends BaseController< getProvider: (networkClientId) => this.#getProvider({ networkClientId }), getTransactions: () => this.state.transactions, layer1GasFeeFlows: this.layer1GasFeeFlows, + messenger: this.messagingSystem, onStateChange: (listener) => { this.messagingSystem.subscribe( 'TransactionController:stateChange', @@ -1984,6 +1986,7 @@ export class TransactionController extends BaseController< await updateTransactionLayer1GasFee({ layer1GasFeeFlows: this.layer1GasFeeFlows, + messenger: this.messagingSystem, provider, transactionMeta: updatedTransaction, }); @@ -2293,6 +2296,7 @@ export class TransactionController extends BaseController< const gasFeeFlow = getGasFeeFlow( transactionMeta, this.gasFeeFlows, + this.messagingSystem, ) as GasFeeFlow; const ethQuery = new EthQuery(provider); @@ -2304,6 +2308,7 @@ export class TransactionController extends BaseController< return gasFeeFlow.getGasFees({ ethQuery, gasFeeControllerData, + messenger: this.messagingSystem, transactionMeta, }); } @@ -2333,6 +2338,7 @@ export class TransactionController extends BaseController< return await getTransactionLayer1GasFee({ layer1GasFeeFlows: this.layer1GasFeeFlows, + messenger: this.messagingSystem, provider, transactionMeta: { txParams: transactionParams, @@ -2581,6 +2587,7 @@ export class TransactionController extends BaseController< gasFeeFlows: this.gasFeeFlows, getGasFeeEstimates: this.getGasFeeEstimates, getSavedGasFees: this.getSavedGasFees.bind(this), + messenger: this.messagingSystem, txMeta: transactionMeta, }), ); @@ -2590,6 +2597,7 @@ export class TransactionController extends BaseController< async () => await updateTransactionLayer1GasFee({ layer1GasFeeFlows: this.layer1GasFeeFlows, + messenger: this.messagingSystem, provider, transactionMeta, }), @@ -3749,7 +3757,11 @@ export class TransactionController extends BaseController< return [new TestGasFeeFlow()]; } - return [new LineaGasFeeFlow(), new DefaultGasFeeFlow()]; + return [ + new RandomisedEstimationsGasFeeFlow(), + new LineaGasFeeFlow(), + new DefaultGasFeeFlow(), + ]; } #getLayer1GasFeeFlows(): Layer1GasFeeFlow[] { diff --git a/packages/transaction-controller/src/gas-flows/DefaultGasFeeFlow.test.ts b/packages/transaction-controller/src/gas-flows/DefaultGasFeeFlow.test.ts index 1965cbb3a57..c9c9907c1fa 100644 --- a/packages/transaction-controller/src/gas-flows/DefaultGasFeeFlow.test.ts +++ b/packages/transaction-controller/src/gas-flows/DefaultGasFeeFlow.test.ts @@ -8,6 +8,7 @@ import type { import { GAS_ESTIMATE_TYPES } from '@metamask/gas-fee-controller'; import { DefaultGasFeeFlow } from './DefaultGasFeeFlow'; +import type { TransactionControllerMessenger } from '../TransactionController'; import type { FeeMarketGasFeeEstimates, GasPriceGasFeeEstimates, @@ -99,9 +100,7 @@ describe('DefaultGasFeeFlow', () => { describe('matchesTransaction', () => { it('returns true', () => { const defaultGasFeeFlow = new DefaultGasFeeFlow(); - const result = defaultGasFeeFlow.matchesTransaction( - TRANSACTION_META_MOCK, - ); + const result = defaultGasFeeFlow.matchesTransaction(); expect(result).toBe(true); }); }); @@ -113,6 +112,7 @@ describe('DefaultGasFeeFlow', () => { const response = await defaultGasFeeFlow.getGasFees({ ethQuery: ETH_QUERY_MOCK, gasFeeControllerData: FEE_MARKET_RESPONSE_MOCK, + messenger: {} as TransactionControllerMessenger, transactionMeta: TRANSACTION_META_MOCK, }); @@ -127,6 +127,7 @@ describe('DefaultGasFeeFlow', () => { const response = await defaultGasFeeFlow.getGasFees({ ethQuery: ETH_QUERY_MOCK, gasFeeControllerData: LEGACY_RESPONSE_MOCK, + messenger: {} as TransactionControllerMessenger, transactionMeta: TRANSACTION_META_MOCK, }); @@ -141,6 +142,7 @@ describe('DefaultGasFeeFlow', () => { const response = await defaultGasFeeFlow.getGasFees({ ethQuery: ETH_QUERY_MOCK, gasFeeControllerData: GAS_PRICE_RESPONSE_MOCK, + messenger: {} as TransactionControllerMessenger, transactionMeta: TRANSACTION_META_MOCK, }); @@ -157,6 +159,7 @@ describe('DefaultGasFeeFlow', () => { gasFeeControllerData: { gasEstimateType: GAS_ESTIMATE_TYPES.NONE, } as GasFeeState, + messenger: {} as TransactionControllerMessenger, transactionMeta: TRANSACTION_META_MOCK, }); diff --git a/packages/transaction-controller/src/gas-flows/DefaultGasFeeFlow.ts b/packages/transaction-controller/src/gas-flows/DefaultGasFeeFlow.ts index b708145535a..84ae34102c4 100644 --- a/packages/transaction-controller/src/gas-flows/DefaultGasFeeFlow.ts +++ b/packages/transaction-controller/src/gas-flows/DefaultGasFeeFlow.ts @@ -17,7 +17,6 @@ import type { GasFeeFlowResponse, GasPriceGasFeeEstimates, LegacyGasFeeEstimates, - TransactionMeta, } from '../types'; import { GasFeeEstimateLevel, GasFeeEstimateType } from '../types'; import { gweiDecimalToWeiHex } from '../utils/gas-fees'; @@ -28,7 +27,7 @@ const log = createModuleLogger(projectLogger, 'default-gas-fee-flow'); * The standard implementation of a gas fee flow that obtains gas fee estimates using only the GasFeeController. */ export class DefaultGasFeeFlow implements GasFeeFlow { - matchesTransaction(_transactionMeta: TransactionMeta): boolean { + matchesTransaction(): boolean { return true; } @@ -56,8 +55,6 @@ export class DefaultGasFeeFlow implements GasFeeFlow { ); break; default: - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions throw new Error(`Unsupported gas estimate type: ${gasEstimateType}`); } diff --git a/packages/transaction-controller/src/gas-flows/LineaGasFeeFlow.test.ts b/packages/transaction-controller/src/gas-flows/LineaGasFeeFlow.test.ts index 264595d0442..9f37a23d35e 100644 --- a/packages/transaction-controller/src/gas-flows/LineaGasFeeFlow.test.ts +++ b/packages/transaction-controller/src/gas-flows/LineaGasFeeFlow.test.ts @@ -4,6 +4,7 @@ import type EthQuery from '@metamask/eth-query'; import { DefaultGasFeeFlow } from './DefaultGasFeeFlow'; import { LineaGasFeeFlow } from './LineaGasFeeFlow'; import { CHAIN_IDS } from '../constants'; +import type { TransactionControllerMessenger } from '../TransactionController'; import type { FeeMarketGasFeeEstimates, GasFeeFlowRequest, @@ -82,7 +83,12 @@ describe('LineaGasFeeFlow', () => { chainId, }; - expect(flow.matchesTransaction(transaction)).toBe(true); + expect( + flow.matchesTransaction({ + transactionMeta: transaction, + messenger: {} as TransactionControllerMessenger, + }), + ).toBe(true); }); }); diff --git a/packages/transaction-controller/src/gas-flows/LineaGasFeeFlow.ts b/packages/transaction-controller/src/gas-flows/LineaGasFeeFlow.ts index fb45f701d3b..bd9208d5699 100644 --- a/packages/transaction-controller/src/gas-flows/LineaGasFeeFlow.ts +++ b/packages/transaction-controller/src/gas-flows/LineaGasFeeFlow.ts @@ -5,6 +5,7 @@ import type BN from 'bn.js'; import { DefaultGasFeeFlow } from './DefaultGasFeeFlow'; import { projectLogger } from '../logger'; +import type { TransactionControllerMessenger } from '../TransactionController'; import type { GasFeeEstimates, GasFeeFlow, @@ -49,7 +50,12 @@ const PRIORITY_FEE_MULTIPLIERS = { * - Static multipliers to increase the base and priority fees. */ export class LineaGasFeeFlow implements GasFeeFlow { - matchesTransaction(transactionMeta: TransactionMeta): boolean { + matchesTransaction({ + transactionMeta, + }: { + transactionMeta: TransactionMeta; + messenger: TransactionControllerMessenger; + }): boolean { return LINEA_CHAIN_IDS.includes(transactionMeta.chainId); } diff --git a/packages/transaction-controller/src/gas-flows/OptimismLayer1GasFeeFlow.test.ts b/packages/transaction-controller/src/gas-flows/OptimismLayer1GasFeeFlow.test.ts index 9fdf36dddee..d2722953e7e 100644 --- a/packages/transaction-controller/src/gas-flows/OptimismLayer1GasFeeFlow.test.ts +++ b/packages/transaction-controller/src/gas-flows/OptimismLayer1GasFeeFlow.test.ts @@ -1,5 +1,6 @@ import { OptimismLayer1GasFeeFlow } from './OptimismLayer1GasFeeFlow'; import { CHAIN_IDS } from '../constants'; +import type { TransactionControllerMessenger } from '../TransactionController'; import type { TransactionMeta } from '../types'; import { TransactionStatus } from '../types'; @@ -28,7 +29,12 @@ describe('OptimismLayer1GasFeeFlow', () => { chainId, }; - expect(flow.matchesTransaction(transaction)).toBe(true); + expect( + flow.matchesTransaction({ + transactionMeta: transaction, + messenger: {} as TransactionControllerMessenger, + }), + ).toBe(true); }); }); }); diff --git a/packages/transaction-controller/src/gas-flows/OptimismLayer1GasFeeFlow.ts b/packages/transaction-controller/src/gas-flows/OptimismLayer1GasFeeFlow.ts index 4b4a9d1521e..27186d5e427 100644 --- a/packages/transaction-controller/src/gas-flows/OptimismLayer1GasFeeFlow.ts +++ b/packages/transaction-controller/src/gas-flows/OptimismLayer1GasFeeFlow.ts @@ -2,6 +2,7 @@ import { type Hex } from '@metamask/utils'; import { OracleLayer1GasFeeFlow } from './OracleLayer1GasFeeFlow'; import { CHAIN_IDS } from '../constants'; +import type { TransactionControllerMessenger } from '../TransactionController'; import type { TransactionMeta } from '../types'; const OPTIMISM_STACK_CHAIN_IDS: Hex[] = [ @@ -26,7 +27,12 @@ export class OptimismLayer1GasFeeFlow extends OracleLayer1GasFeeFlow { super(OPTIMISM_GAS_PRICE_ORACLE_ADDRESS); } - matchesTransaction(transactionMeta: TransactionMeta): boolean { + matchesTransaction({ + transactionMeta, + }: { + transactionMeta: TransactionMeta; + messenger: TransactionControllerMessenger; + }): boolean { return OPTIMISM_STACK_CHAIN_IDS.includes(transactionMeta.chainId); } } diff --git a/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.test.ts b/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.test.ts index bbe153a651f..ba1ab2dc888 100644 --- a/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.test.ts +++ b/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.test.ts @@ -54,7 +54,7 @@ function createMockTypedTransaction(serializedBuffer: Buffer) { } class MockOracleLayer1GasFeeFlow extends OracleLayer1GasFeeFlow { - matchesTransaction(_transactionMeta: TransactionMeta): boolean { + matchesTransaction(): boolean { return true; } } diff --git a/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.ts b/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.ts index 8a3394ea2c2..918575dbad3 100644 --- a/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.ts +++ b/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.ts @@ -4,6 +4,7 @@ import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; import { projectLogger } from '../logger'; +import type { TransactionControllerMessenger } from '../TransactionController'; import type { Layer1GasFeeFlow, Layer1GasFeeFlowRequest, @@ -40,7 +41,13 @@ export abstract class OracleLayer1GasFeeFlow implements Layer1GasFeeFlow { this.#signTransaction = signTransaction ?? false; } - abstract matchesTransaction(transactionMeta: TransactionMeta): boolean; + abstract matchesTransaction({ + transactionMeta, + messenger, + }: { + transactionMeta: TransactionMeta; + messenger: TransactionControllerMessenger; + }): boolean; async getLayer1Fee( request: Layer1GasFeeFlowRequest, diff --git a/packages/transaction-controller/src/gas-flows/RandomisedEstimationsGasFeeFlow.test.ts b/packages/transaction-controller/src/gas-flows/RandomisedEstimationsGasFeeFlow.test.ts new file mode 100644 index 00000000000..53bfdbe38e6 --- /dev/null +++ b/packages/transaction-controller/src/gas-flows/RandomisedEstimationsGasFeeFlow.test.ts @@ -0,0 +1,455 @@ +import { toHex } from '@metamask/controller-utils'; +import type EthQuery from '@metamask/eth-query'; +import { GAS_ESTIMATE_TYPES } from '@metamask/gas-fee-controller'; +import type { GasFeeState } from '@metamask/gas-fee-controller'; + +import { DefaultGasFeeFlow } from './DefaultGasFeeFlow'; +import { + RandomisedEstimationsGasFeeFlow, + randomiseDecimalGWEIAndConvertToHex, +} from './RandomisedEstimationsGasFeeFlow'; +import type { TransactionControllerMessenger } from '../TransactionController'; +import type { + FeeMarketGasFeeEstimates, + GasPriceGasFeeEstimates, + LegacyGasFeeEstimates, + TransactionMeta, +} from '../types'; +import { + GasFeeEstimateLevel, + GasFeeEstimateType, + TransactionStatus, +} from '../types'; +import { getGasFeeRandomisation } from '../utils/feature-flags'; + +jest.mock('./DefaultGasFeeFlow'); +jest.mock('../utils/feature-flags'); + +// Mock Math.random to return predictable values +const originalRandom = global.Math.random; +jest.spyOn(global.Math, 'random').mockReturnValue(0.5); + +const TRANSACTION_META_MOCK: TransactionMeta = { + id: '1', + chainId: '0x1', + networkClientId: 'testNetworkClientId', + status: TransactionStatus.unapproved, + time: 0, + txParams: { + from: '0x123', + }, +}; + +const ETH_QUERY_MOCK = {} as EthQuery; + +const DEFAULT_FEE_MARKET_RESPONSE: FeeMarketGasFeeEstimates = { + type: GasFeeEstimateType.FeeMarket, + low: { + maxFeePerGas: toHex(1e9), + maxPriorityFeePerGas: toHex(2e9), + }, + medium: { + maxFeePerGas: toHex(3e9), + maxPriorityFeePerGas: toHex(4e9), + }, + high: { + maxFeePerGas: toHex(5e9), + maxPriorityFeePerGas: toHex(6e9), + }, +}; + +const DEFAULT_LEGACY_RESPONSE: LegacyGasFeeEstimates = { + type: GasFeeEstimateType.Legacy, + low: toHex(1e9), + medium: toHex(3e9), + high: toHex(5e9), +}; + +const DEFAULT_GAS_PRICE_RESPONSE: GasPriceGasFeeEstimates = { + type: GasFeeEstimateType.GasPrice, + gasPrice: toHex(3e9), +}; + +describe('RandomisedEstimationsGasFeeFlow', () => { + const getGasFeeRandomisationMock = jest.mocked(getGasFeeRandomisation); + + beforeEach(() => { + jest.resetAllMocks(); + jest + .mocked(DefaultGasFeeFlow.prototype.getGasFees) + .mockImplementation(async (request) => { + const { gasFeeControllerData } = request; + if ( + gasFeeControllerData.gasEstimateType === GAS_ESTIMATE_TYPES.FEE_MARKET + ) { + return { estimates: DEFAULT_FEE_MARKET_RESPONSE }; + } else if ( + gasFeeControllerData.gasEstimateType === GAS_ESTIMATE_TYPES.LEGACY + ) { + return { estimates: DEFAULT_LEGACY_RESPONSE }; + } + return { estimates: DEFAULT_GAS_PRICE_RESPONSE }; + }); + + getGasFeeRandomisationMock.mockReturnValue({ + randomisedGasFeeDigits: { + '0x1': 6, + }, + preservedNumberOfDigits: 2, + }); + }); + + afterEach(() => { + global.Math.random = originalRandom; + }); + + describe('matchesTransaction', () => { + it('returns true if chainId exists in the feature flag config', () => { + const flow = new RandomisedEstimationsGasFeeFlow(); + + const transaction = { + ...TRANSACTION_META_MOCK, + chainId: '0x1', + } as TransactionMeta; + + expect( + flow.matchesTransaction({ + transactionMeta: transaction, + messenger: {} as TransactionControllerMessenger, + }), + ).toBe(true); + }); + + it('returns false if chainId is not in the randomisation config', () => { + getGasFeeRandomisationMock.mockReturnValue({ + randomisedGasFeeDigits: {}, + preservedNumberOfDigits: undefined, + }); + const flow = new RandomisedEstimationsGasFeeFlow(); + + const transaction = { + ...TRANSACTION_META_MOCK, + chainId: '0x89', // Not in config + } as TransactionMeta; + + expect( + flow.matchesTransaction({ + transactionMeta: transaction, + messenger: {} as TransactionControllerMessenger, + }), + ).toBe(false); + }); + }); + + describe('getGasFees', () => { + it.each(Object.values(GasFeeEstimateLevel))( + 'randomises only priority fee for fee market estimates for %s level', + async (level) => { + const flow = new RandomisedEstimationsGasFeeFlow(); + + const request = { + ethQuery: ETH_QUERY_MOCK, + transactionMeta: TRANSACTION_META_MOCK, + gasFeeControllerData: { + gasEstimateType: GAS_ESTIMATE_TYPES.FEE_MARKET, + gasFeeEstimates: { + low: { + suggestedMaxFeePerGas: '100000', + suggestedMaxPriorityFeePerGas: '100000', + }, + medium: { + suggestedMaxFeePerGas: '200000', + suggestedMaxPriorityFeePerGas: '200000', + }, + high: { + suggestedMaxFeePerGas: '300000', + suggestedMaxPriorityFeePerGas: '300000', + }, + }, + estimatedGasFeeTimeBounds: {}, + } as GasFeeState, + messenger: {} as TransactionControllerMessenger, + }; + + const result = await flow.getGasFees(request); + + expect(result.estimates.type).toBe(GasFeeEstimateType.FeeMarket); + + const estimates = request.gasFeeControllerData + .gasFeeEstimates as Record< + GasFeeEstimateLevel, + { + suggestedMaxFeePerGas: string; + suggestedMaxPriorityFeePerGas: string; + } + >; + + const maxFeeHex = (result.estimates as FeeMarketGasFeeEstimates)[level] + .maxFeePerGas; + + // Verify that the maxFeePerGas is not randomised + const originalValue = Number(estimates[level].suggestedMaxFeePerGas); + const actualValue = parseInt(maxFeeHex.slice(2), 16) / 1e9; + expect(actualValue).toBe(originalValue); + + const maxPriorityFeeHex = ( + result.estimates as FeeMarketGasFeeEstimates + )[level].maxPriorityFeePerGas; + const originalPriorityValue = Number( + estimates[level].suggestedMaxPriorityFeePerGas, + ); + const actualPriorityValue = + parseInt(maxPriorityFeeHex.slice(2), 16) / 1e9; + + expect(actualPriorityValue).not.toBe(originalPriorityValue); + expect(actualPriorityValue).toBeGreaterThanOrEqual( + originalPriorityValue, + ); + expect(actualPriorityValue).toBeLessThanOrEqual( + originalPriorityValue + 999999, + ); + }, + ); + + it.each(Object.values(GasFeeEstimateLevel))( + 'does return default legacy estimates for %s level', + async (level) => { + const defaultLegacyEstimates = { + type: GasFeeEstimateType.Legacy, + [GasFeeEstimateLevel.Low]: toHex(1e9), + [GasFeeEstimateLevel.Medium]: toHex(3e9), + [GasFeeEstimateLevel.High]: toHex(5e9), + } as LegacyGasFeeEstimates; + + jest + .mocked(DefaultGasFeeFlow.prototype.getGasFees) + .mockImplementationOnce(async () => { + return { + estimates: defaultLegacyEstimates, + }; + }); + + const flow = new RandomisedEstimationsGasFeeFlow(); + + const request = { + ethQuery: ETH_QUERY_MOCK, + transactionMeta: TRANSACTION_META_MOCK, + gasFeeControllerData: { + gasEstimateType: GAS_ESTIMATE_TYPES.LEGACY, + } as GasFeeState, + messenger: {} as TransactionControllerMessenger, + }; + + const result = await flow.getGasFees(request); + + expect(result.estimates.type).toBe(GasFeeEstimateType.Legacy); + expect((result.estimates as LegacyGasFeeEstimates)[level]).toBe( + defaultLegacyEstimates[level], + ); + }, + ); + + it('does return default eth_gasPrice estimates', async () => { + const defaultGasPriceEstimates = { + type: GasFeeEstimateType.GasPrice, + gasPrice: toHex(200000), + } as GasPriceGasFeeEstimates; + + jest + .mocked(DefaultGasFeeFlow.prototype.getGasFees) + .mockImplementationOnce(async () => { + return { + estimates: defaultGasPriceEstimates, + }; + }); + + const flow = new RandomisedEstimationsGasFeeFlow(); + + const request = { + ethQuery: ETH_QUERY_MOCK, + transactionMeta: TRANSACTION_META_MOCK, + gasFeeControllerData: { + gasEstimateType: GAS_ESTIMATE_TYPES.ETH_GASPRICE, + } as GasFeeState, + messenger: {} as TransactionControllerMessenger, + }; + + const result = await flow.getGasFees(request); + + expect(result.estimates.type).toBe(GasFeeEstimateType.GasPrice); + expect((result.estimates as GasPriceGasFeeEstimates).gasPrice).toBe( + defaultGasPriceEstimates.gasPrice, + ); + }); + + it('fall backs to default flow if randomization fails', async () => { + const flow = new RandomisedEstimationsGasFeeFlow(); + + // Mock Math.random to throw an error + jest.spyOn(global.Math, 'random').mockImplementation(() => { + throw new Error('Random error'); + }); + + const request = { + ethQuery: ETH_QUERY_MOCK, + transactionMeta: TRANSACTION_META_MOCK, + gasFeeControllerData: { + gasEstimateType: GAS_ESTIMATE_TYPES.FEE_MARKET, + gasFeeEstimates: { + low: { + suggestedMaxFeePerGas: '10', + suggestedMaxPriorityFeePerGas: '1', + }, + medium: { + suggestedMaxFeePerGas: '20', + suggestedMaxPriorityFeePerGas: '2', + }, + high: { + suggestedMaxFeePerGas: '30', + suggestedMaxPriorityFeePerGas: '3', + }, + }, + estimatedGasFeeTimeBounds: {}, + } as GasFeeState, + messenger: {} as TransactionControllerMessenger, + }; + + const result = await flow.getGasFees(request); + + // Verify that DefaultGasFeeFlow was called + expect(DefaultGasFeeFlow.prototype.getGasFees).toHaveBeenCalledWith( + request, + ); + expect(result.estimates).toStrictEqual(DEFAULT_FEE_MARKET_RESPONSE); + }); + + it('throws an error for unsupported gas estimate types', async () => { + const flow = new RandomisedEstimationsGasFeeFlow(); + + const request = { + ethQuery: ETH_QUERY_MOCK, + transactionMeta: TRANSACTION_META_MOCK, + gasFeeControllerData: { + gasEstimateType: 'UNSUPPORTED_TYPE', + gasFeeEstimates: {}, + } as unknown as GasFeeState, + messenger: {} as TransactionControllerMessenger, + }; + + // Capture the error in a spy so we can verify default flow was called + const spy = jest.spyOn(console, 'error').mockImplementation(); + + const result = await flow.getGasFees(request); + + expect(DefaultGasFeeFlow.prototype.getGasFees).toHaveBeenCalledWith( + request, + ); + expect(result.estimates).toStrictEqual(DEFAULT_GAS_PRICE_RESPONSE); + spy.mockRestore(); + }); + }); +}); + +describe('randomiseDecimalGWEIAndConvertToHex', () => { + beforeEach(() => { + jest.spyOn(global.Math, 'random').mockReturnValue(0.5); + }); + + afterEach(() => { + jest.spyOn(global.Math, 'random').mockRestore(); + }); + + it('randomizes the last digits while preserving the significant digits', () => { + const result = randomiseDecimalGWEIAndConvertToHex('5', 3, 2); + + const resultWei = parseInt(result.slice(2), 16); + const resultGwei = resultWei / 1e9; + + // With Math.random = 0.5, we expect the last 3 digits to be around 500 + // The expected value should be 5.0000005 (not 5.0005) + expect(resultGwei).toBeCloseTo(5.0000005, 6); + + // The base part should be exactly 5.000 Gwei + const basePart = (Math.floor(resultWei / 1000) * 1000) / 1e9; + expect(basePart).toBe(5); + }); + + it('ensures randomized value is never below original value', () => { + // Test with Math.random = 0 (lowest possible random value) + jest.spyOn(global.Math, 'random').mockReturnValue(0); + + // Test with a value that has non-zero ending digits + const result = randomiseDecimalGWEIAndConvertToHex('5.000500123', 3, 2); + const resultWei = parseInt(result.slice(2), 16); + + // Original value in Wei + const originalWei = 5000500123; + + // With Math.random = 0, result should exactly equal original value + expect(resultWei).toBe(originalWei); + }); + + it('randomizes up to but not exceeding the specified number of digits', () => { + // Set Math.random to return almost 1 + jest.spyOn(global.Math, 'random').mockReturnValue(0.999); + + const result = randomiseDecimalGWEIAndConvertToHex('5', 3, 2); + const resultWei = parseInt(result.slice(2), 16); + + const baseWei = 5 * 1e9; + + // With 3 digits and Math.random almost 1, we expect the last 3 digits to be close to 999 + expect(resultWei).toBeGreaterThanOrEqual(baseWei); + expect(resultWei).toBeLessThanOrEqual(baseWei + 999); + expect(resultWei).toBeCloseTo(baseWei + 999, -1); + }); + + it('handles values with more digits than requested to randomize', () => { + const result = randomiseDecimalGWEIAndConvertToHex('1.23456789', 2, 2); + const resultWei = parseInt(result.slice(2), 16); + + // Base should be 1.234567 Gwei in Wei + const basePart = Math.floor(resultWei / 100) * 100; + expect(basePart).toBe(1234567800); + + // Original ending digits: 89 + const originalEndingDigits = 89; + + // Randomized part should be in range [89-99] + const randomizedPart = resultWei - basePart; + expect(randomizedPart).toBeGreaterThanOrEqual(originalEndingDigits); + expect(randomizedPart).toBeLessThanOrEqual(99); + }); + + it('respects the PRESERVE_NUMBER_OF_DIGITS constant', () => { + const result = randomiseDecimalGWEIAndConvertToHex('0.00001', 4, 2); + const resultWei = parseInt(result.slice(2), 16); + + // Original value is 10000 Wei + // With PRESERVE_NUMBER_OF_DIGITS = 2, we can randomize at most 3 digits + // Base should be 10000 - (10000 % 1000) = 10000 + const basePart = Math.floor(resultWei / 1000) * 1000; + expect(basePart).toBe(10000); + + // Result should stay within allowed range + expect(resultWei).toBeGreaterThanOrEqual(10000); + expect(resultWei).toBeLessThanOrEqual(10999); + }); + + it('handles edge case with zero', () => { + // For "0" input, the result should still be 0 + // This is because 0 has no "ending digits" to randomize + // The implementation will still start from 0 and only randomize upward + const result = randomiseDecimalGWEIAndConvertToHex('0', 3, 2); + const resultWei = parseInt(result.slice(2), 16); + + expect(resultWei).toBeGreaterThanOrEqual(0); + expect(resultWei).toBeLessThanOrEqual(999); + }); + + it('handles different number formats correctly', () => { + const resultFromNumber = randomiseDecimalGWEIAndConvertToHex(5, 3, 2); + const resultFromString = randomiseDecimalGWEIAndConvertToHex('5', 3, 2); + expect(resultFromNumber).toStrictEqual(resultFromString); + }); +}); diff --git a/packages/transaction-controller/src/gas-flows/RandomisedEstimationsGasFeeFlow.ts b/packages/transaction-controller/src/gas-flows/RandomisedEstimationsGasFeeFlow.ts new file mode 100644 index 00000000000..b9b0391c2b6 --- /dev/null +++ b/packages/transaction-controller/src/gas-flows/RandomisedEstimationsGasFeeFlow.ts @@ -0,0 +1,211 @@ +import type { GasFeeEstimates as FeeMarketGasPriceEstimate } from '@metamask/gas-fee-controller'; +import { GAS_ESTIMATE_TYPES } from '@metamask/gas-fee-controller'; +import { add0x, createModuleLogger, type Hex } from '@metamask/utils'; + +import { DefaultGasFeeFlow } from './DefaultGasFeeFlow'; +import { projectLogger } from '../logger'; +import type { TransactionControllerMessenger } from '../TransactionController'; +import type { + FeeMarketGasFeeEstimateForLevel, + FeeMarketGasFeeEstimates, + GasFeeFlow, + GasFeeFlowRequest, + GasFeeFlowResponse, + TransactionMeta, +} from '../types'; +import { GasFeeEstimateLevel, GasFeeEstimateType } from '../types'; +import { getGasFeeRandomisation } from '../utils/feature-flags'; +import { + gweiDecimalToWeiDecimal, + gweiDecimalToWeiHex, +} from '../utils/gas-fees'; + +const log = createModuleLogger( + projectLogger, + 'randomised-estimation-gas-fee-flow', +); + +const DEFAULT_PRESERVE_NUMBER_OF_DIGITS = 2; + +/** + * Implementation of a gas fee flow that randomises the last digits of gas fee estimations + */ +export class RandomisedEstimationsGasFeeFlow implements GasFeeFlow { + matchesTransaction({ + transactionMeta, + messenger, + }: { + transactionMeta: TransactionMeta; + messenger: TransactionControllerMessenger; + }): boolean { + const { chainId } = transactionMeta; + + const gasFeeRandomisation = getGasFeeRandomisation(messenger); + + const randomisedGasFeeDigits = + gasFeeRandomisation.randomisedGasFeeDigits[chainId]; + + return randomisedGasFeeDigits !== undefined; + } + + async getGasFees(request: GasFeeFlowRequest): Promise { + try { + return await this.#getRandomisedGasFees(request); + } catch (error) { + log('Using default flow as fallback due to error', error); + return await this.#getDefaultGasFees(request); + } + } + + async #getDefaultGasFees( + request: GasFeeFlowRequest, + ): Promise { + return new DefaultGasFeeFlow().getGasFees(request); + } + + async #getRandomisedGasFees( + request: GasFeeFlowRequest, + ): Promise { + const { messenger, gasFeeControllerData, transactionMeta } = request; + const { gasEstimateType, gasFeeEstimates } = gasFeeControllerData; + + const gasFeeRandomisation = getGasFeeRandomisation(messenger); + + const randomisedGasFeeDigits = + gasFeeRandomisation.randomisedGasFeeDigits[transactionMeta.chainId]; + + const preservedNumberOfDigits = + gasFeeRandomisation.preservedNumberOfDigits ?? + DEFAULT_PRESERVE_NUMBER_OF_DIGITS; + + if (gasEstimateType === GAS_ESTIMATE_TYPES.FEE_MARKET) { + log('Randomising fee market estimates', gasFeeEstimates); + const randomisedFeeMarketEstimates = + this.#getRandomisedFeeMarketEstimates( + gasFeeEstimates, + randomisedGasFeeDigits, + preservedNumberOfDigits, + ); + log( + 'Added randomised fee market estimates', + randomisedFeeMarketEstimates, + ); + + return { + estimates: randomisedFeeMarketEstimates, + }; + } + + return await this.#getDefaultGasFees(request); + } + + #getRandomisedFeeMarketEstimates( + gasFeeEstimates: FeeMarketGasPriceEstimate, + lastNDigits: number, + preservedNumberOfDigits: number, + ): FeeMarketGasFeeEstimates { + const levels = Object.values(GasFeeEstimateLevel).reduce( + (result, level) => ({ + ...result, + [level]: this.#getRandomisedFeeMarketLevel( + gasFeeEstimates, + level, + lastNDigits, + preservedNumberOfDigits, + ), + }), + {} as Omit, + ); + + return { + type: GasFeeEstimateType.FeeMarket, + ...levels, + }; + } + + #getRandomisedFeeMarketLevel( + gasFeeEstimates: FeeMarketGasPriceEstimate, + level: GasFeeEstimateLevel, + lastNDigits: number, + preservedNumberOfDigits: number, + ): FeeMarketGasFeeEstimateForLevel { + return { + maxFeePerGas: gweiDecimalToWeiHex( + gasFeeEstimates[level].suggestedMaxFeePerGas, + ), + // Only priority fee is randomised + maxPriorityFeePerGas: randomiseDecimalGWEIAndConvertToHex( + gasFeeEstimates[level].suggestedMaxPriorityFeePerGas, + lastNDigits, + preservedNumberOfDigits, + ), + }; + } +} + +/** + * Generates a random number with the specified number of digits that is greater than or equal to the given minimum value. + * + * @param digitCount - The number of digits the random number should have + * @param minValue - The minimum value the random number should have + * @returns A random number with the specified number of digits + */ +function generateRandomDigits(digitCount: number, minValue: number): number { + const multiplier = 10 ** digitCount; + return minValue + Math.floor(Math.random() * (multiplier - minValue)); +} + +/** + * Randomises the least significant digits of a decimal gas fee value and converts it to a hexadecimal Wei value. + * + * This function preserves the more significant digits while randomizing only the least significant ones, + * ensuring that fees remain close to the original estimation while providing randomisation. + * The randomisation is performed in Wei units for more precision. + * + * @param gweiDecimalValue - The original gas fee value in Gwei (decimal) + * @param numberOfDigitsToRandomizeAtTheEnd - The number of least significant digits to randomise + * @param preservedNumberOfDigits - The number of most significant digits to preserve + * @returns The randomised value converted to Wei in hexadecimal format + */ +export function randomiseDecimalGWEIAndConvertToHex( + gweiDecimalValue: string | number, + numberOfDigitsToRandomizeAtTheEnd: number, + preservedNumberOfDigits: number, +): Hex { + const weiDecimalValue = gweiDecimalToWeiDecimal(gweiDecimalValue); + const decimalLength = weiDecimalValue.length; + + // Determine how many digits to randomise while preserving the PRESERVE_NUMBER_OF_DIGITS + const effectiveDigitsToRandomise = Math.min( + numberOfDigitsToRandomizeAtTheEnd, + decimalLength - preservedNumberOfDigits, + ); + + // Handle the case when the value is 0 or too small + if (Number(weiDecimalValue) === 0 || effectiveDigitsToRandomise <= 0) { + return `0x${Number(weiDecimalValue).toString(16)}` as Hex; + } + + // Use string manipulation to get the base part (significant digits) + const significantDigitsCount = decimalLength - effectiveDigitsToRandomise; + const significantDigits = weiDecimalValue.slice(0, significantDigitsCount); + + // Get the original ending digits using string manipulation + const endingDigits = weiDecimalValue.slice(-effectiveDigitsToRandomise); + const originalEndingDigits = Number(endingDigits); + + // Generate random digits that are greater than or equal to the original ending digits + const randomEndingDigits = generateRandomDigits( + effectiveDigitsToRandomise, + originalEndingDigits, + ); + + const basePart = BigInt( + significantDigits + '0'.repeat(effectiveDigitsToRandomise), + ); + const randomisedWeiDecimal = basePart + BigInt(randomEndingDigits); + + const hexRandomisedWei = `0x${randomisedWeiDecimal.toString(16)}`; + + return add0x(hexRandomisedWei); +} diff --git a/packages/transaction-controller/src/gas-flows/ScrollLayer1GasFeeFlow.test.ts b/packages/transaction-controller/src/gas-flows/ScrollLayer1GasFeeFlow.test.ts index 4451a1ab1c5..2c19516f207 100644 --- a/packages/transaction-controller/src/gas-flows/ScrollLayer1GasFeeFlow.test.ts +++ b/packages/transaction-controller/src/gas-flows/ScrollLayer1GasFeeFlow.test.ts @@ -1,5 +1,6 @@ import { ScrollLayer1GasFeeFlow } from './ScrollLayer1GasFeeFlow'; import { CHAIN_IDS } from '../constants'; +import type { TransactionControllerMessenger } from '../TransactionController'; import type { TransactionMeta } from '../types'; import { TransactionStatus } from '../types'; @@ -28,7 +29,12 @@ describe('ScrollLayer1GasFeeFlow', () => { chainId, }; - expect(flow.matchesTransaction(transaction)).toBe(true); + expect( + flow.matchesTransaction({ + transactionMeta: transaction, + messenger: {} as TransactionControllerMessenger, + }), + ).toBe(true); }); }); }); diff --git a/packages/transaction-controller/src/gas-flows/ScrollLayer1GasFeeFlow.ts b/packages/transaction-controller/src/gas-flows/ScrollLayer1GasFeeFlow.ts index 63c0cc66c24..0298bcebaad 100644 --- a/packages/transaction-controller/src/gas-flows/ScrollLayer1GasFeeFlow.ts +++ b/packages/transaction-controller/src/gas-flows/ScrollLayer1GasFeeFlow.ts @@ -2,6 +2,7 @@ import { type Hex } from '@metamask/utils'; import { OracleLayer1GasFeeFlow } from './OracleLayer1GasFeeFlow'; import { CHAIN_IDS } from '../constants'; +import type { TransactionControllerMessenger } from '../TransactionController'; import type { TransactionMeta } from '../types'; const SCROLL_CHAIN_IDS: Hex[] = [CHAIN_IDS.SCROLL, CHAIN_IDS.SCROLL_SEPOLIA]; @@ -18,7 +19,12 @@ export class ScrollLayer1GasFeeFlow extends OracleLayer1GasFeeFlow { super(SCROLL_GAS_PRICE_ORACLE_ADDRESS, true); } - matchesTransaction(transactionMeta: TransactionMeta): boolean { + matchesTransaction({ + transactionMeta, + }: { + transactionMeta: TransactionMeta; + messenger: TransactionControllerMessenger; + }): boolean { return SCROLL_CHAIN_IDS.includes(transactionMeta.chainId); } } diff --git a/packages/transaction-controller/src/gas-flows/TestGasFeeFlow.test.ts b/packages/transaction-controller/src/gas-flows/TestGasFeeFlow.test.ts index 6c24952ecac..44a5f77c75d 100644 --- a/packages/transaction-controller/src/gas-flows/TestGasFeeFlow.test.ts +++ b/packages/transaction-controller/src/gas-flows/TestGasFeeFlow.test.ts @@ -1,15 +1,11 @@ import { TestGasFeeFlow } from './TestGasFeeFlow'; -import { - GasFeeEstimateType, - type GasFeeFlowRequest, - type TransactionMeta, -} from '../types'; +import { GasFeeEstimateType, type GasFeeFlowRequest } from '../types'; describe('TestGasFeeFlow', () => { describe('matchesTransaction', () => { it('should return true', () => { const testGasFeeFlow = new TestGasFeeFlow(); - const result = testGasFeeFlow.matchesTransaction({} as TransactionMeta); + const result = testGasFeeFlow.matchesTransaction(); expect(result).toBe(true); }); }); diff --git a/packages/transaction-controller/src/gas-flows/TestGasFeeFlow.ts b/packages/transaction-controller/src/gas-flows/TestGasFeeFlow.ts index 718c4a6bbfc..1c5d63dce8a 100644 --- a/packages/transaction-controller/src/gas-flows/TestGasFeeFlow.ts +++ b/packages/transaction-controller/src/gas-flows/TestGasFeeFlow.ts @@ -6,7 +6,6 @@ import { type GasFeeFlow, type GasFeeFlowRequest, type GasFeeFlowResponse, - type TransactionMeta, } from '../types'; const INCREMENT = 1e15; // 0.001 ETH @@ -20,7 +19,7 @@ const LEVEL_DIFFERENCE = 0.5; export class TestGasFeeFlow implements GasFeeFlow { #counter = 1; - matchesTransaction(_transactionMeta: TransactionMeta): boolean { + matchesTransaction(): boolean { return true; } diff --git a/packages/transaction-controller/src/helpers/GasFeePoller.test.ts b/packages/transaction-controller/src/helpers/GasFeePoller.test.ts index 348921c38c9..cbe007ff348 100644 --- a/packages/transaction-controller/src/helpers/GasFeePoller.test.ts +++ b/packages/transaction-controller/src/helpers/GasFeePoller.test.ts @@ -3,6 +3,7 @@ import type { Hex } from '@metamask/utils'; import { GasFeePoller, updateTransactionGasFees } from './GasFeePoller'; import { flushPromises } from '../../../../tests/helpers'; +import type { TransactionControllerMessenger } from '../TransactionController'; import type { GasFeeFlowResponse, Layer1GasFeeFlow } from '../types'; import { GasFeeEstimateLevel, @@ -16,6 +17,7 @@ import { } from '../types'; import { getTransactionLayer1GasFee } from '../utils/layer1-gas-fee-flow'; +jest.mock('../utils/feature-flags'); jest.mock('../utils/layer1-gas-fee-flow', () => ({ getTransactionLayer1GasFee: jest.fn(), })); @@ -77,6 +79,7 @@ describe('GasFeePoller', () => { const layer1GasFeeFlowsMock: jest.Mocked = []; const getGasFeeControllerEstimatesMock = jest.fn(); const findNetworkClientIdByChainIdMock = jest.fn(); + const messengerMock = jest.fn() as unknown as TransactionControllerMessenger; beforeEach(() => { jest.clearAllTimers(); @@ -97,6 +100,7 @@ describe('GasFeePoller', () => { getGasFeeControllerEstimates: getGasFeeControllerEstimatesMock, getTransactions: getTransactionsMock, layer1GasFeeFlows: layer1GasFeeFlowsMock, + messenger: messengerMock, onStateChange: (listener: () => void) => { triggerOnStateChange = listener; }, @@ -136,6 +140,7 @@ describe('GasFeePoller', () => { expect(gasFeeFlowMock.getGasFees).toHaveBeenCalledWith({ ethQuery: expect.any(Object), gasFeeControllerData: {}, + messenger: expect.any(Function), transactionMeta: TRANSACTION_META_MOCK, }); }); @@ -150,6 +155,7 @@ describe('GasFeePoller', () => { expect(getTransactionLayer1GasFeeMock).toHaveBeenCalledWith({ provider: expect.any(Object), layer1GasFeeFlows: layer1GasFeeFlowsMock, + messenger: expect.any(Function), transactionMeta: TRANSACTION_META_MOCK, }); }); diff --git a/packages/transaction-controller/src/helpers/GasFeePoller.ts b/packages/transaction-controller/src/helpers/GasFeePoller.ts index 87c3d930821..b77de08c962 100644 --- a/packages/transaction-controller/src/helpers/GasFeePoller.ts +++ b/packages/transaction-controller/src/helpers/GasFeePoller.ts @@ -11,6 +11,7 @@ import { createModuleLogger } from '@metamask/utils'; import EventEmitter from 'events'; import { projectLogger } from '../logger'; +import type { TransactionControllerMessenger } from '../TransactionController'; import type { GasFeeEstimates, GasFeeFlow, @@ -56,6 +57,8 @@ export class GasFeePoller { readonly #layer1GasFeeFlows: Layer1GasFeeFlow[]; + readonly #messenger: TransactionControllerMessenger; + #timeout: ReturnType | undefined; #running = false; @@ -70,6 +73,7 @@ export class GasFeePoller { * @param options.getProvider - Callback to obtain a provider instance. * @param options.getTransactions - Callback to obtain the transaction data. * @param options.layer1GasFeeFlows - The layer 1 gas fee flows to use to obtain suitable layer 1 gas fees. + * @param options.messenger - The TransactionControllerMessenger instance. * @param options.onStateChange - Callback to register a listener for controller state changes. */ constructor({ @@ -79,6 +83,7 @@ export class GasFeePoller { getProvider, getTransactions, layer1GasFeeFlows, + messenger, onStateChange, }: { findNetworkClientIdByChainId: (chainId: Hex) => NetworkClientId | undefined; @@ -89,6 +94,7 @@ export class GasFeePoller { getProvider: (networkClientId: NetworkClientId) => Provider; getTransactions: () => TransactionMeta[]; layer1GasFeeFlows: Layer1GasFeeFlow[]; + messenger: TransactionControllerMessenger; onStateChange: (listener: () => void) => void; }) { this.#findNetworkClientIdByChainId = findNetworkClientIdByChainId; @@ -97,6 +103,7 @@ export class GasFeePoller { this.#getGasFeeControllerEstimates = getGasFeeControllerEstimates; this.#getProvider = getProvider; this.#getTransactions = getTransactions; + this.#messenger = messenger; onStateChange(() => { const unapprovedTransactions = this.#getUnapprovedTransactions(); @@ -207,7 +214,11 @@ export class GasFeePoller { const { networkClientId } = transactionMeta; const ethQuery = new EthQuery(this.#getProvider(networkClientId)); - const gasFeeFlow = getGasFeeFlow(transactionMeta, this.#gasFeeFlows); + const gasFeeFlow = getGasFeeFlow( + transactionMeta, + this.#gasFeeFlows, + this.#messenger, + ); if (gasFeeFlow) { log( @@ -220,6 +231,7 @@ export class GasFeePoller { const request: GasFeeFlowRequest = { ethQuery, gasFeeControllerData, + messenger: this.#messenger, transactionMeta, }; @@ -254,6 +266,7 @@ export class GasFeePoller { const layer1GasFee = await getTransactionLayer1GasFee({ layer1GasFeeFlows: this.#layer1GasFeeFlows, + messenger: this.#messenger, provider, transactionMeta, }); diff --git a/packages/transaction-controller/src/types.ts b/packages/transaction-controller/src/types.ts index 946e80eb973..b132c55a5e7 100644 --- a/packages/transaction-controller/src/types.ts +++ b/packages/transaction-controller/src/types.ts @@ -6,6 +6,8 @@ import type { NetworkClientId, Provider } from '@metamask/network-controller'; import type { Hex, Json } from '@metamask/utils'; import type { Operation } from 'fast-json-patch'; +import type { TransactionControllerMessenger } from './TransactionController'; + /** * Given a record, ensures that each property matches the `Json` type. */ @@ -1213,6 +1215,9 @@ export type GasFeeFlowRequest = { /** Gas fee controller data matching the chain ID of the transaction. */ gasFeeControllerData: GasFeeState; + /** The messenger instance. */ + messenger: TransactionControllerMessenger; + /** The metadata of the transaction to obtain estimates for. */ transactionMeta: TransactionMeta; }; @@ -1228,10 +1233,18 @@ export type GasFeeFlow = { /** * Determine if the gas fee flow supports the specified transaction. * - * @param transactionMeta - The transaction metadata. + * @param args - The arguments for the matcher function. + * @param args.transactionMeta - The transaction metadata. + * @param args.messenger - The messenger instance. * @returns Whether the gas fee flow supports the transaction. */ - matchesTransaction(transactionMeta: TransactionMeta): boolean; + matchesTransaction({ + transactionMeta, + messenger, + }: { + transactionMeta: TransactionMeta; + messenger: TransactionControllerMessenger; + }): boolean; /** * Get gas fee estimates for a specific transaction. @@ -1262,10 +1275,18 @@ export type Layer1GasFeeFlow = { /** * Determine if the gas fee flow supports the specified transaction. * - * @param transactionMeta - The transaction metadata. - * @returns Whether the layer1 gas fee flow supports the transaction. + * @param args - The arguments for the matcher function. + * @param args.transactionMeta - The transaction metadata. + * @param args.messenger - The messenger instance. + * @returns Whether the gas fee flow supports the transaction. */ - matchesTransaction(transactionMeta: TransactionMeta): boolean; + matchesTransaction({ + transactionMeta, + messenger, + }: { + transactionMeta: TransactionMeta; + messenger: TransactionControllerMessenger; + }): boolean; /** * Get layer 1 gas fee estimates for a specific transaction. diff --git a/packages/transaction-controller/src/utils/feature-flags.test.ts b/packages/transaction-controller/src/utils/feature-flags.test.ts index 0b888d6b940..5423d7a78d6 100644 --- a/packages/transaction-controller/src/utils/feature-flags.test.ts +++ b/packages/transaction-controller/src/utils/feature-flags.test.ts @@ -11,6 +11,7 @@ import { getEIP7702ContractAddresses, getEIP7702SupportedChains, getEIP7702UpgradeContractAddress, + getGasFeeRandomisation, } from './feature-flags'; import { isValidSignature } from './signature'; import type { TransactionControllerMessenger } from '..'; @@ -428,4 +429,52 @@ describe('Feature Flags Utils', () => { }); }); }); + + describe('getGasFeeRandomisation', () => { + it('returns empty objects if no feature flags set', () => { + mockFeatureFlags({}); + + expect(getGasFeeRandomisation(controllerMessenger)).toStrictEqual({ + randomisedGasFeeDigits: {}, + preservedNumberOfDigits: undefined, + }); + }); + + it('returns values from feature flags when set', () => { + mockFeatureFlags({ + [FEATURE_FLAG_TRANSACTIONS]: { + gasFeeRandomisation: { + randomisedGasFeeDigits: { + [CHAIN_ID_MOCK]: 3, + [CHAIN_ID_2_MOCK]: 5, + }, + preservedNumberOfDigits: 2, + }, + }, + }); + + expect(getGasFeeRandomisation(controllerMessenger)).toStrictEqual({ + randomisedGasFeeDigits: { + [CHAIN_ID_MOCK]: 3, + [CHAIN_ID_2_MOCK]: 5, + }, + preservedNumberOfDigits: 2, + }); + }); + + it('returns empty randomisedGasFeeDigits if not set in feature flags', () => { + mockFeatureFlags({ + [FEATURE_FLAG_TRANSACTIONS]: { + gasFeeRandomisation: { + preservedNumberOfDigits: 2, + }, + }, + }); + + expect(getGasFeeRandomisation(controllerMessenger)).toStrictEqual({ + randomisedGasFeeDigits: {}, + preservedNumberOfDigits: 2, + }); + }); + }); }); diff --git a/packages/transaction-controller/src/utils/feature-flags.ts b/packages/transaction-controller/src/utils/feature-flags.ts index 9be6d52ec2d..1082ba8e89b 100644 --- a/packages/transaction-controller/src/utils/feature-flags.ts +++ b/packages/transaction-controller/src/utils/feature-flags.ts @@ -63,6 +63,14 @@ export type TransactionControllerFeatureFlags = { /** Default `intervalMs` in case no chain-specific parameter is set. */ defaultIntervalMs?: number; }; + + gasFeeRandomisation?: { + /** Randomised gas fee digits per chainId. */ + randomisedGasFeeDigits?: Record; + + /** Number of digits to preserve for randomised gas fee digits. */ + preservedNumberOfDigits?: number; + }; }; }; @@ -174,6 +182,29 @@ export function getAcceleratedPollingParams( return { countMax, intervalMs }; } +/** + * Retrieves the gas fee randomisation parameters. + * + * @param messenger - The controller messenger instance. + * @returns The gas fee randomisation parameters. + */ +export function getGasFeeRandomisation( + messenger: TransactionControllerMessenger, +): { + randomisedGasFeeDigits: Record; + preservedNumberOfDigits: number | undefined; +} { + const featureFlags = getFeatureFlags(messenger); + + const gasFeeRandomisation = + featureFlags?.[FEATURE_FLAG_TRANSACTIONS]?.gasFeeRandomisation || {}; + + return { + randomisedGasFeeDigits: gasFeeRandomisation.randomisedGasFeeDigits || {}, + preservedNumberOfDigits: gasFeeRandomisation.preservedNumberOfDigits, + }; +} + /** * 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 fbef41ffce4..69f73f6d0b7 100644 --- a/packages/transaction-controller/src/utils/gas-fees.test.ts +++ b/packages/transaction-controller/src/utils/gas-fees.test.ts @@ -1,7 +1,7 @@ import { ORIGIN_METAMASK, query } from '@metamask/controller-utils'; import type { UpdateGasFeesRequest } from './gas-fees'; -import { updateGasFees } from './gas-fees'; +import { gweiDecimalToWeiDecimal, updateGasFees } from './gas-fees'; import type { GasFeeFlow, GasFeeFlowResponse } from '../types'; import { GasFeeEstimateType, TransactionType, UserFeeLevel } from '../types'; @@ -550,3 +550,29 @@ describe('gas-fees', () => { }); }); }); + +describe('gweiDecimalToWeiDecimal', () => { + it('converts string gwei decimal to wei decimal', () => { + expect(gweiDecimalToWeiDecimal('1')).toBe('1000000000'); + expect(gweiDecimalToWeiDecimal('1.5')).toBe('1500000000'); + expect(gweiDecimalToWeiDecimal('0.1')).toBe('100000000'); + expect(gweiDecimalToWeiDecimal('123.456')).toBe('123456000000'); + }); + + it('converts number gwei decimal to wei decimal', () => { + expect(gweiDecimalToWeiDecimal(1)).toBe('1000000000'); + expect(gweiDecimalToWeiDecimal(1.5)).toBe('1500000000'); + expect(gweiDecimalToWeiDecimal(0.1)).toBe('100000000'); + expect(gweiDecimalToWeiDecimal(123.456)).toBe('123456000000'); + }); + + it('handles zero values', () => { + expect(gweiDecimalToWeiDecimal('0')).toBe('0'); + expect(gweiDecimalToWeiDecimal(0)).toBe('0'); + }); + + it('handles very large values', () => { + expect(gweiDecimalToWeiDecimal('1000000')).toBe('1000000000000000'); + expect(gweiDecimalToWeiDecimal(1000000)).toBe('1000000000000000'); + }); +}); diff --git a/packages/transaction-controller/src/utils/gas-fees.ts b/packages/transaction-controller/src/utils/gas-fees.ts index 1aaf7aa8fa8..50a7f64f5f9 100644 --- a/packages/transaction-controller/src/utils/gas-fees.ts +++ b/packages/transaction-controller/src/utils/gas-fees.ts @@ -15,6 +15,7 @@ import { add0x, createModuleLogger } from '@metamask/utils'; import { getGasFeeFlow } from './gas-flow'; import { SWAP_TRANSACTION_TYPES } from './swaps'; import { projectLogger } from '../logger'; +import type { TransactionControllerMessenger } from '../TransactionController'; import type { SavedGasFees, TransactionParams, @@ -32,6 +33,7 @@ export type UpdateGasFeesRequest = { options: FetchGasFeeEstimateOptions, ) => Promise; getSavedGasFees: (chainId: Hex) => SavedGasFees | undefined; + messenger: TransactionControllerMessenger; txMeta: TransactionMeta; }; @@ -112,6 +114,26 @@ export function gweiDecimalToWeiHex(value: string) { return toHex(gweiDecToWEIBN(value)); } +/** + * Converts a value from Gwei decimal representation to Wei decimal representation + * + * @param gweiDecimal - The value in Gwei as a string or number + * @returns The value in Wei as a string + * + * @example + * // Convert 1.5 Gwei to Wei + * gweiDecimalToWeiDecimal("1.5") + * // Returns "1500000000" + */ +export function gweiDecimalToWeiDecimal(gweiDecimal: string | number): string { + const gwei = + typeof gweiDecimal === 'string' ? gweiDecimal : String(gweiDecimal); + + const weiDecimal = Number(gwei) * 1e9; + + return weiDecimal.toString(); +} + /** * Determine the maxFeePerGas value for the transaction. * @@ -326,8 +348,14 @@ function updateDefaultGasEstimates(txMeta: TransactionMeta) { async function getSuggestedGasFees( request: UpdateGasFeesRequest, ): Promise { - const { eip1559, ethQuery, gasFeeFlows, getGasFeeEstimates, txMeta } = - request; + const { + eip1559, + ethQuery, + gasFeeFlows, + getGasFeeEstimates, + messenger, + txMeta, + } = request; const { networkClientId } = txMeta; @@ -340,7 +368,11 @@ async function getSuggestedGasFees( return {}; } - const gasFeeFlow = getGasFeeFlow(txMeta, gasFeeFlows) as GasFeeFlow; + const gasFeeFlow = getGasFeeFlow( + txMeta, + gasFeeFlows, + messenger, + ) as GasFeeFlow; try { const gasFeeControllerData = await getGasFeeEstimates({ networkClientId }); @@ -348,6 +380,7 @@ async function getSuggestedGasFees( const response = await gasFeeFlow.getGasFees({ ethQuery, gasFeeControllerData, + messenger, transactionMeta: txMeta, }); diff --git a/packages/transaction-controller/src/utils/gas-flow.test.ts b/packages/transaction-controller/src/utils/gas-flow.test.ts index 2d8e7f4e31a..a6faffe8362 100644 --- a/packages/transaction-controller/src/utils/gas-flow.test.ts +++ b/packages/transaction-controller/src/utils/gas-flow.test.ts @@ -1,6 +1,7 @@ import type { GasFeeEstimates as GasFeeControllerEstimates } from '@metamask/gas-fee-controller'; import { getGasFeeFlow, mergeGasFeeEstimates } from './gas-flow'; +import type { TransactionControllerMessenger } from '../TransactionController'; import type { FeeMarketGasFeeEstimates, GasFeeFlow, @@ -94,7 +95,11 @@ describe('gas-flow', () => { gasFeeFlow2.matchesTransaction.mockReturnValue(false); expect( - getGasFeeFlow(TRANSACTION_META_MOCK, [gasFeeFlow1, gasFeeFlow2]), + getGasFeeFlow( + TRANSACTION_META_MOCK, + [gasFeeFlow1, gasFeeFlow2], + {} as TransactionControllerMessenger, + ), ).toBeUndefined(); }); @@ -106,7 +111,11 @@ describe('gas-flow', () => { gasFeeFlow2.matchesTransaction.mockReturnValue(true); expect( - getGasFeeFlow(TRANSACTION_META_MOCK, [gasFeeFlow1, gasFeeFlow2]), + getGasFeeFlow( + TRANSACTION_META_MOCK, + [gasFeeFlow1, gasFeeFlow2], + {} as TransactionControllerMessenger, + ), ).toBe(gasFeeFlow2); }); }); diff --git a/packages/transaction-controller/src/utils/gas-flow.ts b/packages/transaction-controller/src/utils/gas-flow.ts index 94cf4ed0b7f..a641c74dc12 100644 --- a/packages/transaction-controller/src/utils/gas-flow.ts +++ b/packages/transaction-controller/src/utils/gas-flow.ts @@ -7,6 +7,7 @@ import type { } from '@metamask/gas-fee-controller'; import { type GasFeeState } from '@metamask/gas-fee-controller'; +import type { TransactionControllerMessenger } from '../TransactionController'; import type { FeeMarketGasFeeEstimates, GasPriceGasFeeEstimates, @@ -36,14 +37,16 @@ type MergeGasFeeEstimatesRequest = { * * @param transactionMeta - The transaction metadata to find a gas fee flow for. * @param gasFeeFlows - The gas fee flows to search. + * @param messenger - The messenger instance. * @returns The first gas fee flow that matches the transaction, or undefined if none match. */ export function getGasFeeFlow( transactionMeta: TransactionMeta, gasFeeFlows: GasFeeFlow[], + messenger: TransactionControllerMessenger, ): GasFeeFlow | undefined { return gasFeeFlows.find((gasFeeFlow) => - gasFeeFlow.matchesTransaction(transactionMeta), + gasFeeFlow.matchesTransaction({ transactionMeta, messenger }), ); } diff --git a/packages/transaction-controller/src/utils/layer1-gas-fee-flow.test.ts b/packages/transaction-controller/src/utils/layer1-gas-fee-flow.test.ts index a5027bc4d35..ad39439c5f8 100644 --- a/packages/transaction-controller/src/utils/layer1-gas-fee-flow.test.ts +++ b/packages/transaction-controller/src/utils/layer1-gas-fee-flow.test.ts @@ -2,6 +2,7 @@ import type { Provider } from '@metamask/network-controller'; import type { Hex } from '@metamask/utils'; import { updateTransactionLayer1GasFee } from './layer1-gas-fee-flow'; +import type { TransactionControllerMessenger } from '../TransactionController'; import { TransactionStatus, type Layer1GasFeeFlow, @@ -41,6 +42,7 @@ describe('updateTransactionLayer1GasFee', () => { let layer1GasFeeFlowsMock: jest.Mocked; let providerMock: Provider; let transactionMetaMock: TransactionMeta; + let messengerMock: TransactionControllerMessenger; beforeEach(() => { layer1GasFeeFlowsMock = [ @@ -66,11 +68,14 @@ describe('updateTransactionLayer1GasFee', () => { from: '0x123', }, }; + + messengerMock = {} as TransactionControllerMessenger; }); it('updates given transaction layer1GasFee property', async () => { await updateTransactionLayer1GasFee({ layer1GasFeeFlows: layer1GasFeeFlowsMock, + messenger: messengerMock, provider: providerMock, transactionMeta: transactionMetaMock, }); @@ -101,6 +106,7 @@ describe('updateTransactionLayer1GasFee', () => { await updateTransactionLayer1GasFee({ layer1GasFeeFlows: layer1GasFeeFlowsMock, + messenger: messengerMock, provider: providerMock, transactionMeta: transactionMetaMock, }); @@ -121,6 +127,7 @@ describe('updateTransactionLayer1GasFee', () => { await updateTransactionLayer1GasFee({ layer1GasFeeFlows: layer1GasFeeFlowsMock, + messenger: messengerMock, provider: providerMock, transactionMeta: transactionMetaMock, }); diff --git a/packages/transaction-controller/src/utils/layer1-gas-fee-flow.ts b/packages/transaction-controller/src/utils/layer1-gas-fee-flow.ts index a311a60b20e..ff11cb4a958 100644 --- a/packages/transaction-controller/src/utils/layer1-gas-fee-flow.ts +++ b/packages/transaction-controller/src/utils/layer1-gas-fee-flow.ts @@ -2,12 +2,14 @@ import type { Provider } from '@metamask/network-controller'; import { createModuleLogger, type Hex } from '@metamask/utils'; import { projectLogger } from '../logger'; +import type { TransactionControllerMessenger } from '../TransactionController'; import type { Layer1GasFeeFlow, TransactionMeta } from '../types'; const log = createModuleLogger(projectLogger, 'layer-1-gas-fee-flow'); export type UpdateLayer1GasFeeRequest = { layer1GasFeeFlows: Layer1GasFeeFlow[]; + messenger: TransactionControllerMessenger; provider: Provider; transactionMeta: TransactionMeta; }; @@ -41,14 +43,19 @@ export async function updateTransactionLayer1GasFee( * * @param transactionMeta - The transaction to get the layer 1 gas fee flow for. * @param layer1GasFeeFlows - The layer 1 gas fee flows to search. + * @param messenger - The messenger instance. * @returns The layer 1 gas fee flow for the transaction, or undefined if none match. */ function getLayer1GasFeeFlow( transactionMeta: TransactionMeta, layer1GasFeeFlows: Layer1GasFeeFlow[], + messenger: TransactionControllerMessenger, ): Layer1GasFeeFlow | undefined { return layer1GasFeeFlows.find((layer1GasFeeFlow) => - layer1GasFeeFlow.matchesTransaction(transactionMeta), + layer1GasFeeFlow.matchesTransaction({ + transactionMeta, + messenger, + }), ); } @@ -59,16 +66,19 @@ function getLayer1GasFeeFlow( * @param request.layer1GasFeeFlows - The layer 1 gas fee flows to search. * @param request.provider - The provider to use to get the layer 1 gas fee. * @param request.transactionMeta - The transaction to get the layer 1 gas fee for. + * @param request.messenger - The messenger instance. * @returns The layer 1 gas fee. */ export async function getTransactionLayer1GasFee({ layer1GasFeeFlows, + messenger, provider, transactionMeta, }: UpdateLayer1GasFeeRequest): Promise { const layer1GasFeeFlow = getLayer1GasFeeFlow( transactionMeta, layer1GasFeeFlows, + messenger, ); if (!layer1GasFeeFlow) {