From 34af464e547556e5036077d7345d5c4fff132b7d Mon Sep 17 00:00:00 2001 From: adaOctopus Date: Thu, 23 Jul 2026 13:33:31 +0200 Subject: [PATCH] fix: include Mantle operator fee in post-confirmation layer1GasFee Derive L1 + operator fees from receipt fields on confirmation and in client-utils activity fee mapping so Activity Details match what was paid. Co-authored-by: Cursor --- packages/client-utils/CHANGELOG.md | 4 + .../src/mappers/helpers/transactions.test.ts | 90 +++++++++++++ .../src/mappers/helpers/transactions.ts | 119 +++++++++++++++++- packages/transaction-controller/CHANGELOG.md | 9 ++ .../helpers/PendingTransactionTracker.test.ts | 118 +++++++++++++++++ .../src/helpers/PendingTransactionTracker.ts | 8 ++ packages/transaction-controller/src/index.ts | 4 + packages/transaction-controller/src/types.ts | 19 ++- .../src/utils/receipt-fees.test.ts | 113 +++++++++++++++++ .../src/utils/receipt-fees.ts | 93 ++++++++++++++ 10 files changed, 573 insertions(+), 4 deletions(-) create mode 100644 packages/transaction-controller/src/utils/receipt-fees.test.ts create mode 100644 packages/transaction-controller/src/utils/receipt-fees.ts diff --git a/packages/client-utils/CHANGELOG.md b/packages/client-utils/CHANGELOG.md index d67a2516bba..6de44d9a29b 100644 --- a/packages/client-utils/CHANGELOG.md +++ b/packages/client-utils/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Include L1 data fee and Mantle operator fee in `getLocalTransactionFees` network fee amounts, preferring `layer1GasFee` and falling back to receipt-derived L1 + operator fee + ## [1.2.1] ### Changed diff --git a/packages/client-utils/src/mappers/helpers/transactions.test.ts b/packages/client-utils/src/mappers/helpers/transactions.test.ts index 3127f194596..9a9026a2824 100644 --- a/packages/client-utils/src/mappers/helpers/transactions.test.ts +++ b/packages/client-utils/src/mappers/helpers/transactions.test.ts @@ -265,6 +265,96 @@ describe('transaction helpers', () => { } as Parameters[0]), ).toBeUndefined(); }); + + it('adds layer1GasFee (L1 + operator) onto the L2 network fee', () => { + expect( + getLocalTransactionFees({ + primaryTransaction: { + chainId: '0x1388', + layer1GasFee: '0x5f5e100', // 100_000_000 + txParams: {}, + txReceipt: { + gasUsed: '0x5208', + effectiveGasPrice: '0x3b9aca00', + }, + }, + } as Parameters[0]), + ).toStrictEqual([ + { + type: 'base', + // 21_000_000_000_000 + 100_000_000 + amount: '21000100000000', + decimals: 18, + }, + ]); + }); + + it('falls back to receipt L1 + operator fee when layer1GasFee is absent', () => { + const gasUsed = BigInt('0xceaf'); + const operatorFee = gasUsed * BigInt('0x5f5e100') * 100n; + const l1Fee = BigInt('0x9173c910a1ac'); + const l2Amount = BigInt('0xceaf') * BigInt('0xba43cfaa0'); + const expected = String(l2Amount + l1Fee + operatorFee); + + expect( + getLocalTransactionFees({ + primaryTransaction: { + chainId: '0x1388', + txParams: {}, + txReceipt: { + gasUsed: '0xceaf', + effectiveGasPrice: '0xba43cfaa0', + l1Fee: '0x9173c910a1ac', + operatorFeeConstant: '0x0', + operatorFeeScalar: '0x5f5e100', + }, + }, + } as Parameters[0])?.[0]?.amount, + ).toBe(expected); + }); + + it('falls back to receipt l1Fee for Optimism-style receipts', () => { + expect( + getLocalTransactionFees({ + primaryTransaction: { + chainId: '0xa', + txParams: {}, + txReceipt: { + gasUsed: '0x5208', + effectiveGasPrice: '0x3b9aca00', + l1Fee: '0x5f5e100', + }, + }, + } as Parameters[0]), + ).toStrictEqual([ + { + type: 'base', + amount: '21000100000000', + decimals: 18, + symbol: 'ETH', + assetId: 'eip155:10/slip44:60', + }, + ]); + }); + + it('prefers layer1GasFee over receipt fees to avoid double-counting', () => { + expect( + getLocalTransactionFees({ + primaryTransaction: { + chainId: '0x1388', + layer1GasFee: '0x5f5e100', + txParams: {}, + txReceipt: { + gasUsed: '0x5208', + effectiveGasPrice: '0x3b9aca00', + l1Fee: '0xffffffff', + operatorFeeScalar: '0x5f5e100', + operatorFeeConstant: '0x0', + }, + }, + } as Parameters[0])?.[0]?.amount, + ).toBe('21000100000000'); + }); }); describe('getFees', () => { diff --git a/packages/client-utils/src/mappers/helpers/transactions.ts b/packages/client-utils/src/mappers/helpers/transactions.ts index 332ce98092d..b49f55f9665 100644 --- a/packages/client-utils/src/mappers/helpers/transactions.ts +++ b/packages/client-utils/src/mappers/helpers/transactions.ts @@ -28,6 +28,18 @@ export type TransactionGroup = { const nativeTokenDecimals = 18; +/** + * Receipt fields used for L1 / operator fee derivation. Kept local so this + * package works before/alongside `@metamask/transaction-controller` typing + * the Mantle Arsia receipt fields. + */ +type ReceiptFeeFields = { + gasUsed?: string; + l1Fee?: string; + operatorFeeConstant?: string; + operatorFeeScalar?: string; +}; + function toNetworkFeeAmount( gasUsed: string | number | undefined, gasPrice: string | number | undefined, @@ -43,6 +55,86 @@ function toNetworkFeeAmount( } } +function parseHexQuantity(value: string | undefined): bigint | undefined { + if (value === undefined || value === '' || value === '0x' || value === '0X') { + return undefined; + } + + try { + return BigInt(value); + } catch { + return undefined; + } +} + +/** + * Derives L1 data fee + operator fee from a receipt (Mantle Arsia / + * OP Stack). Mirrors `@metamask/transaction-controller`'s + * `getLayer1FeeFromReceipt` so Activity Details stay correct even when + * `layer1GasFee` was not refreshed on confirmation. + * + * @param receipt - Transaction receipt. + * @returns Combined fee as a decimal wei string, or undefined. + */ +function getReceiptLayer1FeeAmount( + receipt: ReceiptFeeFields, +): string | undefined { + const l1Fee = parseHexQuantity(receipt.l1Fee); + const gasUsed = parseHexQuantity(receipt.gasUsed); + const operatorFeeScalar = parseHexQuantity(receipt.operatorFeeScalar); + const operatorFeeConstant = parseHexQuantity(receipt.operatorFeeConstant); + + const operatorFee = + gasUsed !== undefined && + operatorFeeScalar !== undefined && + operatorFeeConstant !== undefined + ? gasUsed * operatorFeeScalar * 100n + operatorFeeConstant + : undefined; + + if (l1Fee === undefined && operatorFee === undefined) { + return undefined; + } + + return String((l1Fee ?? 0n) + (operatorFee ?? 0n)); +} + +/** + * Adds L1 / operator fee onto an L2 network fee (decimal wei string). + * Prefers `layer1GasFee` from TransactionMeta over a receipt-derived fee + * so values are not double-counted. + * + * @param networkFeeAmount - L2 network fee amount in decimal wei. + * @param layer1GasFee - Optional hex wei L1 + operator fee from TransactionMeta. + * @param receiptLayer1FeeAmount - Optional decimal wei L1 + operator from receipt. + * @returns Combined fee amount in decimal wei, or the original L2 amount on failure. + */ +function addLayer1FeeToNetworkFeeAmount( + networkFeeAmount: string, + layer1GasFee: string | undefined, + receiptLayer1FeeAmount: string | undefined, +): string { + const layer1Amount = + layer1GasFee === undefined + ? receiptLayer1FeeAmount + : (() => { + try { + return String(BigInt(layer1GasFee)); + } catch { + return undefined; + } + })(); + + if (!layer1Amount) { + return networkFeeAmount; + } + + try { + return String(BigInt(networkFeeAmount) + BigInt(layer1Amount)); + } catch { + return networkFeeAmount; + } +} + function buildBaseNetworkFee(amount: string, chainId: string | number): Fee { const nativeAsset = getNativeAsset(chainId); @@ -78,19 +170,40 @@ export function getFees( return networkFee ? [networkFee] : undefined; } +/** + * Builds the base network fee (in the chain's native token) for a local + * transaction from its receipt (`gasUsed × effectiveGasPrice`), plus any + * L1 / operator fee from `layer1GasFee` or receipt fields. Falls back to + * `txParams.gasPrice` while pending. + * + * @param transactionGroup - Transaction group with the primary transaction. + * @returns Activity fee list with a single base network fee, or undefined. + */ export function getLocalTransactionFees( transactionGroup: Pick, ): Fee[] | undefined { const { primaryTransaction } = transactionGroup; - const amount = toNetworkFeeAmount( + const l2Amount = toNetworkFeeAmount( primaryTransaction.txReceipt?.gasUsed, primaryTransaction.txReceipt?.effectiveGasPrice ?? primaryTransaction.txParams?.gasPrice, ); - return amount - ? [buildBaseNetworkFee(amount, primaryTransaction.chainId)] + if (!l2Amount) { + return undefined; + } + + const receiptLayer1FeeAmount = primaryTransaction.txReceipt + ? getReceiptLayer1FeeAmount(primaryTransaction.txReceipt) : undefined; + + const amount = addLayer1FeeToNetworkFeeAmount( + l2Amount, + primaryTransaction.layer1GasFee, + receiptLayer1FeeAmount, + ); + + return [buildBaseNetworkFee(amount, primaryTransaction.chainId)]; } const inProgressTransactionStatuses = [ diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 80f5a2a15f0..8e24661d909 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add optional `operatorFeeScalar`, `operatorFeeConstant`, and `tokenRatio` fields to `TransactionReceipt` for OP Stack / Mantle Arsia receipts +- Export `getOperatorFeeFromReceipt` and `getLayer1FeeFromReceipt` helpers to derive L1 + operator fees from transaction receipts + +### Fixed + +- Refresh `transactionMeta.layer1GasFee` from the receipt on confirmation so Activity Details include Mantle operator fee (and OP Stack L1 fee) based on inclusion-time values rather than the pre-confirm estimate alone + ## [69.2.1] ### Changed diff --git a/packages/transaction-controller/src/helpers/PendingTransactionTracker.test.ts b/packages/transaction-controller/src/helpers/PendingTransactionTracker.test.ts index 4744e5e0032..9ac360d9416 100644 --- a/packages/transaction-controller/src/helpers/PendingTransactionTracker.test.ts +++ b/packages/transaction-controller/src/helpers/PendingTransactionTracker.test.ts @@ -767,6 +767,124 @@ describe('PendingTransactionTracker', () => { ); }); + it('sets layer1GasFee from Mantle receipt L1 + operator fee', async () => { + const transaction = { + ...TRANSACTION_SUBMITTED_MOCK, + layer1GasFee: '0x1', // stale pre-confirm estimate + }; + const getTransactions = jest + .fn() + .mockReturnValue(freeze([transaction], true)); + + pendingTransactionTracker = new PendingTransactionTracker({ + ...options, + getTransactions, + }); + + const listener = jest.fn(); + pendingTransactionTracker.hub.addListener( + 'transaction-confirmed', + listener, + ); + + const mantleReceipt = { + ...RECEIPT_MOCK, + gasUsed: '0xceaf', + l1Fee: '0x9173c910a1ac', + operatorFeeConstant: '0x0', + operatorFeeScalar: '0x5f5e100', + tokenRatio: '0x120c', + }; + + getTransactionReceiptMock.mockResolvedValueOnce(mantleReceipt); + getBlockByHashMock.mockResolvedValueOnce(BLOCK_MOCK); + + await onPoll(); + + const operatorFee = BigInt('0xceaf') * BigInt('0x5f5e100') * 100n; + const layer1Hex = (BigInt('0x9173c910a1ac') + operatorFee).toString( + 16, + ); + const expectedLayer1 = `0x${ + layer1Hex.length % 2 === 0 ? layer1Hex : `0${layer1Hex}` + }`; + + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + layer1GasFee: expectedLayer1, + txReceipt: mantleReceipt, + }), + ); + }); + + it('sets layer1GasFee from Optimism-style receipt l1Fee only', async () => { + const transaction = { ...TRANSACTION_SUBMITTED_MOCK }; + const getTransactions = jest + .fn() + .mockReturnValue(freeze([transaction], true)); + + pendingTransactionTracker = new PendingTransactionTracker({ + ...options, + getTransactions, + }); + + const listener = jest.fn(); + pendingTransactionTracker.hub.addListener( + 'transaction-confirmed', + listener, + ); + + const optimismReceipt = { + ...RECEIPT_MOCK, + l1Fee: '0x5f5e100', + }; + + getTransactionReceiptMock.mockResolvedValueOnce(optimismReceipt); + getBlockByHashMock.mockResolvedValueOnce(BLOCK_MOCK); + + await onPoll(); + + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + layer1GasFee: '0x05f5e100', + txReceipt: optimismReceipt, + }), + ); + }); + + it('leaves layer1GasFee unchanged when receipt has no L1 fee fields', async () => { + const transaction = { + ...TRANSACTION_SUBMITTED_MOCK, + layer1GasFee: '0xabc', + }; + const getTransactions = jest + .fn() + .mockReturnValue(freeze([transaction], true)); + + pendingTransactionTracker = new PendingTransactionTracker({ + ...options, + getTransactions, + }); + + const listener = jest.fn(); + pendingTransactionTracker.hub.addListener( + 'transaction-confirmed', + listener, + ); + + getTransactionReceiptMock.mockResolvedValueOnce(RECEIPT_MOCK); + getBlockByHashMock.mockResolvedValueOnce(BLOCK_MOCK); + + await onPoll(); + + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + layer1GasFee: '0xabc', + txReceipt: RECEIPT_MOCK, + }), + ); + }); + it('if isIntentComplete is true', async () => { const transaction = { ...TRANSACTION_SUBMITTED_MOCK, diff --git a/packages/transaction-controller/src/helpers/PendingTransactionTracker.ts b/packages/transaction-controller/src/helpers/PendingTransactionTracker.ts index 691f2290332..7c4c01af347 100644 --- a/packages/transaction-controller/src/helpers/PendingTransactionTracker.ts +++ b/packages/transaction-controller/src/helpers/PendingTransactionTracker.ts @@ -17,6 +17,7 @@ import { getTimeoutAttempts, } from '../utils/feature-flags'; import { getChainId, rpcRequest } from '../utils/provider'; +import { getLayer1FeeFromReceipt } from '../utils/receipt-fees'; import { extractRevert, OnChainFailureError } from '../utils/revert-reason'; import { TransactionPoller } from './TransactionPoller'; @@ -384,6 +385,13 @@ export class PendingTransactionTracker { }; updatedTxMeta.txReceipt = receipt; updatedTxMeta.verifiedOnBlockchain = true; + + // Prefer inclusion-time receipt values (L1 data fee + operator fee) over + // the pre-confirm estimate so Activity Details match what was paid. + const layer1GasFee = getLayer1FeeFromReceipt(receipt); + if (layer1GasFee !== undefined) { + updatedTxMeta.layer1GasFee = layer1GasFee; + } } updatedTxMeta.status = TransactionStatus.confirmed; diff --git a/packages/transaction-controller/src/index.ts b/packages/transaction-controller/src/index.ts index 67493bc3dce..0e6d923b658 100644 --- a/packages/transaction-controller/src/index.ts +++ b/packages/transaction-controller/src/index.ts @@ -138,6 +138,10 @@ export { normalizeTransactionParams, } from './utils/utils'; export { hasTransactionType } from './utils/transaction-type'; +export { + getLayer1FeeFromReceipt, + getOperatorFeeFromReceipt, +} from './utils/receipt-fees'; export { CHAIN_IDS } from './constants'; export { HARDFORK } from './utils/prepare'; export { getAccountAddressRelationship } from './api/accounts-api'; diff --git a/packages/transaction-controller/src/types.ts b/packages/transaction-controller/src/types.ts index 35aac199021..8ac40cd3736 100644 --- a/packages/transaction-controller/src/types.ts +++ b/packages/transaction-controller/src/types.ts @@ -1150,10 +1150,27 @@ export type TransactionReceipt = { gasUsed?: string; /** - * Total used gas in hex. + * Layer 1 data fee in hex wei (native token units on Mantle). */ l1Fee?: string; + /** + * Operator fee constant from OP Stack / Mantle Arsia receipts, in hex wei. + */ + operatorFeeConstant?: string; + + /** + * Operator fee scalar from OP Stack / Mantle Arsia receipts, in hex. + * Used as: `gasUsed * operatorFeeScalar * 100 + operatorFeeConstant`. + */ + operatorFeeScalar?: string; + + /** + * ETH/MNT token ratio from Mantle receipts. Informational only — + * receipt `l1Fee` is already denominated in the native token. + */ + tokenRatio?: string; + /** * All the logs emitted by this transaction. */ diff --git a/packages/transaction-controller/src/utils/receipt-fees.test.ts b/packages/transaction-controller/src/utils/receipt-fees.test.ts new file mode 100644 index 00000000000..4a577153f5a --- /dev/null +++ b/packages/transaction-controller/src/utils/receipt-fees.test.ts @@ -0,0 +1,113 @@ +import type { TransactionReceipt } from '../types'; +import { + getLayer1FeeFromReceipt, + getOperatorFeeFromReceipt, +} from './receipt-fees'; + +describe('receipt-fees', () => { + // Values from a live Mantle mainnet receipt. + const MANTLE_RECEIPT: TransactionReceipt = { + gasUsed: '0xceaf', + l1Fee: '0x9173c910a1ac', + operatorFeeConstant: '0x0', + operatorFeeScalar: '0x5f5e100', + tokenRatio: '0x120c', + }; + + describe('getOperatorFeeFromReceipt', () => { + it('computes Mantle Arsia operator fee from receipt fields', () => { + // gasUsed (52911) * scalar (100000000) * 100 + constant (0) + // Odd-length hex is padded to even length. + expect(getOperatorFeeFromReceipt(MANTLE_RECEIPT)).toBe('0x01e1390598dc00'); + }); + + it('returns undefined when operator fee fields are missing', () => { + expect( + getOperatorFeeFromReceipt({ + gasUsed: '0xceaf', + l1Fee: '0x9173c910a1ac', + }), + ).toBeUndefined(); + }); + + it('returns undefined when gasUsed is missing', () => { + expect( + getOperatorFeeFromReceipt({ + operatorFeeScalar: '0x5f5e100', + operatorFeeConstant: '0x0', + }), + ).toBeUndefined(); + }); + + it('includes a non-zero operatorFeeConstant', () => { + expect( + getOperatorFeeFromReceipt({ + gasUsed: '0x1', + operatorFeeScalar: '0x1', + operatorFeeConstant: '0xa', + }), + ).toBe('0x6e'); // 1 * 1 * 100 + 10 = 110 + }); + }); + + describe('getLayer1FeeFromReceipt', () => { + it('sums Mantle receipt l1Fee and operator fee', () => { + const operatorFee = BigInt('0xceaf') * BigInt('0x5f5e100') * 100n; + const l1Fee = BigInt('0x9173c910a1ac'); + // padHexToEvenLength prefixes a 0 when the hex length is odd + expect(getLayer1FeeFromReceipt(MANTLE_RECEIPT)).toBe( + `0x0${(l1Fee + operatorFee).toString(16)}`, + ); + }); + + it('returns l1Fee only for Optimism-style receipts without operator fields', () => { + expect( + getLayer1FeeFromReceipt({ + gasUsed: '0xb496', + l1Fee: '0x5f5e100', + }), + ).toBe('0x05f5e100'); + }); + + it('returns operator fee only when l1Fee is absent', () => { + expect( + getLayer1FeeFromReceipt({ + gasUsed: '0x1', + operatorFeeScalar: '0x1', + operatorFeeConstant: '0x0', + }), + ).toBe('0x64'); // 100 + }); + + it('returns undefined when neither l1Fee nor operator fields are present', () => { + expect( + getLayer1FeeFromReceipt({ + gasUsed: '0xceaf', + }), + ).toBeUndefined(); + }); + + it('returns 0x00 when both components are present and zero', () => { + expect( + getLayer1FeeFromReceipt({ + gasUsed: '0x1', + l1Fee: '0x0', + operatorFeeScalar: '0x0', + operatorFeeConstant: '0x0', + }), + ).toBe('0x00'); + }); + + it('does not multiply l1Fee by tokenRatio', () => { + const withoutRatio = getLayer1FeeFromReceipt({ + gasUsed: MANTLE_RECEIPT.gasUsed, + l1Fee: MANTLE_RECEIPT.l1Fee, + operatorFeeScalar: MANTLE_RECEIPT.operatorFeeScalar, + operatorFeeConstant: MANTLE_RECEIPT.operatorFeeConstant, + }); + const withRatio = getLayer1FeeFromReceipt(MANTLE_RECEIPT); + + expect(withRatio).toBe(withoutRatio); + }); + }); +}); diff --git a/packages/transaction-controller/src/utils/receipt-fees.ts b/packages/transaction-controller/src/utils/receipt-fees.ts new file mode 100644 index 00000000000..43068305f2f --- /dev/null +++ b/packages/transaction-controller/src/utils/receipt-fees.ts @@ -0,0 +1,93 @@ +import type { Hex } from '@metamask/utils'; +import { add0x } from '@metamask/utils'; + +import type { TransactionReceipt } from '../types'; +import { padHexToEvenLength } from './utils'; + +/** + * Parses an optional hex quantity to a bigint. + * + * @param value - Hex string (with or without `0x`), or undefined. + * @returns Parsed bigint, or undefined when missing / invalid / empty. + */ +function parseHexQuantity(value: string | undefined): bigint | undefined { + if (value === undefined || value === '' || value === '0x' || value === '0X') { + return undefined; + } + + try { + return BigInt(value); + } catch { + return undefined; + } +} + +/** + * Formats a bigint as a `0x`-prefixed hex string with even length. + * + * @param value - Fee amount in wei. + * @returns Hex wei string. + */ +function toHexWei(value: bigint): Hex { + return add0x(padHexToEvenLength(value.toString(16))) as Hex; +} + +/** + * Computes the OP Stack / Mantle Arsia operator fee from receipt fields. + * + * Formula: `gasUsed * operatorFeeScalar * 100 + operatorFeeConstant` + * + * @param receipt - Transaction receipt that may include operator fee params. + * @returns Operator fee in hex wei, or undefined when it cannot be computed. + */ +export function getOperatorFeeFromReceipt( + receipt: TransactionReceipt, +): Hex | undefined { + const gasUsed = parseHexQuantity(receipt.gasUsed); + const operatorFeeScalar = parseHexQuantity(receipt.operatorFeeScalar); + const operatorFeeConstant = parseHexQuantity(receipt.operatorFeeConstant); + + if ( + gasUsed === undefined || + operatorFeeScalar === undefined || + operatorFeeConstant === undefined + ) { + return undefined; + } + + const operatorFee = + gasUsed * operatorFeeScalar * 100n + operatorFeeConstant; + + return toHexWei(operatorFee); +} + +/** + * Derives the combined layer-1 fee (L1 data fee + operator fee) from a receipt. + * + * Does not multiply `l1Fee` by `tokenRatio` — on Mantle, receipt `l1Fee` is + * already denominated in the native token (MNT). + * + * @param receipt - Transaction receipt. + * @returns Combined L1 + operator fee in hex wei, or undefined when neither + * component is available. + */ +export function getLayer1FeeFromReceipt( + receipt: TransactionReceipt, +): Hex | undefined { + const l1Fee = parseHexQuantity(receipt.l1Fee) ?? 0n; + const operatorFeeHex = getOperatorFeeFromReceipt(receipt); + const operatorFee = + operatorFeeHex === undefined ? 0n : (parseHexQuantity(operatorFeeHex) ?? 0n); + + if (l1Fee === 0n && operatorFee === 0n) { + // Distinguish "both zero" (valid) from "neither present". + const hasL1Fee = parseHexQuantity(receipt.l1Fee) !== undefined; + const hasOperatorFee = operatorFeeHex !== undefined; + + if (!hasL1Fee && !hasOperatorFee) { + return undefined; + } + } + + return toHexWei(l1Fee + operatorFee); +}