Skip to content

feat(realtime): L2 UserOps for bridge-out withdrawals#922

Open
smartprogrammer93 wants to merge 10 commits intosurge-real-time-provingfrom
feat/l2-userops-bridge-out
Open

feat(realtime): L2 UserOps for bridge-out withdrawals#922
smartprogrammer93 wants to merge 10 commits intosurge-real-time-provingfrom
feat/l2-userops-bridge-out

Conversation

@smartprogrammer93
Copy link
Copy Markdown
Contributor

@smartprogrammer93 smartprogrammer93 commented Mar 29, 2026

Summary

Adds L2 UserOp support to the catalyst sequencer with automatic chain routing and full status tracking.

Changes

  • Explicit `chainId` param in `surge_sendUserOp` RPC — routes to L1 or L2 based on the param (default L1, backwards compatible)
  • L2 UserOp execution — constructs signed L2 transactions forwarding calldata to the smart wallet on L2
  • L2 UserOp status tracking — L2 direct UserOps now get `ProvingBlock`, `Processing`, `Executed`/`Rejected` status updates (same as L1)
  • Status cleanup — entries removed from sled after 60s to prevent unbounded disk growth
  • Increased gas limits — processMessage and L2 UserOp txs use 3M gas (anchor stays at 1M)
  • Remove `disable_bridging` gate — bridge handler starts without the flag

How it works

  1. UI sends `surge_sendUserOp` with `chainId` field
  2. If `chainId` matches L2, catalyst constructs L2 tx and includes it in the next block
  3. After block execution, `find_l1_call()` detects any L2→L1 bridge messages and relays them
  4. Status tracked through proving → submission → confirmation

Related PRs

🤖 Generated with Claude Code

smartprogrammer93 and others added 2 commits March 29, 2026 05:10
Add the ability for users to submit UserOps that execute on L2, enabling
bridge-out functionality. The catalyst now processes both L1→L2 deposits
and L2→L1 withdrawals in the same block.

Changes:
- New `surge_sendL2UserOp` RPC method for submitting L2-targeted UserOps
- L2 UserOp execution transactions are constructed and included in L2 blocks
- After block execution, existing `find_l1_call()` detects the resulting
  bridge MessageSent events and relays them to L1 via processMessage
- Block building handles mixed deposit + withdrawal transactions
- Remove `disable_bridging` gate that prevented bridge handler startup

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…tion

Instead of separate RPC methods for L1 and L2 UserOps, the single
surge_sendUserOp endpoint now auto-detects the target chain by parsing
the EIP-712 signature in the executeBatch calldata.

The UserOpsSubmitter's EIP-712 domain includes chainId, so the signature
is only valid for one chain. We compute the EIP-712 digest for both L1
and L2 chain IDs, ecrecover each, and route accordingly:

- L1 signature → L1→L2 deposit flow (simulate on L1, processMessage on L2)
- L2 signature → L2 direct execution (UserOp tx in L2 block, L2→L1 relay via find_l1_call)

Both types can coexist in the same block.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
smartprogrammer93 and others added 3 commits March 29, 2026 07:29
…etection

The surge_sendUserOp RPC now accepts an optional chainId field in the
UserOp params. If chainId matches L2, the UserOp is executed directly
on L2. Otherwise defaults to L1 (backwards compatible).

Removes the EIP-712 signature parsing logic which was unreliable
(ecrecover always returns a non-zero address).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
processMessage and L2 UserOp transactions need more gas for operations
that deploy contracts (e.g. CREATE2 smart wallet deployment via bridge
relay). 1M gas was insufficient — the bridge's post-call gas check
was failing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The anchor tx has a required gas limit enforced by the L2 engine.
Revert anchor to 1M, keep processMessage and UserOp txs at 3M.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
L2 direct UserOps now get ProvingBlock and Executed/Rejected status
updates, same as L1 UserOps. Added l2_user_op_ids to Proposal struct
and included them in the async submitter's status tracking.

