From fbc396ab6e9465404aa094ab601971e16d1ac627 Mon Sep 17 00:00:00 2001 From: cryptotavares Date: Tue, 14 Jul 2026 17:13:38 +0100 Subject: [PATCH 1/5] fix: query layer 1 gas fee oracles via direct eth_call instead of ethers Web3Provider Previously OracleLayer1GasFeeFlow and MantleLayer1GasFeeFlow called OP-stack oracle contracts via an ethers Contract backed by Web3Provider. Web3Provider batches its JSON-RPC dispatch using setTimeout, which never fires on React Native iOS when the timer pump is starved due to a display-link freeze. This blocks addTransaction indefinitely, preventing the dapp confirmation modal from ever appearing (MetaMask/metamask-mobile#32863, Sev1). Replace the Contract/Web3Provider path with Interface.encodeFunctionData (from @ethersproject/abi) to encode calldata, then dispatch via the controller's existing rpcRequest utility (a plain provider.request eth_call). This path is timer-free and works correctly even while the RN timer pump is frozen. Layer1GasFeeFlowRequest gains a messenger field so flows can call rpcRequest directly. The fix is applied to both the base oracle flow (getL1Fee, getOperatorFee) and MantleLayer1GasFeeFlow (tokenRatio). Validated end-to-end on a live MetaMask Mobile repro: the confirmation modal now surfaces while the RN timer pump is frozen. --- packages/transaction-controller/CHANGELOG.md | 6 + .../gas-flows/MantleLayer1GasFeeFlow.test.ts | 147 ++++++++++++------ .../src/gas-flows/MantleLayer1GasFeeFlow.ts | 40 +++-- .../gas-flows/OracleLayer1GasFeeFlow.test.ts | 140 +++++++++++------ .../src/gas-flows/OracleLayer1GasFeeFlow.ts | 84 +++++----- packages/transaction-controller/src/types.ts | 3 + .../src/utils/layer1-gas-fee-flow.test.ts | 2 + .../src/utils/layer1-gas-fee-flow.ts | 1 + 8 files changed, 274 insertions(+), 149 deletions(-) diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index a37416da197..463ddad1593 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Add `messenger` property to `Layer1GasFeeFlowRequest` ([#9502](https://github.com/MetaMask/core/pull/9502)) +- Query layer 1 gas fee oracles via direct `eth_call` RPC requests instead of an ethers `Contract` backed by `Web3Provider` ([#9502](https://github.com/MetaMask/core/pull/9502)) + - `Web3Provider` schedules its JSON-RPC dispatch with `setTimeout`, which never fires on React Native iOS when the timer pump is starved, blocking `addTransaction` indefinitely and preventing dapp confirmations from appearing ([MetaMask/metamask-mobile#32863](https://github.com/MetaMask/metamask-mobile/issues/32863)) + ## [69.0.0] ### Changed diff --git a/packages/transaction-controller/src/gas-flows/MantleLayer1GasFeeFlow.test.ts b/packages/transaction-controller/src/gas-flows/MantleLayer1GasFeeFlow.test.ts index e6528702e90..97597ee07d6 100644 --- a/packages/transaction-controller/src/gas-flows/MantleLayer1GasFeeFlow.test.ts +++ b/packages/transaction-controller/src/gas-flows/MantleLayer1GasFeeFlow.test.ts @@ -1,6 +1,6 @@ import { TransactionFactory } from '@ethereumjs/tx'; import type { TypedTransaction } from '@ethereumjs/tx'; -import { Contract } from '@ethersproject/contracts'; +import { Interface } from '@ethersproject/abi'; import type { Provider } from '@metamask/network-controller'; import { add0x } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; @@ -10,14 +10,20 @@ import { CHAIN_IDS } from '../constants'; import type { TransactionControllerMessenger } from '../TransactionController'; import { TransactionStatus } from '../types'; import type { Layer1GasFeeFlowRequest, TransactionMeta } from '../types'; +import { rpcRequest } from '../utils/provider'; import { bnFromHex, padHexToEvenLength } from '../utils/utils'; import { MantleLayer1GasFeeFlow } from './MantleLayer1GasFeeFlow'; -jest.mock('@ethersproject/contracts', () => ({ - Contract: jest.fn(), -})); +jest.mock('../utils/provider'); -jest.mock('@ethersproject/providers'); +const ORACLE_INTERFACE = new Interface([ + 'function getL1Fee(bytes _data)', + 'function getOperatorFee(uint256 _gasUsed)', + 'function tokenRatio()', +]); + +const GET_L1_FEE_SELECTOR = ORACLE_INTERFACE.getSighash('getL1Fee'); +const TOKEN_RATIO_SELECTOR = ORACLE_INTERFACE.getSighash('tokenRatio'); const TRANSACTION_PARAMS_MOCK = { from: '0x123', @@ -42,6 +48,8 @@ const TRANSACTION_META_TESTNET_MOCK: TransactionMeta = { txParams: TRANSACTION_PARAMS_MOCK, }; +const MESSENGER_MOCK = {} as TransactionControllerMessenger; + const SERIALIZED_TRANSACTION_MOCK = '0x1234'; // L1 fee in ETH (returned by oracle) const L1_FEE_MOCK = '0x0de0b6b3a7640000'; // 1e18 (1 ETH in wei) @@ -69,37 +77,42 @@ function createMockTypedTransaction( } describe('MantleLayer1GasFeeFlow', () => { - const contractMock = jest.mocked(Contract); - const contractGetL1FeeMock: jest.MockedFn<() => Promise> = jest.fn(); - const contractGetOperatorFeeMock: jest.MockedFn<() => Promise> = - jest.fn(); - const contractTokenRatioMock: jest.MockedFn<() => Promise> = jest.fn(); + const rpcRequestMock = jest.mocked(rpcRequest); + const getL1FeeMock: jest.MockedFn<() => Promise> = jest.fn(); + const getOperatorFeeMock: jest.MockedFn<() => Promise> = jest.fn(); + const tokenRatioMock: jest.MockedFn<() => Promise> = jest.fn(); let request: Layer1GasFeeFlowRequest; beforeEach(() => { request = { + messenger: MESSENGER_MOCK, provider: {} as Provider, transactionMeta: TRANSACTION_META_MOCK, }; - contractMock.mockClear(); - contractGetL1FeeMock.mockClear(); - contractGetOperatorFeeMock.mockClear(); - contractTokenRatioMock.mockClear(); - - contractGetL1FeeMock.mockResolvedValue(bnFromHex(L1_FEE_MOCK)); - contractGetOperatorFeeMock.mockResolvedValue(new BN(0)); - contractTokenRatioMock.mockResolvedValue(TOKEN_RATIO_MOCK); - - // The base class creates a contract first (for getL1Fee/getOperatorFee), - // then transformOracleFee creates a second contract (for tokenRatio). - // Both use the same mock constructor. - contractMock.mockReturnValue({ - getL1Fee: contractGetL1FeeMock, - getOperatorFee: contractGetOperatorFeeMock, - tokenRatio: contractTokenRatioMock, - } as unknown as Contract); + rpcRequestMock.mockClear(); + getL1FeeMock.mockClear(); + getOperatorFeeMock.mockClear(); + tokenRatioMock.mockClear(); + + getL1FeeMock.mockResolvedValue(L1_FEE_MOCK); + getOperatorFeeMock.mockResolvedValue('0x0'); + tokenRatioMock.mockResolvedValue(add0x(TOKEN_RATIO_MOCK.toString(16))); + + rpcRequestMock.mockImplementation(async (args) => { + const { data } = (args.params as [{ data: Hex }, string])[0]; + + if (data.startsWith(GET_L1_FEE_SELECTOR)) { + return await getL1FeeMock(); + } + + if (data.startsWith(TOKEN_RATIO_SELECTOR)) { + return await tokenRatioMock(); + } + + return await getOperatorFeeMock(); + }); }); describe('matchesTransaction', () => { @@ -153,9 +166,7 @@ describe('MantleLayer1GasFeeFlow', () => { }, }; - contractGetOperatorFeeMock.mockResolvedValueOnce( - bnFromHex(OPERATOR_FEE_MOCK), - ); + getOperatorFeeMock.mockResolvedValueOnce(OPERATOR_FEE_MOCK); jest .spyOn(TransactionFactory, 'fromTxData') @@ -173,17 +184,15 @@ describe('MantleLayer1GasFeeFlow', () => { bnFromHex(OPERATOR_FEE_MOCK), ); - expect(contractTokenRatioMock).toHaveBeenCalledTimes(1); - expect(contractGetOperatorFeeMock).toHaveBeenCalledTimes(1); + expect(tokenRatioMock).toHaveBeenCalledTimes(1); + expect(getOperatorFeeMock).toHaveBeenCalledTimes(1); expect(response).toStrictEqual({ layer1Fee: add0x(padHexToEvenLength(expectedTotal.toString(16))), }); }); it('falls back to txParams.gas for operator fee when gasUsed is missing', async () => { - contractGetOperatorFeeMock.mockResolvedValueOnce( - bnFromHex(OPERATOR_FEE_MOCK), - ); + getOperatorFeeMock.mockResolvedValueOnce(OPERATOR_FEE_MOCK); jest .spyOn(TransactionFactory, 'fromTxData') @@ -201,9 +210,19 @@ describe('MantleLayer1GasFeeFlow', () => { bnFromHex(OPERATOR_FEE_MOCK), ); - expect(contractGetOperatorFeeMock).toHaveBeenCalledTimes(1); - expect(contractGetOperatorFeeMock).toHaveBeenCalledWith( - TRANSACTION_PARAMS_MOCK.gas, + expect(getOperatorFeeMock).toHaveBeenCalledTimes(1); + expect(rpcRequestMock).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'eth_call', + params: [ + expect.objectContaining({ + data: ORACLE_INTERFACE.encodeFunctionData('getOperatorFee', [ + TRANSACTION_PARAMS_MOCK.gas, + ]), + }), + 'latest', + ], + }), ); expect(response).toStrictEqual({ layer1Fee: add0x(padHexToEvenLength(expectedTotal.toString(16))), @@ -223,9 +242,7 @@ describe('MantleLayer1GasFeeFlow', () => { }, }; - contractGetOperatorFeeMock.mockResolvedValueOnce( - bnFromHex(OPERATOR_FEE_MOCK), - ); + getOperatorFeeMock.mockResolvedValueOnce(OPERATOR_FEE_MOCK); jest .spyOn(TransactionFactory, 'fromTxData') @@ -243,8 +260,20 @@ describe('MantleLayer1GasFeeFlow', () => { bnFromHex(OPERATOR_FEE_MOCK), ); - expect(contractGetOperatorFeeMock).toHaveBeenCalledTimes(1); - expect(contractGetOperatorFeeMock).toHaveBeenCalledWith(gasLimit); + expect(getOperatorFeeMock).toHaveBeenCalledTimes(1); + expect(rpcRequestMock).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'eth_call', + params: [ + expect.objectContaining({ + data: ORACLE_INTERFACE.encodeFunctionData('getOperatorFee', [ + gasLimit, + ]), + }), + 'latest', + ], + }), + ); expect(response).toStrictEqual({ layer1Fee: add0x(padHexToEvenLength(expectedTotal.toString(16))), }); @@ -272,7 +301,7 @@ describe('MantleLayer1GasFeeFlow', () => { const expectedL1FeeInMnt = bnFromHex(L1_FEE_MOCK).mul(TOKEN_RATIO_MOCK); - expect(contractGetOperatorFeeMock).not.toHaveBeenCalled(); + expect(getOperatorFeeMock).not.toHaveBeenCalled(); expect(response).toStrictEqual({ layer1Fee: add0x(padHexToEvenLength(expectedL1FeeInMnt.toString(16))), }); @@ -288,7 +317,7 @@ describe('MantleLayer1GasFeeFlow', () => { }, }; - contractGetOperatorFeeMock.mockRejectedValueOnce(new Error('revert')); + getOperatorFeeMock.mockRejectedValueOnce(new Error('revert')); jest .spyOn(TransactionFactory, 'fromTxData') @@ -309,7 +338,25 @@ describe('MantleLayer1GasFeeFlow', () => { }); it('throws if tokenRatio call fails', async () => { - contractTokenRatioMock.mockRejectedValue(new Error('error')); + tokenRatioMock.mockRejectedValue(new Error('error')); + + jest + .spyOn(TransactionFactory, 'fromTxData') + .mockReturnValueOnce( + createMockTypedTransaction( + Buffer.from(SERIALIZED_TRANSACTION_MOCK, 'hex'), + ), + ); + + const flow = new MantleLayer1GasFeeFlow(); + + await expect(flow.getLayer1Fee(request)).rejects.toThrow( + 'Failed to get oracle layer 1 gas fee', + ); + }); + + it('throws if tokenRatio call returns empty result', async () => { + tokenRatioMock.mockResolvedValue('0x'); jest .spyOn(TransactionFactory, 'fromTxData') @@ -362,9 +409,7 @@ describe('MantleLayer1GasFeeFlow', () => { }, }; - contractGetOperatorFeeMock.mockResolvedValueOnce( - bnFromHex(OPERATOR_FEE_MOCK), - ); + getOperatorFeeMock.mockResolvedValueOnce(OPERATOR_FEE_MOCK); jest .spyOn(TransactionFactory, 'fromTxData') @@ -382,8 +427,8 @@ describe('MantleLayer1GasFeeFlow', () => { bnFromHex(OPERATOR_FEE_MOCK), ); - expect(contractTokenRatioMock).toHaveBeenCalledTimes(1); - expect(contractGetOperatorFeeMock).toHaveBeenCalledTimes(1); + expect(tokenRatioMock).toHaveBeenCalledTimes(1); + expect(getOperatorFeeMock).toHaveBeenCalledTimes(1); expect(response).toStrictEqual({ layer1Fee: add0x(padHexToEvenLength(expectedTotal.toString(16))), }); diff --git a/packages/transaction-controller/src/gas-flows/MantleLayer1GasFeeFlow.ts b/packages/transaction-controller/src/gas-flows/MantleLayer1GasFeeFlow.ts index faae3cf5094..17ebaeb2301 100644 --- a/packages/transaction-controller/src/gas-flows/MantleLayer1GasFeeFlow.ts +++ b/packages/transaction-controller/src/gas-flows/MantleLayer1GasFeeFlow.ts @@ -1,12 +1,11 @@ -import { Contract } from '@ethersproject/contracts'; -import { Web3Provider } from '@ethersproject/providers'; -import type { ExternalProvider } from '@ethersproject/providers'; +import { Interface } from '@ethersproject/abi'; import type { Hex } from '@metamask/utils'; import type BN from 'bn.js'; import { CHAIN_IDS } from '../constants'; import type { TransactionControllerMessenger } from '../TransactionController'; import type { Layer1GasFeeFlowRequest, TransactionMeta } from '../types'; +import { rpcRequest } from '../utils/provider'; import { toBN } from '../utils/utils'; import { OracleLayer1GasFeeFlow } from './OracleLayer1GasFeeFlow'; @@ -28,6 +27,8 @@ const TOKEN_RATIO_ABI = [ }, ]; +const TOKEN_RATIO_INTERFACE = new Interface(TOKEN_RATIO_ABI); + /** * Mantle layer 1 gas fee flow. * @@ -60,18 +61,31 @@ export class MantleLayer1GasFeeFlow extends OracleLayer1GasFeeFlow { oracleFee: BN, request: Layer1GasFeeFlowRequest, ): Promise { - const { provider, transactionMeta } = request; - const oracleAddress = this.getOracleAddressForChain( - transactionMeta.chainId, - ); + const { messenger, transactionMeta } = request; + const { chainId, networkClientId } = transactionMeta; - const contract = new Contract( - oracleAddress, - TOKEN_RATIO_ABI, - new Web3Provider(provider as unknown as ExternalProvider), - ); + const to = this.getOracleAddressForChain(chainId); + const data = TOKEN_RATIO_INTERFACE.encodeFunctionData( + 'tokenRatio', + [], + ) as Hex; + + // Direct `eth_call` RPC request rather than an ethers `Contract` with + // `Web3Provider`, whose `setTimeout`-based dispatch never fires on React + // Native when the timer pump is starved (e.g. iOS display link freeze). + // See https://github.com/MetaMask/metamask-mobile/issues/32863 + const result = await rpcRequest({ + messenger, + networkClientId, + method: 'eth_call', + params: [{ to, data }, 'latest'], + }); + + if (typeof result !== 'string' || result === '0x') { + throw new Error('No value returned from token ratio contract'); + } - const tokenRatio = toBN(await contract.tokenRatio()); + const tokenRatio = toBN(result); return oracleFee.mul(tokenRatio); } } diff --git a/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.test.ts b/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.test.ts index 53300918f4a..62f3f31951b 100644 --- a/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.test.ts +++ b/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.test.ts @@ -1,6 +1,6 @@ import type { TypedTransaction } from '@ethereumjs/tx'; import { TransactionFactory } from '@ethereumjs/tx'; -import { Contract } from '@ethersproject/contracts'; +import { Interface } from '@ethersproject/abi'; import type { Provider } from '@metamask/network-controller'; import { add0x } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; @@ -10,18 +10,22 @@ import { CHAIN_IDS } from '../constants'; import type { TransactionControllerMessenger } from '../TransactionController'; import { TransactionStatus } from '../types'; import type { Layer1GasFeeFlowRequest, TransactionMeta } from '../types'; +import { rpcRequest } from '../utils/provider'; import { bnFromHex, padHexToEvenLength } from '../utils/utils'; import { OracleLayer1GasFeeFlow } from './OracleLayer1GasFeeFlow'; -jest.mock('@ethersproject/contracts', () => ({ - Contract: jest.fn(), -})); +jest.mock('../utils/provider'); jest.mock('../utils/layer1-gas-fee-flow', () => ({ buildUnserializedTransaction: jest.fn(), })); -jest.mock('@ethersproject/providers'); +const ORACLE_INTERFACE = new Interface([ + 'function getL1Fee(bytes _data)', + 'function getOperatorFee(uint256 _gasUsed)', +]); + +const GET_L1_FEE_SELECTOR = ORACLE_INTERFACE.getSighash('getL1Fee'); const TRANSACTION_PARAMS_MOCK = { from: '0x123', @@ -37,6 +41,8 @@ const TRANSACTION_META_MOCK: TransactionMeta = { txParams: TRANSACTION_PARAMS_MOCK, }; +const MESSENGER_MOCK = {} as TransactionControllerMessenger; + const SERIALIZED_TRANSACTION_MOCK = '0x1234'; const ORACLE_ADDRESS_MOCK = '0x5678' as Hex; const LAYER_1_FEE_MOCK = '0x09abcd'; @@ -63,6 +69,19 @@ function createMockTypedTransaction( return instance as unknown as jest.Mocked; } +/** + * Extracts the call object from a mocked rpcRequest invocation. + * + * @param call - The arguments of a single rpcRequest call. + * @returns The `eth_call` transaction object. + */ +function getEthCallObject(call: Parameters): { + to: Hex; + data: Hex; +} { + return (call[0].params as [{ to: Hex; data: Hex }, string])[0]; +} + class MockOracleLayer1GasFeeFlow extends OracleLayer1GasFeeFlow { readonly #sign: boolean; @@ -103,34 +122,39 @@ class DefaultOracleLayer1GasFeeFlow extends OracleLayer1GasFeeFlow { } describe('OracleLayer1GasFeeFlow', () => { - const contractMock = jest.mocked(Contract); - const contractGetL1FeeMock: jest.MockedFn<() => Promise> = jest.fn(); - const contractGetOperatorFeeMock: jest.MockedFn<() => Promise> = - jest.fn(); + const rpcRequestMock = jest.mocked(rpcRequest); + const getL1FeeMock: jest.MockedFn<() => Promise> = jest.fn(); + const getOperatorFeeMock: jest.MockedFn<() => Promise> = jest.fn(); let request: Layer1GasFeeFlowRequest; beforeEach(() => { request = { + messenger: MESSENGER_MOCK, provider: {} as Provider, transactionMeta: TRANSACTION_META_MOCK, }; - contractMock.mockClear(); - contractGetL1FeeMock.mockClear(); - contractGetOperatorFeeMock.mockClear(); + rpcRequestMock.mockClear(); + getL1FeeMock.mockClear(); + getOperatorFeeMock.mockClear(); + + getL1FeeMock.mockResolvedValue(LAYER_1_FEE_MOCK); + getOperatorFeeMock.mockResolvedValue('0x0'); - contractGetL1FeeMock.mockResolvedValue(bnFromHex(LAYER_1_FEE_MOCK)); - contractGetOperatorFeeMock.mockResolvedValue(new BN(0)); + rpcRequestMock.mockImplementation(async (args) => { + const { data } = getEthCallObject([args]); + + if (data.startsWith(GET_L1_FEE_SELECTOR)) { + return await getL1FeeMock(); + } - contractMock.mockReturnValue({ - getL1Fee: contractGetL1FeeMock, - getOperatorFee: contractGetOperatorFeeMock, - } as unknown as Contract); + return await getOperatorFeeMock(); + }); }); describe('getLayer1GasFee', () => { - it('returns value from smart contract call', async () => { + it('returns value from oracle eth_call', async () => { const serializedTransactionMock = Buffer.from( SERIALIZED_TRANSACTION_MOCK, 'hex', @@ -159,11 +183,22 @@ describe('OracleLayer1GasFeeFlow', () => { expect.anything(), ); - expect(contractGetL1FeeMock).toHaveBeenCalledTimes(1); - expect(contractGetL1FeeMock).toHaveBeenCalledWith( - serializedTransactionMock, - ); - expect(contractGetOperatorFeeMock).not.toHaveBeenCalled(); + expect(rpcRequestMock).toHaveBeenCalledTimes(1); + expect(rpcRequestMock).toHaveBeenCalledWith({ + messenger: MESSENGER_MOCK, + networkClientId: TRANSACTION_META_MOCK.networkClientId, + method: 'eth_call', + params: [ + { + to: ORACLE_ADDRESS_MOCK, + data: ORACLE_INTERFACE.encodeFunctionData('getL1Fee', [ + serializedTransactionMock, + ]), + }, + 'latest', + ], + }); + expect(getOperatorFeeMock).not.toHaveBeenCalled(); }); it('signs transaction with dummy key if supported by flow', async () => { @@ -188,12 +223,12 @@ describe('OracleLayer1GasFeeFlow', () => { }); expect(typedTransactionMock.sign).toHaveBeenCalledTimes(1); - expect(contractGetOperatorFeeMock).not.toHaveBeenCalled(); + expect(getOperatorFeeMock).not.toHaveBeenCalled(); }); describe('throws', () => { it('if getL1Fee fails', async () => { - contractGetL1FeeMock.mockRejectedValue(new Error('error')); + getL1FeeMock.mockRejectedValue(new Error('error')); const flow = new MockOracleLayer1GasFeeFlow(false); @@ -203,9 +238,17 @@ describe('OracleLayer1GasFeeFlow', () => { }); it('if getL1Fee returns undefined', async () => { - contractGetL1FeeMock.mockResolvedValue( - undefined as unknown as ReturnType, + getL1FeeMock.mockResolvedValue(undefined); + + const flow = new MockOracleLayer1GasFeeFlow(false); + + await expect(flow.getLayer1Fee(request)).rejects.toThrow( + 'Failed to get oracle layer 1 gas fee', ); + }); + + it('if getL1Fee returns empty result', async () => { + getL1FeeMock.mockResolvedValue('0x'); const flow = new MockOracleLayer1GasFeeFlow(false); @@ -232,9 +275,9 @@ describe('OracleLayer1GasFeeFlow', () => { const flow = new DefaultOracleLayer1GasFeeFlow(); await flow.getLayer1Fee(request); - expect(contractMock).toHaveBeenCalledTimes(1); - const [oracleAddress] = contractMock.mock.calls[0]; - expect(oracleAddress).toBe(DEFAULT_GAS_PRICE_ORACLE_ADDRESS); + expect(rpcRequestMock).toHaveBeenCalledTimes(1); + const { to } = getEthCallObject(rpcRequestMock.mock.calls[0]); + expect(to).toBe(DEFAULT_GAS_PRICE_ORACLE_ADDRESS); expect(typedTransactionMock.sign).not.toHaveBeenCalled(); }); @@ -248,15 +291,26 @@ describe('OracleLayer1GasFeeFlow', () => { }, }; - contractGetOperatorFeeMock.mockResolvedValueOnce( - bnFromHex(OPERATOR_FEE_MOCK), - ); + getOperatorFeeMock.mockResolvedValueOnce(OPERATOR_FEE_MOCK); const flow = new MockOracleLayer1GasFeeFlow(false); const response = await flow.getLayer1Fee(request); - expect(contractGetOperatorFeeMock).toHaveBeenCalledTimes(1); - expect(contractGetOperatorFeeMock).toHaveBeenCalledWith(gasUsed); + expect(getOperatorFeeMock).toHaveBeenCalledTimes(1); + expect(rpcRequestMock).toHaveBeenCalledWith({ + messenger: MESSENGER_MOCK, + networkClientId: TRANSACTION_META_MOCK.networkClientId, + method: 'eth_call', + params: [ + { + to: ORACLE_ADDRESS_MOCK, + data: ORACLE_INTERFACE.encodeFunctionData('getOperatorFee', [ + gasUsed, + ]), + }, + 'latest', + ], + }); expect(response).toStrictEqual({ layer1Fee: add0x( padHexToEvenLength( @@ -278,12 +332,12 @@ describe('OracleLayer1GasFeeFlow', () => { }, }; - contractGetOperatorFeeMock.mockRejectedValueOnce(new Error('revert')); + getOperatorFeeMock.mockRejectedValueOnce(new Error('revert')); const flow = new MockOracleLayer1GasFeeFlow(false); const response = await flow.getLayer1Fee(request); - expect(contractGetOperatorFeeMock).toHaveBeenCalledTimes(1); + expect(getOperatorFeeMock).toHaveBeenCalledTimes(1); expect(response).toStrictEqual({ layer1Fee: LAYER_1_FEE_MOCK, }); @@ -299,14 +353,12 @@ describe('OracleLayer1GasFeeFlow', () => { }, }; - contractGetOperatorFeeMock.mockResolvedValueOnce( - undefined as unknown as BN, - ); + getOperatorFeeMock.mockResolvedValueOnce(undefined); const flow = new MockOracleLayer1GasFeeFlow(false); const response = await flow.getLayer1Fee(request); - expect(contractGetOperatorFeeMock).toHaveBeenCalledTimes(1); + expect(getOperatorFeeMock).toHaveBeenCalledTimes(1); expect(response).toStrictEqual({ layer1Fee: LAYER_1_FEE_MOCK, }); @@ -323,9 +375,7 @@ describe('OracleLayer1GasFeeFlow', () => { }; const multiplier = new BN(2); - contractGetOperatorFeeMock.mockResolvedValueOnce( - bnFromHex(OPERATOR_FEE_MOCK), - ); + getOperatorFeeMock.mockResolvedValueOnce(OPERATOR_FEE_MOCK); jest .spyOn(TransactionFactory, 'fromTxData') diff --git a/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.ts b/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.ts index e76586b2a7b..cc9d75e7ee3 100644 --- a/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.ts +++ b/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.ts @@ -1,6 +1,4 @@ -import { Contract } from '@ethersproject/contracts'; -import { Web3Provider } from '@ethersproject/providers'; -import type { ExternalProvider } from '@ethersproject/providers'; +import { Interface } from '@ethersproject/abi'; import type { Hex } from '@metamask/utils'; import { add0x, createModuleLogger } from '@metamask/utils'; import BN from 'bn.js'; @@ -14,6 +12,7 @@ import type { TransactionMeta, } from '../types'; import { prepareTransaction } from '../utils/prepare'; +import { rpcRequest } from '../utils/provider'; import { padHexToEvenLength, toBN } from '../utils/utils'; const log = createModuleLogger(projectLogger, 'oracle-layer1-gas-fee-flow'); @@ -69,6 +68,8 @@ const GAS_PRICE_ORACLE_ABI = [ const DEFAULT_GAS_PRICE_ORACLE_ADDRESS = '0x420000000000000000000000000000000000000F' as Hex; +const GAS_PRICE_ORACLE_INTERFACE = new Interface(GAS_PRICE_ORACLE_ABI); + /** * Layer 1 gas fee flow that obtains gas fee estimate using an oracle smart contract. */ @@ -128,22 +129,9 @@ export abstract class OracleLayer1GasFeeFlow implements Layer1GasFeeFlow { request: Layer1GasFeeFlowRequest, ): Promise { try { - const { provider, transactionMeta } = request; - - const contract = this.#getGasPriceOracleContract( - provider, - transactionMeta.chainId, - ); - - const oracleFee = await this.#getOracleLayer1GasFee( - contract, - transactionMeta, - ); + const oracleFee = await this.#getOracleLayer1GasFee(request); const transformedFee = await this.transformOracleFee(oracleFee, request); - const operatorFee = await this.#getOperatorLayer1GasFee( - contract, - transactionMeta, - ); + const operatorFee = await this.#getOperatorLayer1GasFee(request); return { layer1Fee: add0x( @@ -156,18 +144,17 @@ export abstract class OracleLayer1GasFeeFlow implements Layer1GasFeeFlow { } } - async #getOracleLayer1GasFee( - contract: Contract, - transactionMeta: TransactionMeta, - ): Promise { + async #getOracleLayer1GasFee(request: Layer1GasFeeFlowRequest): Promise { const serializedTransaction = this.#buildUnserializedTransaction( - transactionMeta, + request.transactionMeta, this.shouldSignTransaction(), ).serialize(); - const result = await contract.getL1Fee(serializedTransaction); + const result = await this.#callGasPriceOracle(request, 'getL1Fee', [ + serializedTransaction, + ]); - if (result === undefined) { + if (typeof result !== 'string' || result === '0x') { throw new Error('No value returned from oracle contract'); } @@ -190,19 +177,20 @@ export abstract class OracleLayer1GasFeeFlow implements Layer1GasFeeFlow { } async #getOperatorLayer1GasFee( - contract: Contract, - transactionMeta: TransactionMeta, + request: Layer1GasFeeFlowRequest, ): Promise { - const gas = this.getOperatorFeeGas(transactionMeta); + const gas = this.getOperatorFeeGas(request.transactionMeta); if (!gas) { return ZERO; } try { - const result = await contract.getOperatorFee(gas); + const result = await this.#callGasPriceOracle(request, 'getOperatorFee', [ + gas, + ]); - if (result === undefined) { + if (typeof result !== 'string' || result === '0x') { return ZERO; } @@ -241,15 +229,31 @@ export abstract class OracleLayer1GasFeeFlow implements Layer1GasFeeFlow { }; } - #getGasPriceOracleContract( - provider: Layer1GasFeeFlowRequest['provider'], - chainId: Hex, - ): Contract { - return new Contract( - this.getOracleAddressForChain(chainId), - GAS_PRICE_ORACLE_ABI, - // Network controller provider type is incompatible with ethers provider - new Web3Provider(provider as unknown as ExternalProvider), - ); + // The oracle is called via a direct `eth_call` RPC request rather than an + // ethers `Contract` with `Web3Provider`, as `Web3Provider` schedules its + // JSON-RPC dispatch with `setTimeout`, which never fires on React Native + // when the timer pump is starved (e.g. iOS display link freeze), blocking + // `addTransaction` indefinitely. + // See https://github.com/MetaMask/metamask-mobile/issues/32863 + async #callGasPriceOracle( + request: Layer1GasFeeFlowRequest, + functionName: string, + args: readonly unknown[], + ): Promise { + const { messenger, transactionMeta } = request; + const { chainId, networkClientId } = transactionMeta; + + const to = this.getOracleAddressForChain(chainId); + const data = GAS_PRICE_ORACLE_INTERFACE.encodeFunctionData( + functionName, + args, + ) as Hex; + + return await rpcRequest({ + messenger, + networkClientId, + method: 'eth_call', + params: [{ to, data }, 'latest'], + }); } } diff --git a/packages/transaction-controller/src/types.ts b/packages/transaction-controller/src/types.ts index 7fa97cdcc32..c1a3aec1929 100644 --- a/packages/transaction-controller/src/types.ts +++ b/packages/transaction-controller/src/types.ts @@ -1527,6 +1527,9 @@ export type GasFeeFlow = { /** Request to a layer 1 gas fee flow to obtain layer 1 fee estimate. */ export type Layer1GasFeeFlowRequest = { + /** The messenger instance. */ + messenger: TransactionControllerMessenger; + /** RPC Provider instance. */ provider: Provider; 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 87ececc2424..3fe35b9fcb3 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 @@ -98,6 +98,7 @@ describe('updateTransactionLayer1GasFee', () => { }); expect(matchingLayer1GasFeeFlow.getLayer1Fee).toHaveBeenCalledWith({ + messenger: messengerMock, provider: providerMock, transactionMeta: transactionMetaMock, }); @@ -123,6 +124,7 @@ describe('updateTransactionLayer1GasFee', () => { }); expect(matchingLayer1GasFeeFlow.getLayer1Fee).toHaveBeenCalledWith({ + 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 f1deca85048..0705965de8a 100644 --- a/packages/transaction-controller/src/utils/layer1-gas-fee-flow.ts +++ b/packages/transaction-controller/src/utils/layer1-gas-fee-flow.ts @@ -102,6 +102,7 @@ export async function getTransactionLayer1GasFee({ }); const { layer1Fee } = await layer1GasFeeFlow.getLayer1Fee({ + messenger, provider, transactionMeta, }); From e6af65013f39455696ad0bdaae47625a43f859c1 Mon Sep 17 00:00:00 2001 From: cryptotavares Date: Tue, 14 Jul 2026 17:14:41 +0100 Subject: [PATCH 2/5] chore: update changelog PR links to #9505 --- packages/transaction-controller/CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 463ddad1593..26545f631b5 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -9,8 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Add `messenger` property to `Layer1GasFeeFlowRequest` ([#9502](https://github.com/MetaMask/core/pull/9502)) -- Query layer 1 gas fee oracles via direct `eth_call` RPC requests instead of an ethers `Contract` backed by `Web3Provider` ([#9502](https://github.com/MetaMask/core/pull/9502)) +- Add `messenger` property to `Layer1GasFeeFlowRequest` ([#9505](https://github.com/MetaMask/core/pull/9505)) +- Query layer 1 gas fee oracles via direct `eth_call` RPC requests instead of an ethers `Contract` backed by `Web3Provider` ([#9505](https://github.com/MetaMask/core/pull/9505)) - `Web3Provider` schedules its JSON-RPC dispatch with `setTimeout`, which never fires on React Native iOS when the timer pump is starved, blocking `addTransaction` indefinitely and preventing dapp confirmations from appearing ([MetaMask/metamask-mobile#32863](https://github.com/MetaMask/metamask-mobile/issues/32863)) ## [69.0.0] From 0c34216cbe6bf39a2b57b02649db38ada2ab6bcd Mon Sep 17 00:00:00 2001 From: cryptotavares Date: Tue, 14 Jul 2026 17:31:21 +0100 Subject: [PATCH 3/5] fix: remove @ethersproject/providers dep from transactionController --- packages/transaction-controller/package.json | 1 - yarn.lock | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/transaction-controller/package.json b/packages/transaction-controller/package.json index 0a4cb157ac7..3ecb7a22381 100644 --- a/packages/transaction-controller/package.json +++ b/packages/transaction-controller/package.json @@ -58,7 +58,6 @@ "@ethereumjs/util": "^9.1.0", "@ethersproject/abi": "^5.7.0", "@ethersproject/contracts": "^5.7.0", - "@ethersproject/providers": "^5.7.0", "@ethersproject/wallet": "^5.7.0", "@metamask/accounts-controller": "^39.0.5", "@metamask/approval-controller": "^9.0.2", diff --git a/yarn.lock b/yarn.lock index a54131389c2..777ba195ac1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8879,7 +8879,6 @@ __metadata: "@ethereumjs/util": "npm:^9.1.0" "@ethersproject/abi": "npm:^5.7.0" "@ethersproject/contracts": "npm:^5.7.0" - "@ethersproject/providers": "npm:^5.7.0" "@ethersproject/wallet": "npm:^5.7.0" "@metamask/accounts-controller": "npm:^39.0.5" "@metamask/approval-controller": "npm:^9.0.2" From 61bc12435236990efcc4ffad8396dab72dc4937c Mon Sep 17 00:00:00 2001 From: cryptotavares Date: Wed, 15 Jul 2026 11:24:55 +0100 Subject: [PATCH 4/5] chore: remove unused provider arg --- .../src/gas-flows/OracleLayer1GasFeeFlow.ts | 2 +- packages/transaction-controller/src/types.ts | 5 +---- .../src/utils/layer1-gas-fee-flow.test.ts | 21 ------------------- .../src/utils/layer1-gas-fee-flow.ts | 7 ------- 4 files changed, 2 insertions(+), 33 deletions(-) diff --git a/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.ts b/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.ts index cc9d75e7ee3..318b3f3e4a4 100644 --- a/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.ts +++ b/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.ts @@ -115,7 +115,7 @@ export abstract class OracleLayer1GasFeeFlow implements Layer1GasFeeFlow { * Defaults to returning the fee unchanged. * * @param oracleFee - The raw L1 fee returned by the oracle contract. - * @param _request - The original fee flow request (provider + transaction). + * @param _request - The original fee flow request (messenger + transaction). * @returns The transformed fee. */ protected async transformOracleFee( diff --git a/packages/transaction-controller/src/types.ts b/packages/transaction-controller/src/types.ts index c1a3aec1929..35aac199021 100644 --- a/packages/transaction-controller/src/types.ts +++ b/packages/transaction-controller/src/types.ts @@ -3,7 +3,7 @@ import type { AccessList } from '@ethereumjs/tx'; import type { AccountsController } from '@metamask/accounts-controller'; import type { GasFeeState } from '@metamask/gas-fee-controller'; -import type { NetworkClientId, Provider } from '@metamask/network-controller'; +import type { NetworkClientId } from '@metamask/network-controller'; import type { Hex, Json } from '@metamask/utils'; import type { Operation } from 'fast-json-patch'; @@ -1530,9 +1530,6 @@ export type Layer1GasFeeFlowRequest = { /** The messenger instance. */ messenger: TransactionControllerMessenger; - /** RPC Provider instance. */ - provider: Provider; - /** The metadata of the transaction to obtain estimates for. */ transactionMeta: TransactionMeta; }; 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 3fe35b9fcb3..34135610164 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 @@ -1,22 +1,15 @@ -import type { Provider } from '@metamask/network-controller'; import type { Hex } from '@metamask/utils'; import type { TransactionControllerMessenger } from '../TransactionController'; import { TransactionStatus } from '../types'; import type { Layer1GasFeeFlow, TransactionMeta } from '../types'; import { updateTransactionLayer1GasFee } from './layer1-gas-fee-flow'; -import { getProvider } from './provider'; jest.mock('@metamask/controller-utils', () => ({ ...jest.requireActual('@metamask/controller-utils'), query: jest.fn(), })); -jest.mock('./provider', () => ({ - ...jest.requireActual('./provider'), - getProvider: jest.fn(), -})); - const LAYER1_GAS_FEE_VALUE_MATCH_MOCK: Hex = '0x1'; const LAYER1_GAS_FEE_VALUE_UNMATCH_MOCK: Hex = '0x2'; @@ -42,9 +35,7 @@ function createLayer1GasFeeFlowMock({ } describe('updateTransactionLayer1GasFee', () => { - const getProviderMock = jest.mocked(getProvider); let layer1GasFeeFlowsMock: jest.Mocked; - let providerMock: Provider; let transactionMetaMock: TransactionMeta; let messengerMock: TransactionControllerMessenger; @@ -62,8 +53,6 @@ describe('updateTransactionLayer1GasFee', () => { }), ]; - providerMock = {} as Provider; - transactionMetaMock = { id: '1', chainId: '0x123', @@ -76,8 +65,6 @@ describe('updateTransactionLayer1GasFee', () => { }; messengerMock = {} as TransactionControllerMessenger; - - getProviderMock.mockReturnValue(providerMock); }); it('updates given transaction layer1GasFee property', async () => { @@ -92,14 +79,8 @@ describe('updateTransactionLayer1GasFee', () => { expect(unmatchingLayer1GasFeeFlow.getLayer1Fee).not.toHaveBeenCalled(); - expect(getProviderMock).toHaveBeenCalledWith({ - messenger: messengerMock, - networkClientId: transactionMetaMock.networkClientId, - }); - expect(matchingLayer1GasFeeFlow.getLayer1Fee).toHaveBeenCalledWith({ messenger: messengerMock, - provider: providerMock, transactionMeta: transactionMetaMock, }); @@ -125,7 +106,6 @@ describe('updateTransactionLayer1GasFee', () => { expect(matchingLayer1GasFeeFlow.getLayer1Fee).toHaveBeenCalledWith({ messenger: messengerMock, - provider: providerMock, transactionMeta: transactionMetaMock, }); expect(transactionMetaMock.layer1GasFee).toBeUndefined(); @@ -144,7 +124,6 @@ describe('updateTransactionLayer1GasFee', () => { transactionMeta: transactionMetaMock, }); - expect(getProviderMock).not.toHaveBeenCalled(); expect(unmatchingLayer1GasFeeFlow.getLayer1Fee).not.toHaveBeenCalled(); }); }); 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 0705965de8a..91814854e4d 100644 --- a/packages/transaction-controller/src/utils/layer1-gas-fee-flow.ts +++ b/packages/transaction-controller/src/utils/layer1-gas-fee-flow.ts @@ -4,7 +4,6 @@ import type { Hex } from '@metamask/utils'; import { projectLogger } from '../logger'; import type { TransactionControllerMessenger } from '../TransactionController'; import type { Layer1GasFeeFlow, TransactionMeta } from '../types'; -import { getProvider } from './provider'; const log = createModuleLogger(projectLogger, 'layer-1-gas-fee-flow'); @@ -96,14 +95,8 @@ export async function getTransactionLayer1GasFee({ ); try { - const provider = getProvider({ - messenger, - networkClientId: transactionMeta.networkClientId, - }); - const { layer1Fee } = await layer1GasFeeFlow.getLayer1Fee({ messenger, - provider, transactionMeta, }); return layer1Fee; From f191b5fc426e46a97ea64ce69accfdd3622e42a8 Mon Sep 17 00:00:00 2001 From: cryptotavares Date: Wed, 15 Jul 2026 11:26:40 +0100 Subject: [PATCH 5/5] chore: update changelog --- packages/transaction-controller/CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 26545f631b5..d6e24883e52 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -9,7 +9,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Add `messenger` property to `Layer1GasFeeFlowRequest` ([#9505](https://github.com/MetaMask/core/pull/9505)) - Query layer 1 gas fee oracles via direct `eth_call` RPC requests instead of an ethers `Contract` backed by `Web3Provider` ([#9505](https://github.com/MetaMask/core/pull/9505)) - `Web3Provider` schedules its JSON-RPC dispatch with `setTimeout`, which never fires on React Native iOS when the timer pump is starved, blocking `addTransaction` indefinitely and preventing dapp confirmations from appearing ([MetaMask/metamask-mobile#32863](https://github.com/MetaMask/metamask-mobile/issues/32863))