@@ -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+
75160router . 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 ) ;
0 commit comments