diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 680c96c5d0c..fc5fcfbcf4c 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, supporting both mainnet (chain ID `0x1388`) and Sepolia testnet (chain ID `0x138b`) ([#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..ab44317e6d3 100644 --- a/packages/transaction-controller/src/constants.ts +++ b/packages/transaction-controller/src/constants.ts @@ -33,6 +33,8 @@ export const CHAIN_IDS = { SCROLL_SEPOLIA: '0x8274f', MEGAETH_TESTNET: '0x18c6', SEI: '0x531', + MANTLE: '0x1388', + MANTLE_SEPOLIA: '0x138b', } 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..e91062ccdc4 --- /dev/null +++ b/packages/transaction-controller/src/gas-flows/MantleLayer1GasFeeFlow.test.ts @@ -0,0 +1,314 @@ +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 TRANSACTION_META_TESTNET_MOCK: TransactionMeta = { + id: '2', + chainId: CHAIN_IDS.MANTLE_SEPOLIA, + 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); + + // 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); + }); + + 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 true if chain ID is Mantle Sepolia', async () => { + const flow = new MantleLayer1GasFeeFlow(); + + expect( + await flow.matchesTransaction({ + transactionMeta: TRANSACTION_META_TESTNET_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 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 oracle layer 1 gas fee', + ); + }); + + it('uses default OP Stack oracle address for mainnet', () => { + class TestableMantleLayer1GasFeeFlow extends MantleLayer1GasFeeFlow { + exposeOracleAddress(chainId: Hex): Hex { + return super.getOracleAddressForChain(chainId); + } + } + + const flow = new TestableMantleLayer1GasFeeFlow(); + expect(flow.exposeOracleAddress(CHAIN_IDS.MANTLE)).toBe( + '0x420000000000000000000000000000000000000F', + ); + }); + + it('uses default OP Stack oracle address for Mantle Sepolia', () => { + class TestableMantleLayer1GasFeeFlow extends MantleLayer1GasFeeFlow { + exposeOracleAddress(chainId: Hex): Hex { + return super.getOracleAddressForChain(chainId); + } + } + + const flow = new TestableMantleLayer1GasFeeFlow(); + expect(flow.exposeOracleAddress(CHAIN_IDS.MANTLE_SEPOLIA)).toBe( + '0x420000000000000000000000000000000000000F', + ); + }); + + it('computes correct fee for Mantle Sepolia transactions', async () => { + const gasUsed = '0x5208'; + request = { + ...request, + transactionMeta: { + ...TRANSACTION_META_TESTNET_MOCK, + 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))), + }); + }); + }); +}); 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..13eba6fddfe --- /dev/null +++ b/packages/transaction-controller/src/gas-flows/MantleLayer1GasFeeFlow.ts @@ -0,0 +1,67 @@ +import { Contract } from '@ethersproject/contracts'; +import { Web3Provider } from '@ethersproject/providers'; +import type { ExternalProvider } from '@ethersproject/providers'; +import type { Hex } from '@metamask/utils'; +import type BN from 'bn.js'; + +import { OracleLayer1GasFeeFlow } from './OracleLayer1GasFeeFlow'; +import { CHAIN_IDS } from '../constants'; +import type { TransactionControllerMessenger } from '../TransactionController'; +import type { Layer1GasFeeFlowRequest, TransactionMeta } from '../types'; +import { toBN } from '../utils/utils'; + +const MANTLE_CHAIN_IDS: Hex[] = [CHAIN_IDS.MANTLE, CHAIN_IDS.MANTLE_SEPOLIA]; + +const TOKEN_RATIO_ABI = [ + { + 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. + */ +export class MantleLayer1GasFeeFlow extends OracleLayer1GasFeeFlow { + async matchesTransaction({ + transactionMeta, + }: { + transactionMeta: TransactionMeta; + messenger: TransactionControllerMessenger; + }): Promise { + return MANTLE_CHAIN_IDS.includes(transactionMeta.chainId); + } + + protected override async transformOracleFee( + oracleFee: BN, + request: Layer1GasFeeFlowRequest, + ): Promise { + const { provider, transactionMeta } = request; + const oracleAddress = this.getOracleAddressForChain( + transactionMeta.chainId, + ); + + const contract = new Contract( + oracleAddress, + TOKEN_RATIO_ABI, + new Web3Provider(provider as unknown as ExternalProvider), + ); + + const tokenRatio = toBN(await contract.tokenRatio()); + 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 459ac325aaa..c59e708a7bc 100644 --- a/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.test.ts +++ b/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.test.ts @@ -311,5 +311,57 @@ describe('OracleLayer1GasFeeFlow', () => { layer1Fee: LAYER_1_FEE_MOCK, }); }); + + it('applies transformOracleFee before adding operator fee', async () => { + const gasUsed = '0x5208'; + request = { + ...request, + transactionMeta: { + ...request.transactionMeta, + gasUsed, + }, + }; + + const multiplier = new BN(2); + contractGetOperatorFeeMock.mockResolvedValueOnce( + bnFromHex(OPERATOR_FEE_MOCK), + ); + + jest + .spyOn(TransactionFactory, 'fromTxData') + .mockReturnValueOnce( + createMockTypedTransaction( + Buffer.from(SERIALIZED_TRANSACTION_MOCK, 'hex'), + ), + ); + + class TransformingFlow extends OracleLayer1GasFeeFlow { + async matchesTransaction(): Promise { + return true; + } + + protected override async transformOracleFee( + oracleFee: BN, + ): Promise { + return oracleFee.mul(multiplier); + } + + protected override getOracleAddressForChain(): Hex { + return ORACLE_ADDRESS_MOCK; + } + } + + const flow = new TransformingFlow(); + const response = await flow.getLayer1Fee(request); + + const expectedTransformed = bnFromHex(LAYER_1_FEE_MOCK).mul(multiplier); + const expectedTotal = expectedTransformed.add( + bnFromHex(OPERATOR_FEE_MOCK), + ); + + expect(response).toStrictEqual({ + layer1Fee: add0x(padHexToEvenLength(expectedTotal.toString(16))), + }); + }); }); }); diff --git a/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.ts b/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.ts index bd7b9a12606..21bcb2c14f5 100644 --- a/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.ts +++ b/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.ts @@ -106,6 +106,24 @@ export abstract class OracleLayer1GasFeeFlow implements Layer1GasFeeFlow { messenger: TransactionControllerMessenger; }): Promise; + /** + * Transforms the raw oracle L1 fee before it is combined with the operator + * fee. Subclasses can override this to apply chain-specific conversions + * (e.g. currency conversion via an on-chain exchange rate). + * + * 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). + * @returns The transformed fee. + */ + protected async transformOracleFee( + oracleFee: BN, + _request: Layer1GasFeeFlowRequest, + ): Promise { + return oracleFee; + } + async getLayer1Fee( request: Layer1GasFeeFlowRequest, ): Promise { @@ -121,6 +139,7 @@ export abstract class OracleLayer1GasFeeFlow implements Layer1GasFeeFlow { contract, transactionMeta, ); + const transformedFee = await this.transformOracleFee(oracleFee, request); const operatorFee = await this.#getOperatorLayer1GasFee( contract, transactionMeta, @@ -128,7 +147,7 @@ export abstract class OracleLayer1GasFeeFlow implements Layer1GasFeeFlow { return { layer1Fee: add0x( - padHexToEvenLength(oracleFee.add(operatorFee).toString(16)), + padHexToEvenLength(transformedFee.add(operatorFee).toString(16)), ), }; } catch (error) {