diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 680c96c5d0c..3ac2c74b2f2 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 ([#8376](https://github.com/MetaMask/core/pull/8376)) + ### 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'); + } + } +}