Skip to content

Commit 2311c16

Browse files
authored
feat: add mantle testnet gas fee (#8386)
## Explanation Add Mantle Sepolia testnet (chain ID `0x138b` / 5003) support to `MantleLayer1GasFeeFlow` for accurate MNT-denominated L1 gas fee estimates on the testnet. The Sepolia oracle lives at the same OP Stack predeploy address (`0x420...000F`) and exposes the same ABI (`getL1Fee`, `tokenRatio`, `getOperatorFee`). The testnet chain ID is added to the `MANTLE_CHAIN_IDS` array so the flow matches both mainnet and Sepolia. Verified on-chain: - `getL1Fee(bytes)` works on Sepolia - `tokenRatio()` returns ~3328 on Sepolia - `getOperatorFee(uint256)` works on Sepolia (Arsia upgrade is deployed on testnet) ### Changes | File | Change | |------|--------| | `constants.ts` | Add `MANTLE_SEPOLIA: '0x138b'` | | `MantleLayer1GasFeeFlow.ts` | Add `CHAIN_IDS.MANTLE_SEPOLIA` to `MANTLE_CHAIN_IDS` array | | `MantleLayer1GasFeeFlow.test.ts` | 3 new tests: chain matching, oracle address resolution, full fee calculation for Sepolia | | `CHANGELOG.md` | Unreleased entry | ## References - Mantle Sepolia: chain ID 5003, RPC `https://rpc.sepolia.mantle.xyz` - Oracle contract: [0x420000000000000000000000000000000000000F](https://sepolia.mantlescan.xyz/address/0x420000000000000000000000000000000000000F) ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes the L1 gas fee calculation pipeline for all oracle-based L1 fee flows by introducing a transform hook, and adds a new Mantle-specific flow that performs on-chain conversion; incorrect conversion or contract calls could impact fee estimates on supported networks. > > **Overview** > Adds Mantle (mainnet + Sepolia) support for L1 gas fee estimation by introducing `MantleLayer1GasFeeFlow`, which converts the OP Stack oracle `getL1Fee` result into MNT using the oracle’s `tokenRatio()` before adding any operator fee. > > Refactors `OracleLayer1GasFeeFlow` to apply a new overridable `transformOracleFee` step prior to combining with `getOperatorFee`, wires the new Mantle flow into `TransactionController`’s layer-1 fee flows, and adds `CHAIN_IDS.MANTLE`/`CHAIN_IDS.MANTLE_SEPOLIA` plus comprehensive unit tests and a changelog entry. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 1e7afa0. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent a6a12d3 commit 2311c16

7 files changed

Lines changed: 465 additions & 2 deletions

File tree

packages/transaction-controller/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Add `MantleLayer1GasFeeFlow` with `tokenRatio` conversion for accurate MNT-denominated gas estimates for Mantle and MantleSepolia ([#8386](https://github.com/MetaMask/core/pull/8386))
13+
1014
## [64.2.0]
1115

1216
### Added

packages/transaction-controller/src/TransactionController.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ import { v1 as random } from 'uuid';
6666

6767
import { DefaultGasFeeFlow } from './gas-flows/DefaultGasFeeFlow';
6868
import { LineaGasFeeFlow } from './gas-flows/LineaGasFeeFlow';
69+
import { MantleLayer1GasFeeFlow } from './gas-flows/MantleLayer1GasFeeFlow';
6970
import { OptimismLayer1GasFeeFlow } from './gas-flows/OptimismLayer1GasFeeFlow';
7071
import { RandomisedEstimationsGasFeeFlow } from './gas-flows/RandomisedEstimationsGasFeeFlow';
7172
import { ScrollLayer1GasFeeFlow } from './gas-flows/ScrollLayer1GasFeeFlow';
@@ -4168,7 +4169,11 @@ export class TransactionController extends BaseController<
41684169
}
41694170

41704171
#getLayer1GasFeeFlows(): Layer1GasFeeFlow[] {
4171-
return [new OptimismLayer1GasFeeFlow(), new ScrollLayer1GasFeeFlow()];
4172+
return [
4173+
new MantleLayer1GasFeeFlow(),
4174+
new OptimismLayer1GasFeeFlow(),
4175+
new ScrollLayer1GasFeeFlow(),
4176+
];
41724177
}
41734178

41744179
#updateTransactionInternal(

packages/transaction-controller/src/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ export const CHAIN_IDS = {
3333
SCROLL_SEPOLIA: '0x8274f',
3434
MEGAETH_TESTNET: '0x18c6',
3535
SEI: '0x531',
36+
MANTLE: '0x1388',
37+
MANTLE_SEPOLIA: '0x138b',
3638
} as const;
3739

3840
/** Extract of the Wrapped ERC-20 ABI required for simulation. */
Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
import { TransactionFactory } from '@ethereumjs/tx';
2+
import type { TypedTransaction } from '@ethereumjs/tx';
3+
import { Contract } from '@ethersproject/contracts';
4+
import type { Provider } from '@metamask/network-controller';
5+
import { add0x } from '@metamask/utils';
6+
import type { Hex } from '@metamask/utils';
7+
import BN from 'bn.js';
8+
9+
import { CHAIN_IDS } from '../constants';
10+
import type { TransactionControllerMessenger } from '../TransactionController';
11+
import { TransactionStatus } from '../types';
12+
import type { Layer1GasFeeFlowRequest, TransactionMeta } from '../types';
13+
import { bnFromHex, padHexToEvenLength } from '../utils/utils';
14+
import { MantleLayer1GasFeeFlow } from './MantleLayer1GasFeeFlow';
15+
16+
jest.mock('@ethersproject/contracts', () => ({
17+
Contract: jest.fn(),
18+
}));
19+
20+
jest.mock('@ethersproject/providers');
21+
22+
const TRANSACTION_PARAMS_MOCK = {
23+
from: '0x123',
24+
gas: '0x1234',
25+
};
26+
27+
const TRANSACTION_META_MOCK: TransactionMeta = {
28+
id: '1',
29+
chainId: CHAIN_IDS.MANTLE,
30+
networkClientId: 'testNetworkClientId',
31+
status: TransactionStatus.unapproved,
32+
time: 0,
33+
txParams: TRANSACTION_PARAMS_MOCK,
34+
};
35+
36+
const TRANSACTION_META_TESTNET_MOCK: TransactionMeta = {
37+
id: '2',
38+
chainId: CHAIN_IDS.MANTLE_SEPOLIA,
39+
networkClientId: 'testNetworkClientId',
40+
status: TransactionStatus.unapproved,
41+
time: 0,
42+
txParams: TRANSACTION_PARAMS_MOCK,
43+
};
44+
45+
const SERIALIZED_TRANSACTION_MOCK = '0x1234';
46+
// L1 fee in ETH (returned by oracle)
47+
const L1_FEE_MOCK = '0x0de0b6b3a7640000'; // 1e18 (1 ETH in wei)
48+
// tokenRatio is a raw multiplier (e.g., 3020 means 1 ETH L1 fee = 3020 MNT)
49+
const TOKEN_RATIO_MOCK = new BN('3020');
50+
const OPERATOR_FEE_MOCK = '0x2386f26fc10000'; // 0.01 ETH in wei
51+
52+
/**
53+
* Creates a mock TypedTransaction object.
54+
*
55+
* @param serializedBuffer - The buffer returned by the serialize method.
56+
* @returns The mock TypedTransaction object.
57+
*/
58+
function createMockTypedTransaction(
59+
serializedBuffer: Buffer,
60+
): jest.Mocked<TypedTransaction> {
61+
const instance = {
62+
serialize: (): Buffer => serializedBuffer,
63+
sign: jest.fn(),
64+
};
65+
66+
jest.spyOn(instance, 'sign').mockReturnValue(instance);
67+
68+
return instance as unknown as jest.Mocked<TypedTransaction>;
69+
}
70+
71+
describe('MantleLayer1GasFeeFlow', () => {
72+
const contractMock = jest.mocked(Contract);
73+
const contractGetL1FeeMock: jest.MockedFn<() => Promise<BN>> = jest.fn();
74+
const contractGetOperatorFeeMock: jest.MockedFn<() => Promise<BN>> =
75+
jest.fn();
76+
const contractTokenRatioMock: jest.MockedFn<() => Promise<BN>> = jest.fn();
77+
78+
let request: Layer1GasFeeFlowRequest;
79+
80+
beforeEach(() => {
81+
request = {
82+
provider: {} as Provider,
83+
transactionMeta: TRANSACTION_META_MOCK,
84+
};
85+
86+
contractMock.mockClear();
87+
contractGetL1FeeMock.mockClear();
88+
contractGetOperatorFeeMock.mockClear();
89+
contractTokenRatioMock.mockClear();
90+
91+
contractGetL1FeeMock.mockResolvedValue(bnFromHex(L1_FEE_MOCK));
92+
contractGetOperatorFeeMock.mockResolvedValue(new BN(0));
93+
contractTokenRatioMock.mockResolvedValue(TOKEN_RATIO_MOCK);
94+
95+
// The base class creates a contract first (for getL1Fee/getOperatorFee),
96+
// then transformOracleFee creates a second contract (for tokenRatio).
97+
// Both use the same mock constructor.
98+
contractMock.mockReturnValue({
99+
getL1Fee: contractGetL1FeeMock,
100+
getOperatorFee: contractGetOperatorFeeMock,
101+
tokenRatio: contractTokenRatioMock,
102+
} as unknown as Contract);
103+
});
104+
105+
describe('matchesTransaction', () => {
106+
const messenger = {} as TransactionControllerMessenger;
107+
108+
it('returns true if chain ID is Mantle', async () => {
109+
const flow = new MantleLayer1GasFeeFlow();
110+
111+
expect(
112+
await flow.matchesTransaction({
113+
transactionMeta: TRANSACTION_META_MOCK,
114+
messenger,
115+
}),
116+
).toBe(true);
117+
});
118+
119+
it('returns true if chain ID is Mantle Sepolia', async () => {
120+
const flow = new MantleLayer1GasFeeFlow();
121+
122+
expect(
123+
await flow.matchesTransaction({
124+
transactionMeta: TRANSACTION_META_TESTNET_MOCK,
125+
messenger,
126+
}),
127+
).toBe(true);
128+
});
129+
130+
it('returns false if chain ID is not Mantle', async () => {
131+
const flow = new MantleLayer1GasFeeFlow();
132+
133+
expect(
134+
await flow.matchesTransaction({
135+
transactionMeta: {
136+
...TRANSACTION_META_MOCK,
137+
chainId: CHAIN_IDS.MAINNET,
138+
},
139+
messenger,
140+
}),
141+
).toBe(false);
142+
});
143+
});
144+
145+
describe('getLayer1Fee', () => {
146+
it('multiplies L1 fee by tokenRatio before adding operator fee', async () => {
147+
const gasUsed = '0x5208';
148+
request = {
149+
...request,
150+
transactionMeta: {
151+
...request.transactionMeta,
152+
gasUsed,
153+
},
154+
};
155+
156+
contractGetOperatorFeeMock.mockResolvedValueOnce(
157+
bnFromHex(OPERATOR_FEE_MOCK),
158+
);
159+
160+
jest
161+
.spyOn(TransactionFactory, 'fromTxData')
162+
.mockReturnValueOnce(
163+
createMockTypedTransaction(
164+
Buffer.from(SERIALIZED_TRANSACTION_MOCK, 'hex'),
165+
),
166+
);
167+
168+
const flow = new MantleLayer1GasFeeFlow();
169+
const response = await flow.getLayer1Fee(request);
170+
171+
const expectedL1FeeInMnt = bnFromHex(L1_FEE_MOCK).mul(TOKEN_RATIO_MOCK);
172+
const expectedTotal = expectedL1FeeInMnt.add(
173+
bnFromHex(OPERATOR_FEE_MOCK),
174+
);
175+
176+
expect(contractTokenRatioMock).toHaveBeenCalledTimes(1);
177+
expect(contractGetOperatorFeeMock).toHaveBeenCalledTimes(1);
178+
expect(response).toStrictEqual({
179+
layer1Fee: add0x(padHexToEvenLength(expectedTotal.toString(16))),
180+
});
181+
});
182+
183+
it('returns converted L1 fee when no gasUsed (no operator fee)', async () => {
184+
jest
185+
.spyOn(TransactionFactory, 'fromTxData')
186+
.mockReturnValueOnce(
187+
createMockTypedTransaction(
188+
Buffer.from(SERIALIZED_TRANSACTION_MOCK, 'hex'),
189+
),
190+
);
191+
192+
const flow = new MantleLayer1GasFeeFlow();
193+
const response = await flow.getLayer1Fee(request);
194+
195+
const expectedL1FeeInMnt = bnFromHex(L1_FEE_MOCK).mul(TOKEN_RATIO_MOCK);
196+
197+
expect(contractGetOperatorFeeMock).not.toHaveBeenCalled();
198+
expect(response).toStrictEqual({
199+
layer1Fee: add0x(padHexToEvenLength(expectedL1FeeInMnt.toString(16))),
200+
});
201+
});
202+
203+
it('defaults operator fee to zero when call fails', async () => {
204+
const gasUsed = '0x5208';
205+
request = {
206+
...request,
207+
transactionMeta: {
208+
...request.transactionMeta,
209+
gasUsed,
210+
},
211+
};
212+
213+
contractGetOperatorFeeMock.mockRejectedValueOnce(new Error('revert'));
214+
215+
jest
216+
.spyOn(TransactionFactory, 'fromTxData')
217+
.mockReturnValueOnce(
218+
createMockTypedTransaction(
219+
Buffer.from(SERIALIZED_TRANSACTION_MOCK, 'hex'),
220+
),
221+
);
222+
223+
const flow = new MantleLayer1GasFeeFlow();
224+
const response = await flow.getLayer1Fee(request);
225+
226+
const expectedL1FeeInMnt = bnFromHex(L1_FEE_MOCK).mul(TOKEN_RATIO_MOCK);
227+
228+
expect(response).toStrictEqual({
229+
layer1Fee: add0x(padHexToEvenLength(expectedL1FeeInMnt.toString(16))),
230+
});
231+
});
232+
233+
it('throws if tokenRatio call fails', async () => {
234+
contractTokenRatioMock.mockRejectedValue(new Error('error'));
235+
236+
jest
237+
.spyOn(TransactionFactory, 'fromTxData')
238+
.mockReturnValueOnce(
239+
createMockTypedTransaction(
240+
Buffer.from(SERIALIZED_TRANSACTION_MOCK, 'hex'),
241+
),
242+
);
243+
244+
const flow = new MantleLayer1GasFeeFlow();
245+
246+
await expect(flow.getLayer1Fee(request)).rejects.toThrow(
247+
'Failed to get oracle layer 1 gas fee',
248+
);
249+
});
250+
251+
it('uses default OP Stack oracle address for mainnet', () => {
252+
class TestableMantleLayer1GasFeeFlow extends MantleLayer1GasFeeFlow {
253+
exposeOracleAddress(chainId: Hex): Hex {
254+
return super.getOracleAddressForChain(chainId);
255+
}
256+
}
257+
258+
const flow = new TestableMantleLayer1GasFeeFlow();
259+
expect(flow.exposeOracleAddress(CHAIN_IDS.MANTLE)).toBe(
260+
'0x420000000000000000000000000000000000000F',
261+
);
262+
});
263+
264+
it('uses default OP Stack oracle address for Mantle Sepolia', () => {
265+
class TestableMantleLayer1GasFeeFlow extends MantleLayer1GasFeeFlow {
266+
exposeOracleAddress(chainId: Hex): Hex {
267+
return super.getOracleAddressForChain(chainId);
268+
}
269+
}
270+
271+
const flow = new TestableMantleLayer1GasFeeFlow();
272+
expect(flow.exposeOracleAddress(CHAIN_IDS.MANTLE_SEPOLIA)).toBe(
273+
'0x420000000000000000000000000000000000000F',
274+
);
275+
});
276+
277+
it('computes correct fee for Mantle Sepolia transactions', async () => {
278+
const gasUsed = '0x5208';
279+
request = {
280+
...request,
281+
transactionMeta: {
282+
...TRANSACTION_META_TESTNET_MOCK,
283+
gasUsed,
284+
},
285+
};
286+
287+
contractGetOperatorFeeMock.mockResolvedValueOnce(
288+
bnFromHex(OPERATOR_FEE_MOCK),
289+
);
290+
291+
jest
292+
.spyOn(TransactionFactory, 'fromTxData')
293+
.mockReturnValueOnce(
294+
createMockTypedTransaction(
295+
Buffer.from(SERIALIZED_TRANSACTION_MOCK, 'hex'),
296+
),
297+
);
298+
299+
const flow = new MantleLayer1GasFeeFlow();
300+
const response = await flow.getLayer1Fee(request);
301+
302+
const expectedL1FeeInMnt = bnFromHex(L1_FEE_MOCK).mul(TOKEN_RATIO_MOCK);
303+
const expectedTotal = expectedL1FeeInMnt.add(
304+
bnFromHex(OPERATOR_FEE_MOCK),
305+
);
306+
307+
expect(contractTokenRatioMock).toHaveBeenCalledTimes(1);
308+
expect(contractGetOperatorFeeMock).toHaveBeenCalledTimes(1);
309+
expect(response).toStrictEqual({
310+
layer1Fee: add0x(padHexToEvenLength(expectedTotal.toString(16))),
311+
});
312+
});
313+
});
314+
});

0 commit comments

Comments
 (0)