diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index bbe35622d1a..ccb7cc1d150 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 + +- Calculate operator fee for OP stack networks and include it in `layer1GasFee` ([#6979](https://github.com/MetaMask/core/pull/6979)) + ## [61.1.0] ### Added diff --git a/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.test.ts b/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.test.ts index 8ee08e9ed85..3b1c80d35b6 100644 --- a/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.test.ts +++ b/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.test.ts @@ -2,13 +2,18 @@ import type { TypedTransaction } from '@ethereumjs/tx'; import { TransactionFactory } from '@ethereumjs/tx'; import { Contract } from '@ethersproject/contracts'; import type { Provider } from '@metamask/network-controller'; -import type { Hex } from '@metamask/utils'; +import { add0x, type Hex } from '@metamask/utils'; +import 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 { TransactionStatus } from '../types'; +import { + type Layer1GasFeeFlowRequest, + type TransactionMeta, + TransactionStatus, +} from '../types'; +import { bnFromHex, padHexToEvenLength } from '../utils/utils'; jest.mock('@ethersproject/contracts', () => ({ Contract: jest.fn(), @@ -36,7 +41,8 @@ const TRANSACTION_META_MOCK: TransactionMeta = { const SERIALIZED_TRANSACTION_MOCK = '0x1234'; const ORACLE_ADDRESS_MOCK = '0x5678' as Hex; -const LAYER_1_FEE_MOCK = '0x9ABCD'; +const LAYER_1_FEE_MOCK = '0x09abcd'; +const OPERATOR_FEE_MOCK = '0x5'; const DEFAULT_GAS_PRICE_ORACLE_ADDRESS = '0x420000000000000000000000000000000000000F'; @@ -98,9 +104,9 @@ class DefaultOracleLayer1GasFeeFlow extends OracleLayer1GasFeeFlow { describe('OracleLayer1GasFeeFlow', () => { const contractMock = jest.mocked(Contract); - const contractGetL1FeeMock: jest.MockedFn< - () => Promise<{ toHexString: () => string }> - > = jest.fn(); + const contractGetL1FeeMock: jest.MockedFn<() => Promise> = jest.fn(); + const contractGetOperatorFeeMock: jest.MockedFn<() => Promise> = + jest.fn(); let request: Layer1GasFeeFlowRequest; @@ -112,13 +118,14 @@ describe('OracleLayer1GasFeeFlow', () => { contractMock.mockClear(); contractGetL1FeeMock.mockClear(); + contractGetOperatorFeeMock.mockClear(); - contractGetL1FeeMock.mockResolvedValue({ - toHexString: () => LAYER_1_FEE_MOCK, - }); + contractGetL1FeeMock.mockResolvedValue(bnFromHex(LAYER_1_FEE_MOCK)); + contractGetOperatorFeeMock.mockResolvedValue(new BN(0)); contractMock.mockReturnValue({ getL1Fee: contractGetL1FeeMock, + getOperatorFee: contractGetOperatorFeeMock, } as unknown as Contract); }); @@ -156,6 +163,7 @@ describe('OracleLayer1GasFeeFlow', () => { expect(contractGetL1FeeMock).toHaveBeenCalledWith( serializedTransactionMock, ); + expect(contractGetOperatorFeeMock).not.toHaveBeenCalled(); }); it('signs transaction with dummy key if supported by flow', async () => { @@ -180,6 +188,7 @@ describe('OracleLayer1GasFeeFlow', () => { }); expect(typedTransactionMock.sign).toHaveBeenCalledTimes(1); + expect(contractGetOperatorFeeMock).not.toHaveBeenCalled(); }); describe('throws', () => { @@ -228,5 +237,79 @@ describe('OracleLayer1GasFeeFlow', () => { expect(oracleAddress).toBe(DEFAULT_GAS_PRICE_ORACLE_ADDRESS); expect(typedTransactionMock.sign).not.toHaveBeenCalled(); }); + + it('adds operator fee when gas used is available', async () => { + const gasUsed = '0x5208'; + request = { + ...request, + transactionMeta: { + ...request.transactionMeta, + gasUsed, + }, + }; + + contractGetOperatorFeeMock.mockResolvedValueOnce( + bnFromHex(OPERATOR_FEE_MOCK), + ); + + const flow = new MockOracleLayer1GasFeeFlow(false); + const response = await flow.getLayer1Fee(request); + + expect(contractGetOperatorFeeMock).toHaveBeenCalledTimes(1); + expect(contractGetOperatorFeeMock).toHaveBeenCalledWith(gasUsed); + expect(response).toStrictEqual({ + layer1Fee: add0x( + padHexToEvenLength( + bnFromHex(LAYER_1_FEE_MOCK) + .add(bnFromHex(OPERATOR_FEE_MOCK)) + .toString(16), + ), + ) as Hex, + }); + }); + + it('defaults operator fee to zero when call fails', async () => { + const gasUsed = '0x1'; + request = { + ...request, + transactionMeta: { + ...request.transactionMeta, + gasUsed, + }, + }; + + contractGetOperatorFeeMock.mockRejectedValueOnce(new Error('revert')); + + const flow = new MockOracleLayer1GasFeeFlow(false); + const response = await flow.getLayer1Fee(request); + + expect(contractGetOperatorFeeMock).toHaveBeenCalledTimes(1); + expect(response).toStrictEqual({ + layer1Fee: LAYER_1_FEE_MOCK, + }); + }); + + it('defaults operator fee to zero when call returns undefined', async () => { + const gasUsed = '0x2'; + request = { + ...request, + transactionMeta: { + ...request.transactionMeta, + gasUsed, + }, + }; + + contractGetOperatorFeeMock.mockResolvedValueOnce( + undefined as unknown as BN, + ); + + const flow = new MockOracleLayer1GasFeeFlow(false); + const response = await flow.getLayer1Fee(request); + + expect(contractGetOperatorFeeMock).toHaveBeenCalledTimes(1); + expect(response).toStrictEqual({ + layer1Fee: LAYER_1_FEE_MOCK, + }); + }); }); }); diff --git a/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.ts b/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.ts index 4648ed7f13e..78d986a2bc1 100644 --- a/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.ts +++ b/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.ts @@ -1,7 +1,8 @@ import { Contract } from '@ethersproject/contracts'; import { Web3Provider, type ExternalProvider } from '@ethersproject/providers'; import type { Hex } from '@metamask/utils'; -import { createModuleLogger } from '@metamask/utils'; +import { add0x, createModuleLogger } from '@metamask/utils'; +import BN from 'bn.js'; import { projectLogger } from '../logger'; import type { TransactionControllerMessenger } from '../TransactionController'; @@ -12,17 +13,52 @@ import type { TransactionMeta, } from '../types'; import { prepareTransaction } from '../utils/prepare'; +import { padHexToEvenLength, toBN } from '../utils/utils'; const log = createModuleLogger(projectLogger, 'oracle-layer1-gas-fee-flow'); +const ZERO = new BN(0); + const DUMMY_KEY = 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789'; const GAS_PRICE_ORACLE_ABI = [ { - inputs: [{ internalType: 'bytes', name: '_data', type: 'bytes' }], + inputs: [ + { + internalType: 'bytes', + name: '_data', + type: 'bytes', + }, + ], name: 'getL1Fee', - outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + outputs: [ + { + internalType: 'uint256', + name: '', + type: 'uint256', + }, + ], + stateMutability: 'view', + type: 'function', + }, + // only available post Isthmus + { + inputs: [ + { + internalType: 'uint256', + name: '_gasUsed', + type: 'uint256', + }, + ], + name: 'getOperatorFee', + outputs: [ + { + internalType: 'uint256', + name: '', + type: 'uint256', + }, + ], stateMutability: 'view', type: 'function', }, @@ -73,7 +109,27 @@ export abstract class OracleLayer1GasFeeFlow implements Layer1GasFeeFlow { request: Layer1GasFeeFlowRequest, ): Promise { try { - return await this.#getOracleLayer1GasFee(request); + const { provider, transactionMeta } = request; + + const contract = this.#getGasPriceOracleContract( + provider, + transactionMeta.chainId, + ); + + const oracleFee = await this.#getOracleLayer1GasFee( + contract, + transactionMeta, + ); + const operatorFee = await this.#getOperatorLayer1GasFee( + contract, + transactionMeta, + ); + + return { + layer1Fee: add0x( + padHexToEvenLength(oracleFee.add(operatorFee).toString(16)), + ) as Hex, + }; } catch (error) { log('Failed to get oracle layer 1 gas fee', error); throw new Error(`Failed to get oracle layer 1 gas fee`); @@ -81,17 +137,9 @@ export abstract class OracleLayer1GasFeeFlow implements Layer1GasFeeFlow { } async #getOracleLayer1GasFee( - request: Layer1GasFeeFlowRequest, - ): Promise { - const { provider, transactionMeta } = request; - - const contract = new Contract( - this.getOracleAddressForChain(transactionMeta.chainId), - GAS_PRICE_ORACLE_ABI, - // Network controller provider type is incompatible with ethers provider - new Web3Provider(provider as unknown as ExternalProvider), - ); - + contract: Contract, + transactionMeta: TransactionMeta, + ): Promise { const serializedTransaction = this.#buildUnserializedTransaction( transactionMeta, this.shouldSignTransaction(), @@ -103,9 +151,31 @@ export abstract class OracleLayer1GasFeeFlow implements Layer1GasFeeFlow { throw new Error('No value returned from oracle contract'); } - return { - layer1Fee: result.toHexString(), - }; + return toBN(result); + } + + async #getOperatorLayer1GasFee( + contract: Contract, + transactionMeta: TransactionMeta, + ): Promise { + const { gasUsed } = transactionMeta; + + if (!gasUsed) { + return ZERO; + } + + try { + const result = await contract.getOperatorFee(gasUsed); + + if (result === undefined) { + return ZERO; + } + + return toBN(result); + } catch (error) { + log('Failed to get operator layer 1 gas fee', error); + return ZERO; + } } #buildUnserializedTransaction( @@ -134,4 +204,16 @@ export abstract class OracleLayer1GasFeeFlow implements Layer1GasFeeFlow { gasLimit: transactionMeta.txParams.gas, }; } + + #getGasPriceOracleContract( + provider: Layer1GasFeeFlowRequest['provider'], + chainId: Hex, + ) { + return new Contract( + this.getOracleAddressForChain(chainId), + GAS_PRICE_ORACLE_ABI, + // Network controller provider type is incompatible with ethers provider + new Web3Provider(provider as unknown as ExternalProvider), + ); + } } diff --git a/packages/transaction-controller/src/utils/utils.test.ts b/packages/transaction-controller/src/utils/utils.test.ts index 3b6c124c41e..fbdd94576db 100644 --- a/packages/transaction-controller/src/utils/utils.test.ts +++ b/packages/transaction-controller/src/utils/utils.test.ts @@ -1,4 +1,4 @@ -import { BN } from 'bn.js'; +import BN from 'bn.js'; import * as util from './utils'; import type { @@ -274,4 +274,85 @@ describe('utils', () => { expect(util.getPercentageChange(new BN(2), new BN(-1))).toBe(150); }); }); + + describe('bnFromHex', () => { + it('parses hex with 0x prefix', () => { + const result = util.bnFromHex('0x1a'); + expect(result.eq(new BN(26))).toBe(true); + }); + + it('parses hex without prefix', () => { + const result = util.bnFromHex('1a'); + expect(result.eq(new BN(26))).toBe(true); + }); + + it('parses uppercase 0X prefix', () => { + const result = util.bnFromHex('0XFF'); + expect(result.eq(new BN(255))).toBe(true); + }); + + it('returns zero for empty data with 0x', () => { + const result = util.bnFromHex('0x'); + expect(result.isZero()).toBe(true); + }); + + it('returns zero for empty string', () => { + const result = util.bnFromHex(''); + expect(result.isZero()).toBe(true); + }); + + it('throws for invalid hex', () => { + expect(() => util.bnFromHex('0xzz')).toThrow(Error); + }); + }); + + describe('toBN', () => { + it('returns the same BN instance', () => { + const input = new BN(123); + const result = util.toBN(input); + expect(result).toBe(input); + }); + + it('converts ethers-like BigNumber with toHexString', () => { + const bigNumberLike = { toHexString: () => '0x2a' }; + const result = util.toBN(bigNumberLike); + expect(result.eq(new BN(42))).toBe(true); + }); + + it('converts object with _hex property', () => { + const hexLike = { _hex: '0x2a' }; + const result = util.toBN(hexLike); + expect(result.eq(new BN(42))).toBe(true); + }); + + it('converts hex string values', () => { + expect(util.toBN('0x10').eq(new BN(16))).toBe(true); + expect(util.toBN('10').eq(new BN(16))).toBe(true); + }); + + it('converts bigint values', () => { + const result = util.toBN(123n); + expect(result.eq(new BN(123))).toBe(true); + }); + + it('converts number values', () => { + const result = util.toBN(456); + expect(result.eq(new BN(456))).toBe(true); + }); + + it('throws for unsupported types', () => { + expect(() => util.toBN(true as unknown)).toThrow( + 'Unexpected value returned from oracle contract', + ); + expect(() => util.toBN(null as unknown)).toThrow( + 'Unexpected value returned from oracle contract', + ); + expect(() => util.toBN(undefined as unknown)).toThrow( + 'Unexpected value returned from oracle contract', + ); + expect(() => util.toBN({} as unknown)).toThrow( + 'Unexpected value returned from oracle contract', + ); + }); + }); }); diff --git a/packages/transaction-controller/src/utils/utils.ts b/packages/transaction-controller/src/utils/utils.ts index a33e11c65a3..672d4b21bd7 100644 --- a/packages/transaction-controller/src/utils/utils.ts +++ b/packages/transaction-controller/src/utils/utils.ts @@ -1,20 +1,20 @@ import type { AccessList, AuthorizationList } from '@ethereumjs/common'; +import type { Hex, Json } from '@metamask/utils'; import { add0x, getKnownPropertyNames, isStrictHexString, } from '@metamask/utils'; -import type { Json } from '@metamask/utils'; import BN from 'bn.js'; -import { TransactionEnvelopeType, TransactionStatus } from '../types'; import type { - TransactionParams, - TransactionMeta, - TransactionError, - GasPriceValue, FeeMarketEIP1559Values, + GasPriceValue, + TransactionError, + TransactionMeta, + TransactionParams, } from '../types'; +import { TransactionEnvelopeType, TransactionStatus } from '../types'; export const ESTIMATE_GAS_ERROR = 'eth_estimateGas rpc method error'; @@ -189,6 +189,66 @@ export function padHexToEvenLength(hex: string) { return prefix + evenData; } +/** + * Create a BN from a hex string, accepting an optional 0x prefix. + * + * @param hex - Hex string with or without 0x prefix. + * @returns BN parsed as base-16. + */ +export function bnFromHex(hex: string | Hex): BN { + const str = typeof hex === 'string' ? hex : (hex as string); + const withoutPrefix = + str.startsWith('0x') || str.startsWith('0X') ? str.slice(2) : str; + if (withoutPrefix.length === 0) { + return new BN(0); + } + return new BN(withoutPrefix, 16); +} + +/** + * Convert various numeric-like values to a BN instance. + * Accepts BN, ethers BigNumber, hex string, bigint, or number. + * + * @param value - The value to convert. + * @returns BN representation of the input value. + */ +export function toBN(value: unknown): BN { + if (value instanceof BN) { + return value as BN; + } + if ( + typeof (BN as unknown as { isBN?: (v: unknown) => boolean }).isBN === + 'function' && + (BN as unknown as { isBN: (v: unknown) => boolean }).isBN(value) + ) { + return value as BN; + } + if ( + value !== null && + typeof value === 'object' && + typeof (value as { toHexString?: () => string }).toHexString === 'function' + ) { + return bnFromHex((value as { toHexString: () => string }).toHexString()); + } + if ( + value !== null && + typeof value === 'object' && + typeof (value as { _hex?: string })._hex === 'string' + ) { + return bnFromHex((value as { _hex: string })._hex); + } + if (typeof value === 'string') { + return bnFromHex(value); + } + if (typeof value === 'bigint') { + return new BN(value.toString()); + } + if (typeof value === 'number') { + return new BN(value); + } + throw new Error('Unexpected value returned from oracle contract'); +} + /** * Calculate the absolute percentage change between two values. *