Skip to content

Commit d8a13b8

Browse files
feat: introduce operator fee for OP stack networks
1 parent e3af02a commit d8a13b8

File tree

4 files changed

+213
-33
lines changed

4 files changed

+213
-33
lines changed

packages/transaction-controller/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
### Added
1313

1414
- Add optional `isIntentComplete` property to `TransactionMeta` to indicate transaction outcome was achieved via an alternate chain or mechanism ([#6950](https://github.com/MetaMask/core/pull/6950))
15+
- Calculate operator fee for OP stack networks and include it in `layer1GasFee` ([#6979](https://github.com/MetaMask/core/pull/6979))
1516

1617
### Changed
1718

packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.test.ts

Lines changed: 89 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@ import type { TypedTransaction } from '@ethereumjs/tx';
22
import { TransactionFactory } from '@ethereumjs/tx';
33
import { Contract } from '@ethersproject/contracts';
44
import type { Provider } from '@metamask/network-controller';
5-
import type { Hex } from '@metamask/utils';
5+
import { add0x, type Hex } from '@metamask/utils';
6+
import BN from 'bn.js';
67

7-
import { OracleLayer1GasFeeFlow } from './OracleLayer1GasFeeFlow';
88
import { CHAIN_IDS } from '../constants';
99
import type { TransactionControllerMessenger } from '../TransactionController';
1010
import type { Layer1GasFeeFlowRequest, TransactionMeta } from '../types';
1111
import { TransactionStatus } from '../types';
12+
import { bnFromHex, padHexToEvenLength } from '../utils/utils';
13+
import { OracleLayer1GasFeeFlow } from './OracleLayer1GasFeeFlow';
1214

1315
jest.mock('@ethersproject/contracts', () => ({
1416
Contract: jest.fn(),
@@ -36,7 +38,8 @@ const TRANSACTION_META_MOCK: TransactionMeta = {
3638

3739
const SERIALIZED_TRANSACTION_MOCK = '0x1234';
3840
const ORACLE_ADDRESS_MOCK = '0x5678' as Hex;
39-
const LAYER_1_FEE_MOCK = '0x9ABCD';
41+
const LAYER_1_FEE_MOCK = '0x09abcd';
42+
const OPERATOR_FEE_MOCK = '0x5';
4043
const DEFAULT_GAS_PRICE_ORACLE_ADDRESS =
4144
'0x420000000000000000000000000000000000000F';
4245

@@ -98,9 +101,9 @@ class DefaultOracleLayer1GasFeeFlow extends OracleLayer1GasFeeFlow {
98101

99102
describe('OracleLayer1GasFeeFlow', () => {
100103
const contractMock = jest.mocked(Contract);
101-
const contractGetL1FeeMock: jest.MockedFn<
102-
() => Promise<{ toHexString: () => string }>
103-
> = jest.fn();
104+
const contractGetL1FeeMock: jest.MockedFn<() => Promise<BN>> = jest.fn();
105+
const contractGetOperatorFeeMock: jest.MockedFn<() => Promise<BN>> =
106+
jest.fn();
104107

105108
let request: Layer1GasFeeFlowRequest;
106109

@@ -112,13 +115,14 @@ describe('OracleLayer1GasFeeFlow', () => {
112115

113116
contractMock.mockClear();
114117
contractGetL1FeeMock.mockClear();
118+
contractGetOperatorFeeMock.mockClear();
115119

116-
contractGetL1FeeMock.mockResolvedValue({
117-
toHexString: () => LAYER_1_FEE_MOCK,
118-
});
120+
contractGetL1FeeMock.mockResolvedValue(bnFromHex(LAYER_1_FEE_MOCK));
121+
contractGetOperatorFeeMock.mockResolvedValue(new BN(0));
119122

120123
contractMock.mockReturnValue({
121124
getL1Fee: contractGetL1FeeMock,
125+
getOperatorFee: contractGetOperatorFeeMock,
122126
} as unknown as Contract);
123127
});
124128

@@ -156,6 +160,7 @@ describe('OracleLayer1GasFeeFlow', () => {
156160
expect(contractGetL1FeeMock).toHaveBeenCalledWith(
157161
serializedTransactionMock,
158162
);
163+
expect(contractGetOperatorFeeMock).not.toHaveBeenCalled();
159164
});
160165

161166
it('signs transaction with dummy key if supported by flow', async () => {
@@ -180,6 +185,7 @@ describe('OracleLayer1GasFeeFlow', () => {
180185
});
181186

182187
expect(typedTransactionMock.sign).toHaveBeenCalledTimes(1);
188+
expect(contractGetOperatorFeeMock).not.toHaveBeenCalled();
183189
});
184190

185191
describe('throws', () => {
@@ -228,5 +234,79 @@ describe('OracleLayer1GasFeeFlow', () => {
228234
expect(oracleAddress).toBe(DEFAULT_GAS_PRICE_ORACLE_ADDRESS);
229235
expect(typedTransactionMock.sign).not.toHaveBeenCalled();
230236
});
237+
238+
it('adds operator fee when gas used is available', async () => {
239+
const gasUsed = '0x5208';
240+
request = {
241+
...request,
242+
transactionMeta: {
243+
...request.transactionMeta,
244+
gasUsed,
245+
},
246+
};
247+
248+
contractGetOperatorFeeMock.mockResolvedValueOnce(
249+
bnFromHex(OPERATOR_FEE_MOCK),
250+
);
251+
252+
const flow = new MockOracleLayer1GasFeeFlow(false);
253+
const response = await flow.getLayer1Fee(request);
254+
255+
expect(contractGetOperatorFeeMock).toHaveBeenCalledTimes(1);
256+
expect(contractGetOperatorFeeMock).toHaveBeenCalledWith(gasUsed);
257+
expect(response).toStrictEqual({
258+
layer1Fee: add0x(
259+
padHexToEvenLength(
260+
bnFromHex(LAYER_1_FEE_MOCK)
261+
.add(bnFromHex(OPERATOR_FEE_MOCK))
262+
.toString(16),
263+
),
264+
) as Hex,
265+
});
266+
});
267+
268+
it('defaults operator fee to zero when call fails', async () => {
269+
const gasUsed = '0x1';
270+
request = {
271+
...request,
272+
transactionMeta: {
273+
...request.transactionMeta,
274+
gasUsed,
275+
},
276+
};
277+
278+
contractGetOperatorFeeMock.mockRejectedValueOnce(new Error('revert'));
279+
280+
const flow = new MockOracleLayer1GasFeeFlow(false);
281+
const response = await flow.getLayer1Fee(request);
282+
283+
expect(contractGetOperatorFeeMock).toHaveBeenCalledTimes(1);
284+
expect(response).toStrictEqual({
285+
layer1Fee: LAYER_1_FEE_MOCK,
286+
});
287+
});
288+
289+
it('defaults operator fee to zero when call returns undefined', async () => {
290+
const gasUsed = '0x2';
291+
request = {
292+
...request,
293+
transactionMeta: {
294+
...request.transactionMeta,
295+
gasUsed,
296+
},
297+
};
298+
299+
contractGetOperatorFeeMock.mockResolvedValueOnce(
300+
undefined as unknown as BN,
301+
);
302+
303+
const flow = new MockOracleLayer1GasFeeFlow(false);
304+
const response = await flow.getLayer1Fee(request);
305+
306+
expect(contractGetOperatorFeeMock).toHaveBeenCalledTimes(1);
307+
expect(response).toStrictEqual({
308+
layer1Fee: LAYER_1_FEE_MOCK,
309+
});
310+
});
231311
});
232312
});

packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.ts

Lines changed: 100 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { Contract } from '@ethersproject/contracts';
22
import { Web3Provider, type ExternalProvider } from '@ethersproject/providers';
33
import type { Hex } from '@metamask/utils';
4-
import { createModuleLogger } from '@metamask/utils';
4+
import { add0x, createModuleLogger } from '@metamask/utils';
5+
import BN from 'bn.js';
56

67
import { projectLogger } from '../logger';
78
import type { TransactionControllerMessenger } from '../TransactionController';
@@ -12,17 +13,52 @@ import type {
1213
TransactionMeta,
1314
} from '../types';
1415
import { prepareTransaction } from '../utils/prepare';
16+
import { bnFromHex, padHexToEvenLength } from '../utils/utils';
1517

1618
const log = createModuleLogger(projectLogger, 'oracle-layer1-gas-fee-flow');
1719

20+
const ZERO = new BN(0);
21+
1822
const DUMMY_KEY =
1923
'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789';
2024

2125
const GAS_PRICE_ORACLE_ABI = [
2226
{
23-
inputs: [{ internalType: 'bytes', name: '_data', type: 'bytes' }],
27+
inputs: [
28+
{
29+
internalType: 'bytes',
30+
name: '_data',
31+
type: 'bytes',
32+
},
33+
],
2434
name: 'getL1Fee',
25-
outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }],
35+
outputs: [
36+
{
37+
internalType: 'uint256',
38+
name: '',
39+
type: 'uint256',
40+
},
41+
],
42+
stateMutability: 'view',
43+
type: 'function',
44+
},
45+
// only available post Isthmus
46+
{
47+
inputs: [
48+
{
49+
internalType: 'uint256',
50+
name: '_gasUsed',
51+
type: 'uint256',
52+
},
53+
],
54+
name: 'getOperatorFee',
55+
outputs: [
56+
{
57+
internalType: 'uint256',
58+
name: '',
59+
type: 'uint256',
60+
},
61+
],
2662
stateMutability: 'view',
2763
type: 'function',
2864
},
@@ -73,25 +109,37 @@ export abstract class OracleLayer1GasFeeFlow implements Layer1GasFeeFlow {
73109
request: Layer1GasFeeFlowRequest,
74110
): Promise<Layer1GasFeeFlowResponse> {
75111
try {
76-
return await this.#getOracleLayer1GasFee(request);
112+
const { provider, transactionMeta } = request;
113+
114+
const contract = this.#getGasPriceOracleContract(
115+
provider,
116+
transactionMeta.chainId,
117+
);
118+
119+
const oracleFee = await this.#getOracleLayer1GasFee(
120+
contract,
121+
transactionMeta,
122+
);
123+
const operatorFee = await this.#getOperatorLayer1GasFee(
124+
contract,
125+
transactionMeta,
126+
);
127+
128+
return {
129+
layer1Fee: add0x(
130+
padHexToEvenLength(oracleFee.add(operatorFee).toString(16)),
131+
) as Hex,
132+
};
77133
} catch (error) {
78134
log('Failed to get oracle layer 1 gas fee', error);
79135
throw new Error(`Failed to get oracle layer 1 gas fee`);
80136
}
81137
}
82138

83139
async #getOracleLayer1GasFee(
84-
request: Layer1GasFeeFlowRequest,
85-
): Promise<Layer1GasFeeFlowResponse> {
86-
const { provider, transactionMeta } = request;
87-
88-
const contract = new Contract(
89-
this.getOracleAddressForChain(transactionMeta.chainId),
90-
GAS_PRICE_ORACLE_ABI,
91-
// Network controller provider type is incompatible with ethers provider
92-
new Web3Provider(provider as unknown as ExternalProvider),
93-
);
94-
140+
contract: Contract,
141+
transactionMeta: TransactionMeta,
142+
): Promise<BN> {
95143
const serializedTransaction = this.#buildUnserializedTransaction(
96144
transactionMeta,
97145
this.shouldSignTransaction(),
@@ -103,9 +151,31 @@ export abstract class OracleLayer1GasFeeFlow implements Layer1GasFeeFlow {
103151
throw new Error('No value returned from oracle contract');
104152
}
105153

106-
return {
107-
layer1Fee: result.toHexString(),
108-
};
154+
return bnFromHex(result);
155+
}
156+
157+
async #getOperatorLayer1GasFee(
158+
contract: Contract,
159+
transactionMeta: TransactionMeta,
160+
): Promise<BN> {
161+
const { gasUsed } = transactionMeta;
162+
163+
if (!gasUsed) {
164+
return ZERO;
165+
}
166+
167+
try {
168+
const result = await contract.getOperatorFee(gasUsed);
169+
170+
if (result === undefined) {
171+
return ZERO;
172+
}
173+
174+
return bnFromHex(result);
175+
} catch (error) {
176+
log('Failed to get operator layer 1 gas fee', error);
177+
return ZERO;
178+
}
109179
}
110180

111181
#buildUnserializedTransaction(
@@ -134,4 +204,16 @@ export abstract class OracleLayer1GasFeeFlow implements Layer1GasFeeFlow {
134204
gasLimit: transactionMeta.txParams.gas,
135205
};
136206
}
207+
208+
#getGasPriceOracleContract(
209+
provider: Layer1GasFeeFlowRequest['provider'],
210+
chainId: Hex,
211+
) {
212+
return new Contract(
213+
this.getOracleAddressForChain(chainId),
214+
GAS_PRICE_ORACLE_ABI,
215+
// Network controller provider type is incompatible with ethers provider
216+
new Web3Provider(provider as unknown as ExternalProvider),
217+
);
218+
}
137219
}

packages/transaction-controller/src/utils/utils.ts

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,20 @@
11
import type { AccessList, AuthorizationList } from '@ethereumjs/common';
2+
import type { Hex, Json } from '@metamask/utils';
23
import {
34
add0x,
45
getKnownPropertyNames,
56
isStrictHexString,
67
} from '@metamask/utils';
7-
import type { Json } from '@metamask/utils';
88
import BN from 'bn.js';
99

10-
import { TransactionEnvelopeType, TransactionStatus } from '../types';
1110
import type {
12-
TransactionParams,
13-
TransactionMeta,
14-
TransactionError,
15-
GasPriceValue,
1611
FeeMarketEIP1559Values,
12+
GasPriceValue,
13+
TransactionError,
14+
TransactionMeta,
15+
TransactionParams,
1716
} from '../types';
17+
import { TransactionEnvelopeType, TransactionStatus } from '../types';
1818

1919
export const ESTIMATE_GAS_ERROR = 'eth_estimateGas rpc method error';
2020

@@ -189,6 +189,23 @@ export function padHexToEvenLength(hex: string) {
189189
return prefix + evenData;
190190
}
191191

192+
/**
193+
* Create a BN from a hex string, accepting an optional 0x prefix.
194+
*
195+
* @param hex - Hex string with or without 0x prefix.
196+
* @returns BN parsed as base-16.
197+
*/
198+
export function bnFromHex(hex: string | Hex): BN {
199+
const str = typeof hex === 'string' ? hex : (hex as string);
200+
const withoutPrefix = str.startsWith('0x') || str.startsWith('0X')
201+
? str.slice(2)
202+
: str;
203+
if (withoutPrefix.length === 0) {
204+
return new BN(0);
205+
}
206+
return new BN(withoutPrefix, 16);
207+
}
208+
192209
/**
193210
* Calculate the absolute percentage change between two values.
194211
*

0 commit comments

Comments
 (0)