Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/client-utils/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
90 changes: 90 additions & 0 deletions packages/client-utils/src/mappers/helpers/transactions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,96 @@ describe('transaction helpers', () => {
} as Parameters<typeof getLocalTransactionFees>[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<typeof getLocalTransactionFees>[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<typeof getLocalTransactionFees>[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<typeof getLocalTransactionFees>[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<typeof getLocalTransactionFees>[0])?.[0]?.amount,
).toBe('21000100000000');
});
});

describe('getFees', () => {
Expand Down
119 changes: 116 additions & 3 deletions packages/client-utils/src/mappers/helpers/transactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);

Expand Down Expand Up @@ -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<TransactionGroup, 'primaryTransaction'>,
): 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 = [
Expand Down
9 changes: 9 additions & 0 deletions packages/transaction-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading