Skip to content

Commit 3a3cf8a

Browse files
feat: Dynamic OP Stack L1 gas via gas API
1 parent e4f2a09 commit 3a3cf8a

11 files changed

+338
-70
lines changed

packages/transaction-controller/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Changed
11+
12+
- Identify OP stack chains using gas API ([#6899](https://github.com/MetaMask/core/pull/6899))
13+
1014
## [60.10.0]
1115

1216
### Added

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

Lines changed: 121 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,139 @@
1+
import * as ControllerUtils from '@metamask/controller-utils';
2+
import { hexToNumber, type Hex } from '@metamask/utils';
3+
14
import { OptimismLayer1GasFeeFlow } from './OptimismLayer1GasFeeFlow';
25
import { CHAIN_IDS } from '../constants';
36
import type { TransactionControllerMessenger } from '../TransactionController';
47
import type { TransactionMeta } from '../types';
58
import { TransactionStatus } from '../types';
69

7-
const TRANSACTION_META_MOCK: TransactionMeta = {
8-
id: '1',
9-
chainId: CHAIN_IDS.OPTIMISM,
10-
networkClientId: 'testNetworkClientId',
11-
status: TransactionStatus.unapproved,
12-
time: 0,
13-
txParams: {
14-
from: '0x123',
15-
gas: '0x1234',
16-
},
10+
jest.mock('@metamask/controller-utils', () => {
11+
const actual = jest.requireActual('@metamask/controller-utils');
12+
return { ...actual, handleFetch: jest.fn() };
13+
});
14+
15+
type SupportedNetworksResponseMock = {
16+
fullSupport: number[];
17+
partialSupport: { optimism: number[] };
1718
};
1819

20+
/**
21+
* Creates a minimal `TransactionMeta` object for testing with the provided chain ID.
22+
*
23+
* @param chainId - The hex-encoded chain ID to set on the transaction.
24+
* @returns A `TransactionMeta` stub suitable for tests.
25+
*/
26+
function createTransaction(chainId: string): TransactionMeta {
27+
return {
28+
id: '1',
29+
chainId: chainId as Hex,
30+
networkClientId: 'testNetworkClientId',
31+
status: TransactionStatus.unapproved,
32+
time: 0,
33+
txParams: {
34+
from: '0x123',
35+
gas: '0x1234',
36+
},
37+
};
38+
}
39+
1940
describe('OptimismLayer1GasFeeFlow', () => {
41+
beforeEach(() => {
42+
jest.clearAllMocks();
43+
});
44+
const messenger = {} as TransactionControllerMessenger;
45+
2046
describe('matchesTransaction', () => {
47+
let handleFetchMock: jest.MockedFunction<
48+
(url: string, init?: unknown) => Promise<SupportedNetworksResponseMock>
49+
>;
50+
51+
beforeEach(() => {
52+
handleFetchMock = ControllerUtils.handleFetch as jest.MockedFunction<
53+
(url: string, init?: unknown) => Promise<SupportedNetworksResponseMock>
54+
>;
55+
handleFetchMock.mockReset();
56+
});
57+
2158
it.each([
22-
['Optimisim mainnet', CHAIN_IDS.OPTIMISM],
23-
['Optimisim testnet', CHAIN_IDS.OPTIMISM_TESTNET],
24-
])('returns true if chain ID is %s', (_title, chainId) => {
59+
['Optimism mainnet', CHAIN_IDS.OPTIMISM],
60+
['Optimism testnet', CHAIN_IDS.OPTIMISM_TESTNET],
61+
])(
62+
'uses the fallback list when remote fetch fails for %s',
63+
async (_title: string, chainId: string) => {
64+
handleFetchMock.mockRejectedValue(new Error('ignore'));
65+
const flow = new OptimismLayer1GasFeeFlow();
66+
const transactionMeta = createTransaction(chainId);
67+
68+
expect(
69+
await flow.matchesTransaction({
70+
transactionMeta,
71+
messenger,
72+
}),
73+
).toBe(true);
74+
},
75+
);
76+
77+
it('returns true when the remote list contains the chain', async () => {
78+
handleFetchMock.mockResolvedValue({
79+
fullSupport: [],
80+
partialSupport: { optimism: [hexToNumber(CHAIN_IDS.OPTIMISM)] },
81+
});
2582
const flow = new OptimismLayer1GasFeeFlow();
83+
const transactionMeta = createTransaction(CHAIN_IDS.OPTIMISM);
84+
85+
expect(
86+
await flow.matchesTransaction({
87+
transactionMeta,
88+
messenger,
89+
}),
90+
).toBe(true);
91+
});
2692

27-
const transaction = {
28-
...TRANSACTION_META_MOCK,
29-
chainId,
30-
};
93+
it('falls back to static list when remote list omits the chain', async () => {
94+
handleFetchMock.mockResolvedValue({
95+
fullSupport: [],
96+
partialSupport: { optimism: [] },
97+
});
98+
const flow = new OptimismLayer1GasFeeFlow();
99+
const transactionMeta = createTransaction(CHAIN_IDS.BASE);
100+
101+
expect(
102+
await flow.matchesTransaction({
103+
transactionMeta,
104+
messenger,
105+
}),
106+
).toBe(true);
107+
});
108+
109+
it('returns false when neither remote nor fallback include the chain', async () => {
110+
handleFetchMock.mockResolvedValue({
111+
fullSupport: [],
112+
partialSupport: { optimism: [] },
113+
});
114+
const flow = new OptimismLayer1GasFeeFlow();
115+
const transactionMeta = createTransaction('0x9999');
116+
117+
expect(
118+
await flow.matchesTransaction({
119+
transactionMeta,
120+
messenger,
121+
}),
122+
).toBe(false);
123+
});
124+
125+
it('uses the fallback list when the remote payload is missing', async () => {
126+
handleFetchMock.mockResolvedValue(
127+
undefined as unknown as SupportedNetworksResponseMock,
128+
);
129+
130+
const flow = new OptimismLayer1GasFeeFlow();
131+
const transactionMeta = createTransaction(CHAIN_IDS.ZORA);
31132

32133
expect(
33-
flow.matchesTransaction({
34-
transactionMeta: transaction,
35-
messenger: {} as TransactionControllerMessenger,
134+
await flow.matchesTransaction({
135+
transactionMeta,
136+
messenger,
36137
}),
37138
).toBe(true);
38139
});
Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import { type Hex } from '@metamask/utils';
1+
import { handleFetch } from '@metamask/controller-utils';
2+
import { type Hex, hexToNumber } from '@metamask/utils';
23

34
import { OracleLayer1GasFeeFlow } from './OracleLayer1GasFeeFlow';
45
import { CHAIN_IDS } from '../constants';
56
import type { TransactionControllerMessenger } from '../TransactionController';
67
import type { TransactionMeta } from '../types';
78

8-
const OPTIMISM_STACK_CHAIN_IDS: Hex[] = [
9+
const FALLBACK_OPTIMISM_STACK_CHAIN_IDS: Hex[] = [
910
CHAIN_IDS.OPTIMISM,
1011
CHAIN_IDS.OPTIMISM_TESTNET,
1112
CHAIN_IDS.BASE,
@@ -15,24 +16,56 @@ const OPTIMISM_STACK_CHAIN_IDS: Hex[] = [
1516
CHAIN_IDS.ZORA,
1617
];
1718

18-
// BlockExplorer link: https://optimistic.etherscan.io/address/0x420000000000000000000000000000000000000f#code
19-
const OPTIMISM_GAS_PRICE_ORACLE_ADDRESS =
20-
'0x420000000000000000000000000000000000000F';
19+
// Default oracle address now provided by base class
20+
21+
type SupportedNetworksResponse = {
22+
readonly fullSupport: readonly number[];
23+
readonly partialSupport: {
24+
readonly optimism: readonly number[];
25+
};
26+
};
27+
28+
const GAS_SUPPORTED_NETWORKS_ENDPOINT =
29+
'https://gas.api.cx.metamask.io/v1/supportedNetworks';
2130

2231
/**
2332
* Optimism layer 1 gas fee flow that obtains gas fee estimate using an oracle contract.
2433
*/
2534
export class OptimismLayer1GasFeeFlow extends OracleLayer1GasFeeFlow {
26-
constructor() {
27-
super(OPTIMISM_GAS_PRICE_ORACLE_ADDRESS);
28-
}
29-
30-
matchesTransaction({
35+
async matchesTransaction({
3136
transactionMeta,
3237
}: {
3338
transactionMeta: TransactionMeta;
3439
messenger: TransactionControllerMessenger;
35-
}): boolean {
36-
return OPTIMISM_STACK_CHAIN_IDS.includes(transactionMeta.chainId);
40+
}): Promise<boolean> {
41+
const chainIdAsNumber = hexToNumber(transactionMeta.chainId);
42+
43+
const supportedChains =
44+
await OptimismLayer1GasFeeFlow.fetchOptimismSupportedChains();
45+
46+
if (supportedChains?.has(chainIdAsNumber)) {
47+
return true;
48+
}
49+
50+
return FALLBACK_OPTIMISM_STACK_CHAIN_IDS.includes(transactionMeta.chainId);
51+
}
52+
53+
// Uses default oracle address from base class
54+
55+
/**
56+
* Fetch remote OP-stack support list; fall back to local list when unavailable.
57+
*
58+
* @returns A set of supported OP-stack chain IDs or null on failure.
59+
*/
60+
private static async fetchOptimismSupportedChains(): Promise<Set<number> | null> {
61+
try {
62+
const res: SupportedNetworksResponse = await handleFetch(
63+
GAS_SUPPORTED_NETWORKS_ENDPOINT,
64+
);
65+
const list = res?.partialSupport?.optimism ?? [];
66+
return new Set<number>(list);
67+
} catch {
68+
return null;
69+
}
3770
}
3871
}

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

Lines changed: 69 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ 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';
56

67
import { OracleLayer1GasFeeFlow } from './OracleLayer1GasFeeFlow';
78
import { CHAIN_IDS } from '../constants';
9+
import type { TransactionControllerMessenger } from '../TransactionController';
810
import type { Layer1GasFeeFlowRequest, TransactionMeta } from '../types';
911
import { TransactionStatus } from '../types';
1012

@@ -33,8 +35,10 @@ const TRANSACTION_META_MOCK: TransactionMeta = {
3335
};
3436

3537
const SERIALIZED_TRANSACTION_MOCK = '0x1234';
36-
const ORACLE_ADDRESS_MOCK = '0x5678';
38+
const ORACLE_ADDRESS_MOCK = '0x5678' as Hex;
3739
const LAYER_1_FEE_MOCK = '0x9ABCD';
40+
const DEFAULT_GAS_PRICE_ORACLE_ADDRESS =
41+
'0x420000000000000000000000000000000000000F';
3842

3943
/**
4044
* Creates a mock TypedTransaction object.
@@ -54,7 +58,40 @@ function createMockTypedTransaction(serializedBuffer: Buffer) {
5458
}
5559

5660
class MockOracleLayer1GasFeeFlow extends OracleLayer1GasFeeFlow {
57-
matchesTransaction(): boolean {
61+
readonly #sign: boolean;
62+
63+
constructor(sign: boolean) {
64+
super();
65+
this.#sign = sign;
66+
}
67+
68+
async matchesTransaction({
69+
transactionMeta: _transactionMeta,
70+
messenger: _messenger,
71+
}: {
72+
transactionMeta: TransactionMeta;
73+
messenger: TransactionControllerMessenger;
74+
}): Promise<boolean> {
75+
return true;
76+
}
77+
78+
protected override getOracleAddressForChain(): Hex {
79+
return ORACLE_ADDRESS_MOCK;
80+
}
81+
82+
protected override shouldSignTransaction(): boolean {
83+
return this.#sign;
84+
}
85+
}
86+
87+
class DefaultOracleLayer1GasFeeFlow extends OracleLayer1GasFeeFlow {
88+
async matchesTransaction({
89+
transactionMeta: _transactionMeta,
90+
messenger: _messenger,
91+
}: {
92+
transactionMeta: TransactionMeta;
93+
messenger: TransactionControllerMessenger;
94+
}): Promise<boolean> {
5895
return true;
5996
}
6097
}
@@ -73,6 +110,9 @@ describe('OracleLayer1GasFeeFlow', () => {
73110
transactionMeta: TRANSACTION_META_MOCK,
74111
};
75112

113+
contractMock.mockClear();
114+
contractGetL1FeeMock.mockClear();
115+
76116
contractGetL1FeeMock.mockResolvedValue({
77117
toHexString: () => LAYER_1_FEE_MOCK,
78118
});
@@ -95,7 +135,7 @@ describe('OracleLayer1GasFeeFlow', () => {
95135
createMockTypedTransaction(serializedTransactionMock),
96136
);
97137

98-
const flow = new MockOracleLayer1GasFeeFlow(ORACLE_ADDRESS_MOCK, false);
138+
const flow = new MockOracleLayer1GasFeeFlow(false);
99139
const response = await flow.getLayer1Fee(request);
100140

101141
expect(response).toStrictEqual({
@@ -132,7 +172,7 @@ describe('OracleLayer1GasFeeFlow', () => {
132172
.spyOn(TransactionFactory, 'fromTxData')
133173
.mockReturnValueOnce(typedTransactionMock);
134174

135-
const flow = new MockOracleLayer1GasFeeFlow(ORACLE_ADDRESS_MOCK, true);
175+
const flow = new MockOracleLayer1GasFeeFlow(true);
136176
const response = await flow.getLayer1Fee(request);
137177

138178
expect(response).toStrictEqual({
@@ -146,7 +186,7 @@ describe('OracleLayer1GasFeeFlow', () => {
146186
it('if getL1Fee fails', async () => {
147187
contractGetL1FeeMock.mockRejectedValue(new Error('error'));
148188

149-
const flow = new MockOracleLayer1GasFeeFlow(ORACLE_ADDRESS_MOCK, false);
189+
const flow = new MockOracleLayer1GasFeeFlow(false);
150190

151191
await expect(flow.getLayer1Fee(request)).rejects.toThrow(
152192
'Failed to get oracle layer 1 gas fee',
@@ -158,12 +198,35 @@ describe('OracleLayer1GasFeeFlow', () => {
158198
undefined as unknown as ReturnType<typeof contractGetL1FeeMock>,
159199
);
160200

161-
const flow = new MockOracleLayer1GasFeeFlow(ORACLE_ADDRESS_MOCK, false);
201+
const flow = new MockOracleLayer1GasFeeFlow(false);
162202

163203
await expect(flow.getLayer1Fee(request)).rejects.toThrow(
164204
'Failed to get oracle layer 1 gas fee',
165205
);
166206
});
167207
});
208+
209+
it('uses default oracle configuration when subclasses do not override helpers', async () => {
210+
const serializedTransactionMock = Buffer.from(
211+
SERIALIZED_TRANSACTION_MOCK,
212+
'hex',
213+
);
214+
215+
const typedTransactionMock = createMockTypedTransaction(
216+
serializedTransactionMock,
217+
);
218+
219+
jest
220+
.spyOn(TransactionFactory, 'fromTxData')
221+
.mockReturnValueOnce(typedTransactionMock);
222+
223+
const flow = new DefaultOracleLayer1GasFeeFlow();
224+
await flow.getLayer1Fee(request);
225+
226+
expect(contractMock).toHaveBeenCalledTimes(1);
227+
const [oracleAddress] = contractMock.mock.calls[0];
228+
expect(oracleAddress).toBe(DEFAULT_GAS_PRICE_ORACLE_ADDRESS);
229+
expect(typedTransactionMock.sign).not.toHaveBeenCalled();
230+
});
168231
});
169232
});

0 commit comments

Comments
 (0)