Also adds cleanup: status entries are removed from sled after 60s
to prevent unbounded disk growth.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@smartprogrammer93
Copy link
Copy Markdown
Contributor Author

@claude review

@claude
Copy link
Copy Markdown

claude bot commented Mar 29, 2026

Claude finished @smartprogrammer93's task in 2m 11s —— View job


Code Review: feat(realtime): L2 UserOps for bridge-out withdrawals

Tasks:

  • Gather context and understand the request
  • Read changed files
  • Analyze code for bugs, security issues, and improvements
  • Post review feedback

Overall the PR is well-structured and the feature is coherent. A few correctness and clarity issues are worth addressing before merge.

Issues

1. Misleading status sequence for L2Direct UserOps — mod.rs:325-329

When an L2Direct UserOp is added to the draft block, it immediately gets status Processing { tx_hash: FixedBytes::default() } with a zero tx_hash. Then in async_submitter.rs:242-249 it is overwritten with ProvingBlock, and later overwritten again with a real Processing { tx_hash } when the L1 tx is broadcast.

The resulting status progression is:

Pending → Processing(0x000…) → ProvingBlock → Processing(real_hash) → Executed

The early Processing(zero_hash) step is semantically wrong — the L1 tx hasn't been sent yet. A client polling at that moment will see a Processing state with a bogus hash. Consider using Pending here (status is already Pending from RPC receipt), or introduce a new Queued/Scheduled variant.

Fix this →

2. Silently ignored error from add_l2_user_op_idmod.rs:324

let _ = self.batch_builder.add_l2_user_op_id(user_op.id);

If this fails (no current batch), the L2 UserOp tx has already been pushed into the draft block but its ID is never recorded in proposal.l2_user_op_ids. The status store entry stays at Processing(zero_hash) indefinitely — the 60s cleanup in submission_task won't run because the ID is missing. This is a correctness bug. The error should be propagated (or at minimum logged as an error and the tx insertion reversed).

Fix this →

3. Redundant target_chain variable — bridge_handler.rs:218-224

let target_chain = if user_op.chain_id == self.l2_chain_id {
    self.l2_chain_id
} else {
    self.l1_chain_id
};

