From f7003fe4bc0e24e2a4545f58ba241e36337c13ed Mon Sep 17 00:00:00 2001 From: jeremytsng Date: Wed, 20 May 2026 13:15:19 +0700 Subject: [PATCH] fix(transaction-controller): include Mantle operator fee when `gasUsed` is missing `OracleLayer1GasFeeFlow` skipped the operator-fee oracle call whenever `transactionMeta.gasUsed` was unset. Mantle has no value for `gasUsed` during the pre-confirmation lifecycle, so the operator fee silently collapsed to zero and the displayed L1 fee under-reported the actual on-chain cost. Expose a `protected getOperatorFeeGas` hook on the base flow so subclasses can supply a fallback value, and override it in `MantleLayer1GasFeeFlow` to fall back to `txParams.gas` (or `txParams.gasLimit`) when `gasUsed` is not available. Gas limit is an upper bound on actual gas used, so the resulting operator fee over-estimates rather than under-reports. --- packages/transaction-controller/CHANGELOG.md | 6 ++ .../gas-flows/MantleLayer1GasFeeFlow.test.ts | 80 ++++++++++++++++++- .../src/gas-flows/MantleLayer1GasFeeFlow.ts | 10 +++ .../src/gas-flows/OracleLayer1GasFeeFlow.ts | 21 ++++- 4 files changed, 113 insertions(+), 4 deletions(-) diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index eb181b8f82..c5b5392900 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] +### Fixed + +- Include Mantle operator fee in the displayed L1 gas estimate when simulated `gasUsed` is unavailable, by falling back to the transaction's gas limit ([#8837](https://github.com/MetaMask/core/pull/8837)) + - Adds a `protected getOperatorFeeGas` hook on `OracleLayer1GasFeeFlow` that subclasses can override to supply a fallback value. The default behaviour is unchanged (returns `transactionMeta.gasUsed`). + - `MantleLayer1GasFeeFlow` overrides the hook with `gasUsed ?? txParams.gas ?? txParams.gasLimit`, so the operator-fee oracle is called with the gas limit when `gasUsed` is missing. Gas limit is an upper bound on actual gas used, so the operator fee is over-estimated rather than under-reported. + ## [66.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 5980118a4e..e6528702e9 100644 --- a/packages/transaction-controller/src/gas-flows/MantleLayer1GasFeeFlow.test.ts +++ b/packages/transaction-controller/src/gas-flows/MantleLayer1GasFeeFlow.test.ts @@ -180,7 +180,85 @@ describe('MantleLayer1GasFeeFlow', () => { }); }); - it('returns converted L1 fee when no gasUsed (no operator fee)', async () => { + it('falls back to txParams.gas for operator fee when gasUsed is missing', async () => { + 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(contractGetOperatorFeeMock).toHaveBeenCalledTimes(1); + expect(contractGetOperatorFeeMock).toHaveBeenCalledWith( + TRANSACTION_PARAMS_MOCK.gas, + ); + expect(response).toStrictEqual({ + layer1Fee: add0x(padHexToEvenLength(expectedTotal.toString(16))), + }); + }); + + it('falls back to txParams.gasLimit for operator fee when gasUsed and txParams.gas are missing', async () => { + const gasLimit = '0x9999'; + request = { + ...request, + transactionMeta: { + ...request.transactionMeta, + txParams: { + from: TRANSACTION_PARAMS_MOCK.from, + gasLimit, + }, + }, + }; + + 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(contractGetOperatorFeeMock).toHaveBeenCalledTimes(1); + expect(contractGetOperatorFeeMock).toHaveBeenCalledWith(gasLimit); + expect(response).toStrictEqual({ + layer1Fee: add0x(padHexToEvenLength(expectedTotal.toString(16))), + }); + }); + + it('skips operator fee when no gasUsed, txParams.gas, or txParams.gasLimit is available', async () => { + request = { + ...request, + transactionMeta: { + ...request.transactionMeta, + txParams: { from: TRANSACTION_PARAMS_MOCK.from }, + }, + }; + jest .spyOn(TransactionFactory, 'fromTxData') .mockReturnValueOnce( diff --git a/packages/transaction-controller/src/gas-flows/MantleLayer1GasFeeFlow.ts b/packages/transaction-controller/src/gas-flows/MantleLayer1GasFeeFlow.ts index d353659c83..faae3cf509 100644 --- a/packages/transaction-controller/src/gas-flows/MantleLayer1GasFeeFlow.ts +++ b/packages/transaction-controller/src/gas-flows/MantleLayer1GasFeeFlow.ts @@ -46,6 +46,16 @@ export class MantleLayer1GasFeeFlow extends OracleLayer1GasFeeFlow { return MANTLE_CHAIN_IDS.includes(transactionMeta.chainId); } + protected override getOperatorFeeGas( + transactionMeta: TransactionMeta, + ): string | undefined { + return ( + transactionMeta.gasUsed ?? + transactionMeta.txParams.gas ?? + transactionMeta.txParams.gasLimit + ); + } + protected override async transformOracleFee( oracleFee: BN, request: Layer1GasFeeFlowRequest, diff --git a/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.ts b/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.ts index 21bcb2c14f..e76586b2a7 100644 --- a/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.ts +++ b/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.ts @@ -174,18 +174,33 @@ export abstract class OracleLayer1GasFeeFlow implements Layer1GasFeeFlow { return toBN(result); } + /** + * Returns the gas value to pass to the operator-fee oracle, or undefined + * to skip the operator-fee call entirely. Defaults to the simulated + * `gasUsed` on the transaction. Subclasses can override to supply a + * fallback (e.g. estimated gas limit) when `gasUsed` is unavailable. + * + * @param transactionMeta - The transaction metadata. + * @returns The gas value, or undefined to skip the operator-fee call. + */ + protected getOperatorFeeGas( + transactionMeta: TransactionMeta, + ): string | undefined { + return transactionMeta.gasUsed; + } + async #getOperatorLayer1GasFee( contract: Contract, transactionMeta: TransactionMeta, ): Promise { - const { gasUsed } = transactionMeta; + const gas = this.getOperatorFeeGas(transactionMeta); - if (!gasUsed) { + if (!gas) { return ZERO; } try { - const result = await contract.getOperatorFee(gasUsed); + const result = await contract.getOperatorFee(gas); if (result === undefined) { return ZERO;