From bb4d8316668b4883f463643ceea65d78f76142b6 Mon Sep 17 00:00:00 2001 From: jeremy-consensys Date: Thu, 2 Apr 2026 18:00:13 +0700 Subject: [PATCH 1/2] feat: add Mantle layer 1 gas fee flow with tokenRatio conversion Mantle uses MNT as its native gas token, but the OP Stack oracle's getL1Fee returns values denominated in ETH. Add MantleLayer1GasFeeFlow that multiplies the L1 fee by tokenRatio (ETH/MNT exchange rate from the oracle contract) before adding the operator fee. - New MantleLayer1GasFeeFlow extending OracleLayer1GasFeeFlow - Registered first in #getLayer1GasFeeFlows() to match before Optimism - Added MANTLE chain ID (0x1388) to constants - 8 unit tests covering conversion, operator fee, error handling --- packages/transaction-controller/CHANGELOG.md | 4 + .../src/TransactionController.ts | 7 +- .../transaction-controller/src/constants.ts | 1 + .../gas-flows/MantleLayer1GasFeeFlow.test.ts | 259 ++++++++++++++++++ .../src/gas-flows/MantleLayer1GasFeeFlow.ts | 160 +++++++++++ 5 files changed, 430 insertions(+), 1 deletion(-) create mode 100644 packages/transaction-controller/src/gas-flows/MantleLayer1GasFeeFlow.test.ts create mode 100644 packages/transaction-controller/src/gas-flows/MantleLayer1GasFeeFlow.ts diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 680c96c5d0c..f8fffb21e44 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add Mantle layer 1 gas fee flow with tokenRatio conversion for accurate MNT-denominated gas estimates + ### Changed - Bump `@metamask/accounts-controller` from `^37.1.1` to `^37.2.0` ([#8363](https://github.com/MetaMask/core/pull/8363)) diff --git a/packages/transaction-controller/src/TransactionController.ts b/packages/transaction-controller/src/TransactionController.ts index b0bdd3bf222..bb2dced9c12 100644 --- a/packages/transaction-controller/src/TransactionController.ts +++ b/packages/transaction-controller/src/TransactionController.ts @@ -66,6 +66,7 @@ import { v1 as random } from 'uuid'; import { DefaultGasFeeFlow } from './gas-flows/DefaultGasFeeFlow'; import { LineaGasFeeFlow } from './gas-flows/LineaGasFeeFlow'; +import { MantleLayer1GasFeeFlow } from './gas-flows/MantleLayer1GasFeeFlow'; import { OptimismLayer1GasFeeFlow } from './gas-flows/OptimismLayer1GasFeeFlow'; import { RandomisedEstimationsGasFeeFlow } from './gas-flows/RandomisedEstimationsGasFeeFlow'; import { ScrollLayer1GasFeeFlow } from './gas-flows/ScrollLayer1GasFeeFlow'; @@ -4168,7 +4169,11 @@ export class TransactionController extends BaseController< } #getLayer1GasFeeFlows(): Layer1GasFeeFlow[] { - return [new OptimismLayer1GasFeeFlow(), new ScrollLayer1GasFeeFlow()]; + return [ + new MantleLayer1GasFeeFlow(), + new OptimismLayer1GasFeeFlow(), + new ScrollLayer1GasFeeFlow(), + ]; } #updateTransactionInternal( diff --git a/packages/transaction-controller/src/constants.ts b/packages/transaction-controller/src/constants.ts index e7dee47d896..69744d918f6 100644 --- a/packages/transaction-controller/src/constants.ts +++ b/packages/transaction-controller/src/constants.ts @@ -33,6 +33,7 @@ export const CHAIN_IDS = { SCROLL_SEPOLIA: '0x8274f', MEGAETH_TESTNET: '0x18c6', SEI: '0x531', + MANTLE: '0x1388', } as const; /** Extract of the Wrapped ERC-20 ABI required for simulation. */ diff --git a/packages/transaction-controller/src/gas-flows/MantleLayer1GasFeeFlow.test.ts b/packages/transaction-controller/src/gas-flows/MantleLayer1GasFeeFlow.test.ts new file mode 100644 index 00000000000..13a8831344b --- /dev/null +++ b/packages/transaction-controller/src/gas-flows/MantleLayer1GasFeeFlow.test.ts @@ -0,0 +1,259 @@ +import { TransactionFactory } from '@ethereumjs/tx'; +import type { TypedTransaction } from '@ethereumjs/tx'; +import { Contract } from '@ethersproject/contracts'; +import type { Provider } from '@metamask/network-controller'; +import { add0x } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; +import BN from 'bn.js'; + +import { MantleLayer1GasFeeFlow } from './MantleLayer1GasFeeFlow'; +import { CHAIN_IDS } from '../constants'; +import type { TransactionControllerMessenger } from '../TransactionController'; +import { TransactionStatus } from '../types'; +import type { Layer1GasFeeFlowRequest, TransactionMeta } from '../types'; +import { bnFromHex, padHexToEvenLength } from '../utils/utils'; + +jest.mock('@ethersproject/contracts', () => ({ + Contract: jest.fn(), +})); + +jest.mock('@ethersproject/providers'); + +const TRANSACTION_PARAMS_MOCK = { + from: '0x123', + gas: '0x1234', +}; + +const TRANSACTION_META_MOCK: TransactionMeta = { + id: '1', + chainId: CHAIN_IDS.MANTLE, + networkClientId: 'testNetworkClientId', + status: TransactionStatus.unapproved, + time: 0, + txParams: TRANSACTION_PARAMS_MOCK, +}; + +const SERIALIZED_TRANSACTION_MOCK = '0x1234'; +// L1 fee in ETH (returned by oracle) +const L1_FEE_MOCK = '0x0de0b6b3a7640000'; // 1e18 (1 ETH in wei) +// tokenRatio is a raw multiplier (e.g., 3020 means 1 ETH L1 fee = 3020 MNT) +const TOKEN_RATIO_MOCK = new BN('3020'); +const OPERATOR_FEE_MOCK = '0x2386f26fc10000'; // 0.01 ETH in wei + +/** + * Creates a mock TypedTransaction object. + * + * @param serializedBuffer - The buffer returned by the serialize method. + * @returns The mock TypedTransaction object. + */ +function createMockTypedTransaction( + serializedBuffer: Buffer, +): jest.Mocked { + const instance = { + serialize: (): Buffer => serializedBuffer, + sign: jest.fn(), + }; + + jest.spyOn(instance, 'sign').mockReturnValue(instance); + + return instance as unknown as jest.Mocked; +} + +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(); + + let request: Layer1GasFeeFlowRequest; + + beforeEach(() => { + request = { + 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); + + contractMock.mockReturnValue({ + getL1Fee: contractGetL1FeeMock, + getOperatorFee: contractGetOperatorFeeMock, + tokenRatio: contractTokenRatioMock, + } as unknown as Contract); + }); + + describe('matchesTransaction', () => { + const messenger = {} as TransactionControllerMessenger; + + it('returns true if chain ID is Mantle', async () => { + const flow = new MantleLayer1GasFeeFlow(); + + expect( + await flow.matchesTransaction({ + transactionMeta: TRANSACTION_META_MOCK, + messenger, + }), + ).toBe(true); + }); + + it('returns false if chain ID is not Mantle', async () => { + const flow = new MantleLayer1GasFeeFlow(); + + expect( + await flow.matchesTransaction({ + transactionMeta: { + ...TRANSACTION_META_MOCK, + chainId: CHAIN_IDS.MAINNET, + }, + messenger, + }), + ).toBe(false); + }); + }); + + describe('getLayer1Fee', () => { + it('multiplies L1 fee by tokenRatio before adding operator fee', async () => { + const gasUsed = '0x5208'; + request = { + ...request, + transactionMeta: { + ...request.transactionMeta, + gasUsed, + }, + }; + + contractGetOperatorFeeMock.mockResolvedValueOnce( + bnFromHex(OPERATOR_FEE_MOCK), + ); + + jest + .spyOn(TransactionFactory, 'fromTxData') + .mockReturnValueOnce( + createMockTypedTransaction( + Buffer.from(SERIALIZED_TRANSACTION_MOCK, 'hex'), + ), + ); + + const flow = new MantleLayer1GasFeeFlow(); + const response = await flow.getLayer1Fee(request); + + const expectedL1FeeInMnt = bnFromHex(L1_FEE_MOCK).mul(TOKEN_RATIO_MOCK); + const expectedTotal = expectedL1FeeInMnt.add( + bnFromHex(OPERATOR_FEE_MOCK), + ); + + expect(contractTokenRatioMock).toHaveBeenCalledTimes(1); + expect(contractGetOperatorFeeMock).toHaveBeenCalledTimes(1); + expect(response).toStrictEqual({ + layer1Fee: add0x(padHexToEvenLength(expectedTotal.toString(16))), + }); + }); + + it('returns converted L1 fee when no gasUsed (no operator fee)', async () => { + jest + .spyOn(TransactionFactory, 'fromTxData') + .mockReturnValueOnce( + createMockTypedTransaction( + Buffer.from(SERIALIZED_TRANSACTION_MOCK, 'hex'), + ), + ); + + const flow = new MantleLayer1GasFeeFlow(); + const response = await flow.getLayer1Fee(request); + + const expectedL1FeeInMnt = bnFromHex(L1_FEE_MOCK).mul(TOKEN_RATIO_MOCK); + + expect(contractGetOperatorFeeMock).not.toHaveBeenCalled(); + expect(response).toStrictEqual({ + layer1Fee: add0x(padHexToEvenLength(expectedL1FeeInMnt.toString(16))), + }); + }); + + it('defaults operator fee to zero when call fails', async () => { + const gasUsed = '0x5208'; + request = { + ...request, + transactionMeta: { + ...request.transactionMeta, + gasUsed, + }, + }; + + contractGetOperatorFeeMock.mockRejectedValueOnce(new Error('revert')); + + jest + .spyOn(TransactionFactory, 'fromTxData') + .mockReturnValueOnce( + createMockTypedTransaction( + Buffer.from(SERIALIZED_TRANSACTION_MOCK, 'hex'), + ), + ); + + const flow = new MantleLayer1GasFeeFlow(); + const response = await flow.getLayer1Fee(request); + + const expectedL1FeeInMnt = bnFromHex(L1_FEE_MOCK).mul(TOKEN_RATIO_MOCK); + + expect(response).toStrictEqual({ + layer1Fee: add0x(padHexToEvenLength(expectedL1FeeInMnt.toString(16))), + }); + }); + + it('throws if getL1Fee fails', async () => { + contractGetL1FeeMock.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 Mantle layer 1 gas fee', + ); + }); + + it('throws if tokenRatio call fails', async () => { + contractTokenRatioMock.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 Mantle layer 1 gas fee', + ); + }); + + it('uses default OP Stack oracle address', () => { + class TestableMantleLayer1GasFeeFlow extends MantleLayer1GasFeeFlow { + exposeOracleAddress(chainId: Hex): Hex { + return super.getOracleAddressForChain(chainId); + } + } + + const flow = new TestableMantleLayer1GasFeeFlow(); + expect(flow.exposeOracleAddress(CHAIN_IDS.MANTLE)).toBe( + '0x420000000000000000000000000000000000000F', + ); + }); + }); +}); diff --git a/packages/transaction-controller/src/gas-flows/MantleLayer1GasFeeFlow.ts b/packages/transaction-controller/src/gas-flows/MantleLayer1GasFeeFlow.ts new file mode 100644 index 00000000000..21da7c90379 --- /dev/null +++ b/packages/transaction-controller/src/gas-flows/MantleLayer1GasFeeFlow.ts @@ -0,0 +1,160 @@ +import { Contract } from '@ethersproject/contracts'; +import { Web3Provider } from '@ethersproject/providers'; +import type { ExternalProvider } from '@ethersproject/providers'; +import type { Hex } from '@metamask/utils'; +import { add0x, createModuleLogger } from '@metamask/utils'; +import BN from 'bn.js'; + +import { OracleLayer1GasFeeFlow } from './OracleLayer1GasFeeFlow'; +import { CHAIN_IDS } from '../constants'; +import { projectLogger } from '../logger'; +import type { TransactionControllerMessenger } from '../TransactionController'; +import type { + Layer1GasFeeFlowRequest, + Layer1GasFeeFlowResponse, + TransactionMeta, +} from '../types'; +import { prepareTransaction } from '../utils/prepare'; +import { padHexToEvenLength, toBN } from '../utils/utils'; + +const log = createModuleLogger(projectLogger, 'mantle-layer1-gas-fee-flow'); + +const MANTLE_CHAIN_IDS: Hex[] = [CHAIN_IDS.MANTLE]; + +const ZERO = new BN(0); + +// tokenRatio is a raw multiplier (ETH/MNT exchange rate as an integer). +// No decimal scaling needed — multiply L1 fee (ETH wei) by tokenRatio +// to get the equivalent fee in MNT wei. + +const MANTLE_GAS_PRICE_ORACLE_ABI = [ + { + inputs: [ + { + internalType: 'bytes', + name: '_data', + type: 'bytes', + }, + ], + name: 'getL1Fee', + outputs: [ + { + internalType: 'uint256', + name: '', + type: 'uint256', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + internalType: 'uint256', + name: '_gasUsed', + type: 'uint256', + }, + ], + name: 'getOperatorFee', + outputs: [ + { + internalType: 'uint256', + name: '', + type: 'uint256', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'tokenRatio', + outputs: [ + { + internalType: 'uint256', + name: '', + type: 'uint256', + }, + ], + stateMutability: 'view', + type: 'function', + }, +]; + +/** + * Mantle layer 1 gas fee flow. + * + * Mantle uses MNT as its native gas token, but the oracle's getL1Fee returns + * values denominated in ETH. This subclass multiplies the L1 fee by the + * tokenRatio (ETH/MNT exchange rate) from the oracle contract to convert + * the fee to MNT, then adds the operator fee (already in MNT). + */ +export class MantleLayer1GasFeeFlow extends OracleLayer1GasFeeFlow { + async matchesTransaction({ + transactionMeta, + }: { + transactionMeta: TransactionMeta; + messenger: TransactionControllerMessenger; + }): Promise { + return MANTLE_CHAIN_IDS.includes(transactionMeta.chainId); + } + + override async getLayer1Fee( + request: Layer1GasFeeFlowRequest, + ): Promise { + try { + const { provider, transactionMeta } = request; + const oracleAddress = this.getOracleAddressForChain( + transactionMeta.chainId, + ); + + const contract = new Contract( + oracleAddress, + MANTLE_GAS_PRICE_ORACLE_ABI, + new Web3Provider(provider as unknown as ExternalProvider), + ); + + // Get L1 fee (ETH-denominated) + const serializedTransaction = prepareTransaction( + transactionMeta.chainId, + { + ...transactionMeta.txParams, + gasLimit: transactionMeta.txParams.gas, + }, + ).serialize(); + + const l1FeeResult = await contract.getL1Fee(serializedTransaction); + if (l1FeeResult === undefined) { + throw new Error('No value returned from oracle contract'); + } + const l1Fee = toBN(l1FeeResult); + + // Convert L1 fee from ETH to MNT using tokenRatio (raw multiplier) + const tokenRatio = toBN(await contract.tokenRatio()); + const l1FeeInMnt = l1Fee.mul(tokenRatio); + + // Get operator fee (already in MNT) + let operatorFee = ZERO; + const { gasUsed } = transactionMeta; + if (gasUsed) { + try { + const operatorFeeResult = await contract.getOperatorFee(gasUsed); + if (operatorFeeResult !== undefined) { + operatorFee = toBN(operatorFeeResult); + } + } catch (error) { + log('Failed to get operator fee, defaulting to zero', error); + } + } + + const totalFee = l1FeeInMnt.add(operatorFee); + + return { + layer1Fee: add0x(padHexToEvenLength(totalFee.toString(16))), + }; + } catch (error) { + log('Failed to get Mantle layer 1 gas fee', error); + throw new Error('Failed to get Mantle layer 1 gas fee'); + } + } +} From ca120368bf68359f4398debff95b65e3fada3fc2 Mon Sep 17 00:00:00 2001 From: jeremy-consensys Date: Fri, 3 Apr 2026 16:16:08 +0700 Subject: [PATCH 2/2] docs: add PR link to changelog entry for Mantle gas fee flow --- packages/transaction-controller/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index f8fffb21e44..3ac2c74b2f2 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add Mantle layer 1 gas fee flow with tokenRatio conversion for accurate MNT-denominated gas estimates +- Add Mantle layer 1 gas fee flow with tokenRatio conversion for accurate MNT-denominated gas estimates ([#8376](https://github.com/MetaMask/core/pull/8376)) ### Changed