Skip to content

Commit ca8e021

Browse files
author
Antoine de Chevigné
committed
custom op L1 network
1 parent ae6c224 commit ca8e021

15 files changed

+1096
-101
lines changed

run/api/explorers.js

Lines changed: 219 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,91 @@ router.get('/availableOpParents', authMiddleware, async (req, res, next) => {
7272
}
7373
});
7474

75+
/**
76+
* Get available L1 parent workspaces for the user
77+
* Returns public L1s (Ethereum Mainnet, Arbitrum One) and user's custom L1s
78+
* @returns {Promise<object>} - Object with publicParents and customParents arrays
79+
*/
80+
router.get('/availableL1Parents', authMiddleware, async (req, res, next) => {
81+
try {
82+
const { publicParents, customParents } = await db.getAvailableL1Parents(req.body.data.user.id);
83+
84+
res.status(200).json({ publicParents, customParents });
85+
} catch (error) {
86+
return managedError(error, req, res);
87+
}
88+
});
89+
90+
/**
91+
* Create a custom L1 parent workspace
92+
* @param {string} name - Name for the custom L1 parent
93+
* @param {string} backendRpcServer - RPC server URL for backend sync
94+
* @returns {Promise<object>} - The created workspace
95+
*/
96+
router.post('/customL1Parent', authMiddleware, async (req, res, next) => {
97+
const data = req.body.data;
98+
99+
try {
100+
if (!data.name || !data.backendRpcServer)
101+
return managedError(new Error('Missing parameters. Name and backend RPC server are required.'), req, res);
102+
103+
// Validate RPC and fetch network ID
104+
let networkId;
105+
const provider = new ProviderConnector(data.backendRpcServer);
106+
try {
107+
networkId = await withTimeout(provider.fetchNetworkId());
108+
if (!networkId)
109+
throw 'Error';
110+
} catch(error) {
111+
return managedError(new Error(`Our servers can't query this RPC, please use an RPC that is reachable from the internet.`), req, res);
112+
}
113+
114+
let workspace;
115+
try {
116+
workspace = await db.createCustomL1Parent(data.user.id, {
117+
name: data.name,
118+
rpcServer: data.backendRpcServer,
119+
networkId: networkId
120+
});
121+
} catch(error) {
122+
if (error.name === 'SequelizeUniqueConstraintError') {
123+
return managedError(new Error(`A workspace with name "${data.name}" already exists. Please choose a different name.`), req, res);
124+
}
125+
throw error;
126+
}
127+
128+
res.status(200).json({
129+
id: workspace.id,
130+
name: workspace.name,
131+
networkId: workspace.networkId,
132+
rpcServer: workspace.rpcServer
133+
});
134+
} catch(error) {
135+
unmanagedError(error, req, next);
136+
}
137+
});
138+
139+
/**
140+
* Delete a custom L1 parent workspace
141+
* Fails if the workspace has L2 children (Orbit or OP Stack configs)
142+
* @param {number} id - Workspace ID
143+
* @returns {Promise<void>} - 200 on success
144+
*/
145+
router.delete('/customL1Parent/:id', authMiddleware, async (req, res, next) => {
146+
try {
147+
if (!req.params.id)
148+
return managedError(new Error('Missing parameters.'), req, res);
149+
150+
await db.deleteCustomL1Parent(req.body.data.user.id, parseInt(req.params.id));
151+
152+
res.sendStatus(200);
153+
} catch(error) {
154+
if (error.message.includes('Cannot delete') || error.message.includes('not found'))
155+
return managedError(error, req, res);
156+
unmanagedError(error, req, next);
157+
}
158+
});
159+
75160
router.get('/:id/orbitConfig', authMiddleware, async (req, res, next) => {
76161
const data = { ...req.query, ...req.params };
77162

@@ -177,26 +262,85 @@ router.post('/:id/orbitConfig', authMiddleware, async (req, res, next) => {
177262
if (currentConfig)
178263
return managedError(new Error('There is already an orbit config for this explorer.'), req, res);
179264

180-
if (!req.body.params.config.parentChainRpcServer)
181-
return managedError(new Error('Parent chain rpc server is required.'), req, res);
265+
const configInput = req.body.params.config;
266+
const userId = req.body.data.user.id;
267+
268+
// Validate parent chain selection:
269+
// - parentWorkspaceId: existing public or custom L1 parent
270+
// - customL1: { name, rpcServer } for creating new custom L1 inline
271+
// Note: Orbit always requires parentChainRpcServer (frontend RPC for browser withdrawal claims)
272+
const hasExistingParent = !!configInput.parentWorkspaceId;
273+
const hasNewCustomL1 = configInput.customL1 && configInput.customL1.name && configInput.customL1.rpcServer;
182274

275+
if (!hasExistingParent && !hasNewCustomL1)
276+
return managedError(new Error('Parent chain selection is required. Provide parentWorkspaceId or customL1 details.'), req, res);
277+
278+
if (!configInput.parentChainRpcServer)
279+
return managedError(new Error('Parent chain RPC server is required (for browser-based withdrawal claims).'), req, res);
280+
281+
// Validate the frontend RPC is reachable
183282
let networkId;
184-
const provider = new ProviderConnector(req.body.params.config.parentChainRpcServer);
283+
const provider = new ProviderConnector(configInput.parentChainRpcServer);
185284
try {
186285
networkId = await withTimeout(provider.fetchNetworkId());
187286
if (!networkId)
188287
throw 'Error';
189288
} catch(error) {
190-
return managedError(new Error(`Our servers can't query this rpc, please use a rpc that is reachable from the internet.`), req, res);
289+
return managedError(new Error(`Our servers can't query this RPC, please use an RPC that is reachable from the internet.`), req, res);
290+
}
291+
292+
// Prepare config params for model layer
293+
let configParams = { ...configInput, parentChainId: networkId };
294+
295+
// Handle custom L1 parent creation inline
296+
if (hasNewCustomL1) {
297+
// Validate backend RPC and fetch network ID
298+
let backendNetworkId;
299+
const backendProvider = new ProviderConnector(configInput.customL1.rpcServer);
300+
try {
301+
backendNetworkId = await withTimeout(backendProvider.fetchNetworkId());
302+
if (!backendNetworkId)
303+
throw 'Error';
304+
} catch(error) {
305+
return managedError(new Error(`Our servers can't query the backend RPC, please use an RPC that is reachable from the internet.`), req, res);
306+
}
307+
308+
// Create the custom L1 parent workspace
309+
let customL1Workspace;
310+
try {
311+
customL1Workspace = await db.createCustomL1Parent(userId, {
312+
name: configInput.customL1.name,
313+
rpcServer: configInput.customL1.rpcServer,
314+
networkId: backendNetworkId
315+
});
316+
} catch(error) {
317+
if (error.name === 'SequelizeUniqueConstraintError') {
318+
return managedError(new Error(`A workspace with name "${configInput.customL1.name}" already exists. Please choose a different name.`), req, res);
319+
}
320+
throw error;
321+
}
322+
323+
configParams.parentWorkspaceId = customL1Workspace.id;
324+
delete configParams.customL1;
191325
}
192326

193327
let config;
194328
try {
195-
config = await db.createOrbitConfig(req.body.data.user.id, data.id, { ...req.body.params.config, parentChainId: networkId });
329+
config = await db.createOrbitConfig(userId, data.id, configParams);
196330
} catch(error) {
197331
return managedError(error, req, res);
198332
}
199333

334+
// Start sync for custom L1 parent if applicable
335+
if (config.parentWorkspaceId) {
336+
await enqueue(
337+
'startCustomL1ParentSync',
338+
`startCustomL1ParentSync-${config.parentWorkspaceId}`,
339+
{ workspaceId: config.parentWorkspaceId },
340+
1
341+
);
342+
}
343+
200344
res.status(200).json({ config });
201345
} catch (error) {
202346
unmanagedError(error, req, res);
@@ -285,32 +429,89 @@ router.post('/:id/opConfig', authMiddleware, async (req, res, next) => {
285429
if (currentConfig)
286430
return managedError(new Error('There is already an OP config for this explorer.'), req, res);
287431

288-
// Validate networkId is provided and supported
289-
if (!req.body.params.config.networkId)
290-
return managedError(new Error('Network selection is required.'), req, res);
291-
292-
const networkId = parseInt(req.body.params.config.networkId);
293-
if (!isOpNetworkSupported(networkId))
294-
return managedError(new Error('Selected network is not supported. Only Ethereum Mainnet is currently available.'), req, res);
432+
const configInput = req.body.params.config;
433+
const userId = req.body.data.user.id;
434+
435+
// Validate parent chain selection:
436+
// - networkId: public L1 (legacy)
437+
// - parentWorkspaceId: existing custom L1 parent
438+
// - customL1: { name, rpcServer } for creating new custom L1 inline
439+
const hasPublicL1 = !!configInput.networkId;
440+
const hasExistingCustomL1 = !!configInput.parentWorkspaceId;
441+
const hasNewCustomL1 = configInput.customL1 && configInput.customL1.name && configInput.customL1.rpcServer;
442+
443+
if (!hasPublicL1 && !hasExistingCustomL1 && !hasNewCustomL1)
444+
return managedError(new Error('Parent chain selection is required. Provide networkId, parentWorkspaceId, or customL1 details.'), req, res);
445+
446+
// If using public L1 via networkId, validate it's supported
447+
if (hasPublicL1 && !hasExistingCustomL1 && !hasNewCustomL1) {
448+
const networkId = parseInt(configInput.networkId);
449+
if (!isOpNetworkSupported(networkId))
450+
return managedError(new Error('Selected network is not supported. Only Ethereum Mainnet is currently available.'), req, res);
451+
}
295452

296-
if (!req.body.params.config.batchInboxAddress)
453+
if (!configInput.batchInboxAddress)
297454
return managedError(new Error('Batch inbox address is required.'), req, res);
298455

299-
if (!req.body.params.config.optimismPortalAddress)
456+
if (!configInput.optimismPortalAddress)
300457
return managedError(new Error('Optimism portal address is required.'), req, res);
301458

302-
// Convert networkId to parentChainId for model layer
303-
let configParams = { ...req.body.params.config };
304-
configParams.parentChainId = networkId;
459+
// Prepare config params for model layer
460+
let configParams = { ...configInput };
461+
462+
// Handle custom L1 parent creation inline
463+
if (hasNewCustomL1) {
464+
// Validate RPC and fetch network ID
465+
let networkId;
466+
const provider = new ProviderConnector(configInput.customL1.rpcServer);
467+
try {
468+
networkId = await withTimeout(provider.fetchNetworkId());
469+
if (!networkId)
470+
throw 'Error';
471+
} catch(error) {
472+
return managedError(new Error(`Our servers can't query this RPC, please use an RPC that is reachable from the internet.`), req, res);
473+
}
474+
475+
// Create the custom L1 parent workspace
476+
let customL1Workspace;
477+
try {
478+
customL1Workspace = await db.createCustomL1Parent(userId, {
479+
name: configInput.customL1.name,
480+
rpcServer: configInput.customL1.rpcServer,
481+
networkId: networkId
482+
});
483+
} catch(error) {
484+
if (error.name === 'SequelizeUniqueConstraintError') {
485+
return managedError(new Error(`A workspace with name "${configInput.customL1.name}" already exists. Please choose a different name.`), req, res);
486+
}
487+
throw error;
488+
}
489+
490+
configParams.parentWorkspaceId = customL1Workspace.id;
491+
delete configParams.customL1;
492+
} else if (hasPublicL1 && !hasExistingCustomL1) {
493+
// Convert networkId to parentChainId for legacy public L1 path
494+
configParams.parentChainId = parseInt(configInput.networkId);
495+
}
305496
delete configParams.networkId;
306497

307498
let config;
308499
try {
309-
config = await db.createOpConfig(req.body.data.user.id, data.id, configParams);
500+
config = await db.createOpConfig(userId, data.id, configParams);
310501
} catch(error) {
311502
return managedError(error, req, res);
312503
}
313504

505+
// Start sync for custom L1 parent if applicable
506+
if (config.parentWorkspaceId) {
507+
await enqueue(
508+
'startCustomL1ParentSync',
509+
`startCustomL1ParentSync-${config.parentWorkspaceId}`,
510+
{ workspaceId: config.parentWorkspaceId },
511+
1
512+
);
513+
}
514+
314515
res.status(200).json({ config });
315516
} catch (error) {
316517
unmanagedError(error, req, next);

run/jobs/blockSync.js

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -61,17 +61,22 @@ module.exports = async job => {
6161
if (!workspace)
6262
return 'Invalid workspace.';
6363

64-
if (!workspace.explorer)
65-
return 'No active explorer for this workspace';
64+
// Custom L1 parents don't require explorer/subscription - they sync for their L2 children
65+
const isCustomL1Parent = workspace.isCustomL1Parent === true;
6666

67-
if (!workspace.explorer.shouldSync)
68-
return 'Sync is disabled';
67+
if (!isCustomL1Parent) {
68+
if (!workspace.explorer)
69+
return 'No active explorer for this workspace';
6970

70-
if (workspace.rpcHealthCheckEnabled && workspace.rpcHealthCheck && !workspace.rpcHealthCheck.isReachable)
71-
return 'RPC is not reachable';
71+
if (!workspace.explorer.shouldSync)
72+
return 'Sync is disabled';
7273

73-
if (!workspace.explorer.stripeSubscription)
74-
return 'No active subscription';
74+
if (workspace.rpcHealthCheckEnabled && workspace.rpcHealthCheck && !workspace.rpcHealthCheck.isReachable)
75+
return 'RPC is not reachable';
76+
77+
if (!workspace.explorer.stripeSubscription)
78+
return 'No active subscription';
79+
}
7580

7681
if (workspace.browserSyncEnabled)
7782
await db.updateBrowserSync(workspace.id, false);

run/jobs/finalizePendingOrbitBatches.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,14 @@ const { Op } = require('sequelize');
1010

1111
module.exports = async () => {
1212

13+
// Find all L1 parent workspaces (both public and custom)
1314
const workspaces = await Workspace.findAll({
14-
where: { isTopL1Parent: true },
15+
where: {
16+
[Op.or]: [
17+
{ isTopL1Parent: true },
18+
{ isCustomL1Parent: true }
19+
]
20+
},
1521
include: ['orbitChildConfigs']
1622
});
1723

run/jobs/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ module.exports = {
2424
linkOpDepositsToL2Txs: require('./linkOpDepositsToL2Txs'),
2525
storeOpDeposit: require('./storeOpDeposit'),
2626
storeOpOutput: require('./storeOpOutput'),
27+
startCustomL1ParentSync: require('./startCustomL1ParentSync'),
2728

2829
// Medium Priority
2930
processContract: require('./processContract'),

0 commit comments

Comments
 (0)