if target_chain == self.l2_chain_id {

target_chain is computed then immediately compared. This is equivalent to just:

if user_op.chain_id == self.l2_chain_id {

The intermediate variable adds no value and obscures the logic.

Fix this →

4. Wrong doc comment — mod.rs:271

/// Process all pending UserOps: route each to L1 or L2 based on its EIP-712 signature.

Routing is based on the chainId field, not any EIP-712 signature. This comment is misleading.

Fix this →

5. Recovered::new_uncheckedexecution_layer.rs:288

inner: Recovered::new_unchecked(tx_envelope, signer_address),

new_unchecked skips verification that signer_address is actually the signer of tx_envelope. Since the signature was just created from l2_call_signer, this is likely correct in practice, but it's worth a comment explaining why it's safe (or using Recovered::new with verification if the API allows it). The existing construct_l2_call_tx uses the same pattern, so this is consistent — but a comment would help future readers.

6. L2Direct ops marked Executed on L1 submission, not L2 execution

For L2Direct UserOps, the L2 transaction actually executes when it's included in the L2 block — before any L1 proof submission. But the status reaches Executed only after the L1 multicall confirms. For clients, this means Executed doesn't mean "your L2 tx ran" but rather "the L1 proof was submitted". This is a UX concern worth documenting, or potentially warrant a separate status transition.

Minor Observations

  • Single UserOp per block: add_pending_user_ops_to_draft_block uses try_recv (dequeues one item). Multiple pending UserOps are processed one-per-block. This appears intentional but limits throughput under load.
  • Hardcoded gas/fee params in construct_l2_user_op_tx: gas_limit: 3_000_000, max_fee_per_gas: 1_000_000_000 match the construct_l2_call_tx pattern. A brief comment explaining the 3M choice vs the 1M anchor limit would help future readers.
  • Nonce fetched per-call: Under rapid L2 UserOp submissions, the nonce fetch could collide if two L2 ops are constructed simultaneously. This is mitigated by the single-queue-item-per-block design, but worth noting for future scaling.
  • disable_bridging gate removal: The previous logic was inverted (if !config.disable_bridging { return Err(...) }), making the bridge always fail unless disable_bridging=true. Removing this gate is correct behavior, but the old flag name is confusing. Is disable_bridging still in the config struct and should it be cleaned up?

- Fix misleading Processing(zero_hash) status: remove premature status
  update for L2Direct UserOps; status remains Pending until async_submitter
  sets ProvingBlock
- Propagate add_l2_user_op_id error instead of silently ignoring with let _,
  which could leave status entries orphaned forever
- Remove redundant target_chain variable, simplify to direct chain_id comparison
- Fix doc comment: routing is based on chainId field, not EIP-712 signature
- Add SAFETY comment for Recovered::new_unchecked explaining why it's correct

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@smartprogrammer93
Copy link
Copy Markdown
Contributor Author

@claude review

@claude
Copy link
Copy Markdown

claude bot commented Mar 29, 2026

Claude finished @smartprogrammer93's task in 2m 12s —— View job


Code Review: feat(realtime): L2 UserOps for bridge-out withdrawals

Tasks:

  • Gather context and understand the request
  • Read changed files
  • Analyze code for bugs, security issues, and improvements
  • Post review feedback

The previous review issues have all been addressed (no misleading Processing(zero_hash), add_l2_user_op_id now logs errors and sets Rejected, redundant target_chain is gone, doc comment is corrected, SAFETY comment added). The PR is in good shape. A few new issues to address:

Issues

1. L2 UserOp IDs not rejected on L1 multicall failure — async_submitter.rs:279-291

When send_batch_to_l1 fails, only proposal.user_ops (L1 UserOps) are marked Rejected. proposal.l2_user_op_ids are skipped:

for op in &proposal.user_ops {  // l2_user_op_ids are never iterated
    store.set(op.id, &UserOpStatus::Rejected { reason: reason.clone() });
}

L2 UserOps will remain stuck at ProvingBlock indefinitely — the 60s cleanup is only spawned in the success path, so there's no recovery. The fix is to also iterate proposal.l2_user_op_ids in the rejection block.

Fix this →

2. L2 tx inserted into draft block before tracking succeeds — mod.rs:320-335

Ok(tx) => {
    l2_draft_block.prebuilt_tx_list.tx_list.push(tx);   // tx is in the block
    if let Err(e) = self.batch_builder.add_l2_user_op_id(user_op.id) {
        // status set to Rejected, but the tx already ran on L2
    }
}

If add_l2_user_op_id fails (no current batch), the L2 transaction is already committed to the draft block and will execute on L2. The status store says Rejected but the user's transaction actually succeeded. The fix is to call add_l2_user_op_id before pushing the tx into the block, and only push if tracking succeeds — or propagate the error with ?.

Fix this →

3. l1_chain_id stored but never used for routing validation — bridge_handler.rs:106,219

l1_chain_id is stored in BridgeHandler but next_user_op_routed only checks against l2_chain_id. Any UserOp with an unknown chain_id (neither L1 nor L2) is silently treated as L1, simulated, and will be Rejected with the misleading reason "L1 UserOp with no bridge message".

Consider adding an explicit check before the L1 simulation path:

if user_op.chain_id != 0 && user_op.chain_id != self.l1_chain_id {
    warn!("UserOp id={} has unknown chainId={}, rejecting", user_op.id, user_op.chain_id);
    self.status_store.set(user_op.id, &UserOpStatus::Rejected {
        reason: format!("Unknown chainId: {}", user_op.chain_id),
    });
    return Ok(None);
}

Fix this →

Minor Observations

  • Executed semantics for L2Direct ops (carried over from previous review): Executed is set after the L1 multicall confirms, not when the L2 tx ran. For L2Direct bridge-out ops the L2 tx already executed at block inclusion time. A doc comment on UserOpStatus::Executed clarifying this distinction would help future maintainers.

  • PrivateKeySigner::from_str per call (execution_layer.rs:276-278): A new keypair is parsed from the raw private key string on every construct_l2_user_op_tx and construct_l2_call_tx call. Same pattern in both functions — consider caching in Signer::PrivateKey. Low priority since this isn't a hot path.

  • Single UserOp per block: add_pending_user_ops_to_draft_block dequeues one item per block via try_recv. This is consistent with the L1→L2 path and appears intentional.

- Mark L2 UserOp IDs as Rejected on L1 multicall failure (were stuck at ProvingBlock)
- Track L2 UserOp ID before inserting tx into block (prevents executed-but-Rejected state)
- Reject UserOps with unknown chainId instead of silently treating as L1

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@smartprogrammer93
Copy link
Copy Markdown
Contributor Author

@claude review

@claude
Copy link
Copy Markdown

claude bot commented Mar 29, 2026

Claude finished @smartprogrammer93's task in 2m 50s —— View job


Code Review: feat(realtime): L2 UserOps for bridge-out withdrawals

Tasks:

  • Gather context and understand the request
  • Read changed files and diff
  • Analyze code for bugs, security issues, and improvements
  • Post review feedback

All issues from the previous two rounds have been addressed. The code is in good shape. A few remaining items worth considering before merge:

Issues

1. Misleading "No pending UserOps" log when L2Direct op is processed — mod.rs:363-373

add_pending_user_ops_to_draft_block returns Ok(None) for both "nothing queued" and "L2Direct UserOp successfully processed". The caller uses this to log:

} else {
    debug!("No pending UserOps");  // fires even when an L2Direct op was handled
}

If a bridge-out UserOp was included in the block, the log misleadingly says no UserOps were pending. This makes tracing harder. The fix is to propagate a distinct signal from the L2Direct arm (e.g. a bool indicating whether a UserOp was handled), or simply move the success log into the L2Direct arm itself and change the else log to "No L1→L2 UserOps".

Fix this →

2. find_l1_call makes RPC calls on every block — mod.rs:394-405

if let Some(l1_call) = self
    .bridge_handler.lock().await
    .find_l1_call(preconfed_block.number, preconfed_block.state_root)
    .await?

find_l1_call calls l2_el.find_message_and_signal_slot(block_id), which queries the L2 node on every block to search for bridge-out messages — regardless of whether any L2Direct UserOp was actually included. Bridge-out events are infrequent; polling every block adds per-block RPC overhead. Consider tracking whether a L2Direct UserOp was added to the current block (e.g. a flag set in the L2Direct arm) and only calling find_l1_call when the flag is set.

Fix this →

3. proof_request_bypass silently skips all status updates — async_submitter.rs:196-229

When proof_request_bypass = true, submission_task returns early before setting ProvingBlock, Processing, or Executed for any UserOps (L1 or L2). L2Direct UserOps stay at Pending indefinitely and the 60s cleanup never fires. While bypass mode is dev-only, a stale Pending in the status store for ops that already executed on L2 could mislead during integration testing. Consider adding a note in the bypass return path, or setting statuses to a terminal state before early-return.

Minor Observations

  • Processing { tx_hash } shows L1 multicall hash for L2Direct ops (carried from previous review): The Processing status for L2Direct UserOps reflects the L1 multicall tx hash, but the actual L2 transaction ran at block inclusion — before any L1 interaction. Documenting this in UserOpStatus::Processing or UserOpStatus::Executed would help future maintainers and API consumers understand the semantics.

  • PrivateKeySigner::from_str per call (execution_layer.rs:277): Private key is parsed from string on every construct_l2_user_op_tx invocation. Consistent with construct_l2_call_tx — low priority since it's not a hot path, but worth caching in Signer::PrivateKey eventually.

The "No pending UserOps" log was misleading when an L2Direct op was
processed, since add_pending_user_ops_to_draft_block returns None for
both "nothing queued" and "L2Direct handled". Updated to distinguish.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant