Skip to content

Commit 63eecee

Browse files
refactor(realtime): single surge_sendUserOp RPC with auto chain detection
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>
1 parent ef7cc22 commit 63eecee

4 files changed

Lines changed: 262 additions & 152 deletions

File tree

realtime/src/lib.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,12 @@ pub async fn create_realtime_node(
131131
let proof_request_bypass = realtime_config.proof_request_bypass;
132132
let raiko_client = raiko::RaikoClient::new(&realtime_config);
133133

134+
let l1_chain_id = {
135+
use common::l1::traits::ELTrait;
136+
ethereum_l1.execution_layer.common().chain_id()
137+
};
138+
let l2_chain_id = taiko.l2_execution_layer().chain_id;
139+
134140
let node = Node::new(
135141
node_config,
136142
cancel_token.clone(),
@@ -145,6 +151,8 @@ pub async fn create_realtime_node(
145151
protocol_config.basefee_sharing_pctg,
146152
preconf_only,
147153
proof_request_bypass,
154+
l1_chain_id,
155+
l2_chain_id,
148156
)
149157
.await
150158
.map_err(|e| anyhow::anyhow!("Failed to create Node: {}", e))?;

realtime/src/node/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ impl Node {
5656
basefee_sharing_pctg: u8,
5757
preconf_only: bool,
5858
proof_request_bypass: bool,
59+
l1_chain_id: u64,
60+
l2_chain_id: u64,
5961
) -> Result<Self, Error> {
6062
let operator = Operator::new(
6163
ethereum_l1.execution_layer.clone(),
@@ -85,6 +87,8 @@ impl Node {
8587
raiko_client,
8688
basefee_sharing_pctg,
8789
proof_request_bypass,
90+
l1_chain_id,
91+
l2_chain_id,
8892
)
8993
.await
9094
.map_err(|e| anyhow::anyhow!("Failed to create BatchManager: {}", e))?;

realtime/src/node/proposal_manager/bridge_handler.rs

Lines changed: 182 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -80,10 +80,121 @@ pub struct L2Call {
8080
pub signal_slot_on_l2: FixedBytes<32>,
8181
}
8282

83+
/// Result of routing a UserOp: either it targets L1 (and triggers an L2 bridge call)
84+
/// or it targets L2 (for direct execution on L2, e.g. bridge-out).
85+
pub enum UserOpRouting {
86+
/// L1 UserOp that triggers a bridge deposit (L1→L2).
87+
L1ToL2 { user_op: UserOp, l2_call: L2Call },
88+
/// L2 UserOp for direct execution on L2 (e.g. bridge-out L2→L1).
89+
L2Direct { user_op: UserOp },
90+
}
91+
92+
/// Determine the target chain of a UserOp by checking the EIP-712 signature.
93+
///
94+
/// The UserOpsSubmitter uses EIP-712 with domain `(name="UserOpsSubmitter", version="1",
95+
/// chainId, verifyingContract=submitter)`. We decode the `executeBatch(ops[], signature)`
96+
/// calldata, compute the EIP-712 digest for both L1 and L2 chain IDs, and ecrecover.
97+
/// The chain ID that produces a valid recovery (non-zero address) is the target chain.
98+
fn detect_target_chain(user_op: &UserOp, l1_chain_id: u64, l2_chain_id: u64) -> Option<u64> {
99+
use alloy::sol;
100+
use alloy::sol_types::{SolCall, SolValue};
101+
102+
// ABI definition matching UserOpsSubmitter.executeBatch
103+
sol! {
104+
struct UserOpSol {
105+
address target;
106+
uint256 value;
107+
bytes data;
108+
}
109+
110+
function executeBatch(UserOpSol[] calldata _ops, bytes calldata _signature) external;
111+
}
112+
113+
// Decode the calldata
114+
let decoded = executeBatchCall::abi_decode(&user_op.calldata).ok()?;
115+
let ops = &decoded._ops;
116+
let signature = &decoded._signature;
117+
118+
if signature.len() != 65 {
119+
warn!("UserOp id={}: signature length {} != 65", user_op.id, signature.len());
120+
return None;
121+
}
122+
123+
// EIP-712 type hashes
124+
let userop_typehash = alloy::primitives::keccak256(
125+
b"UserOp(address target,uint256 value,bytes data)",
126+
);
127+
let executebatch_typehash = alloy::primitives::keccak256(
128+
b"ExecuteBatch(UserOp[] ops)UserOp(address target,uint256 value,bytes data)",
129+
);
130+
131+
// Hash each op: keccak256(abi.encode(typehash, target, value, keccak256(data)))
132+
let mut op_hashes = Vec::with_capacity(ops.len());
133+
for op in ops {
134+
let data_hash = alloy::primitives::keccak256(&op.data);
135+
let encoded = (userop_typehash, op.target, op.value, data_hash).abi_encode();
136+
op_hashes.push(alloy::primitives::keccak256(&encoded));
137+
}
138+
139+
// keccak256(abi.encodePacked(opHashes))
140+
let mut packed = Vec::with_capacity(op_hashes.len() * 32);
141+
for h in &op_hashes {
142+
packed.extend_from_slice(h.as_slice());
143+
}
144+
let ops_array_hash = alloy::primitives::keccak256(&packed);
145+
146+
// struct hash = keccak256(abi.encode(EXECUTEBATCH_TYPEHASH, ops_array_hash))
147+
let struct_hash = alloy::primitives::keccak256(
148+
&(executebatch_typehash, ops_array_hash).abi_encode(),
149+
);
150+
151+
// Parse the 65-byte signature
152+
let sig = alloy::signers::Signature::try_from(signature.as_ref()).ok()?;
153+
154+
// Try both chain IDs
155+
for chain_id in [l1_chain_id, l2_chain_id] {
156+
let domain_separator = compute_domain_separator(chain_id, user_op.submitter);
157+
158+
// EIP-712 digest: keccak256("\x19\x01" || domainSeparator || structHash)
159+
let mut digest_input = Vec::with_capacity(2 + 32 + 32);
160+
digest_input.extend_from_slice(&[0x19, 0x01]);
161+
digest_input.extend_from_slice(domain_separator.as_slice());
162+
digest_input.extend_from_slice(struct_hash.as_slice());
163+
let digest = alloy::primitives::keccak256(&digest_input);
164+
165+
if let Ok(recovered) = sig.recover_address_from_prehash(&digest) {
166+
if recovered != Address::ZERO {
167+
info!(
168+
"UserOp id={}: signature valid for chain_id={} (recovered={})",
169+
user_op.id, chain_id, recovered
170+
);
171+
return Some(chain_id);
172+
}
173+
}
174+
}
175+
176+
warn!("UserOp id={}: could not determine target chain", user_op.id);
177+
None
178+
}
179+
180+
/// Compute EIP-712 domain separator for UserOpsSubmitter(name="UserOpsSubmitter", version="1")
181+
fn compute_domain_separator(chain_id: u64, verifying_contract: Address) -> B256 {
182+
use alloy::sol_types::SolValue;
183+
184+
let type_hash = alloy::primitives::keccak256(
185+
b"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)",
186+
);
187+
let name_hash = alloy::primitives::keccak256(b"UserOpsSubmitter");
188+
let version_hash = alloy::primitives::keccak256(b"1");
189+
190+
alloy::primitives::keccak256(
191+
&(type_hash, name_hash, version_hash, alloy::primitives::U256::from(chain_id), verifying_contract).abi_encode(),
192+
)
193+
}
194+
83195
#[derive(Clone)]
84196
struct BridgeRpcContext {
85197
tx: mpsc::Sender<UserOp>,
86-
l2_tx: mpsc::Sender<UserOp>,
87198
status_store: UserOpStatusStore,
88199
next_id: Arc<AtomicU64>,
89200
}
@@ -92,8 +203,9 @@ pub struct BridgeHandler {
92203
ethereum_l1: Arc<EthereumL1<ExecutionLayer>>,
93204
taiko: Arc<Taiko>,
94205
rx: Receiver<UserOp>,
95-
l2_rx: Receiver<UserOp>,
96206
status_store: UserOpStatusStore,
207+
l1_chain_id: u64,
208+
l2_chain_id: u64,
97209
}
98210

99211
impl BridgeHandler {
@@ -102,14 +214,14 @@ impl BridgeHandler {
102214
ethereum_l1: Arc<EthereumL1<ExecutionLayer>>,
103215
taiko: Arc<Taiko>,
104216
cancellation_token: CancellationToken,
217+
l1_chain_id: u64,
218+
l2_chain_id: u64,
105219
) -> Result<Self, anyhow::Error> {
106220
let (tx, rx) = mpsc::channel::<UserOp>(1024);
107-
let (l2_tx, l2_rx) = mpsc::channel::<UserOp>(1024);
108221
let status_store = UserOpStatusStore::open("data/user_op_status")?;
109222

110223
let rpc_context = BridgeRpcContext {
111224
tx,
112-
l2_tx,
113225
status_store: status_store.clone(),
114226
next_id: Arc::new(AtomicU64::new(1)),
115227
};
@@ -169,33 +281,6 @@ impl BridgeHandler {
169281
}
170282
})?;
171283

172-
module.register_async_method("surge_sendL2UserOp", |params, ctx, _| async move {
173-
let mut user_op: UserOp = params.parse()?;
174-
let id = ctx.next_id.fetch_add(1, Ordering::Relaxed);
175-
user_op.id = id;
176-
177-
info!(
178-
"Received L2 UserOp: id={}, submitter={:?}, calldata_len={}",
179-
id,
180-
user_op.submitter,
181-
user_op.calldata.len()
182-
);
183-
184-
ctx.status_store.set(id, &UserOpStatus::Pending);
185-
186-
ctx.l2_tx.send(user_op).await.map_err(|e| {
187-
error!("Failed to send L2 UserOp to queue: {}", e);
188-
ctx.status_store.remove(id);
189-
jsonrpsee::types::ErrorObjectOwned::owned(
190-
-32000,
191-
"Failed to queue L2 user operation",
192-
Some(format!("{}", e)),
193-
)
194-
})?;
195-
196-
Ok::<u64, jsonrpsee::types::ErrorObjectOwned>(id)
197-
})?;
198-
199284
info!("Bridge handler RPC server starting on {}", addr);
200285
let handle = server.start(module);
201286

@@ -209,47 +294,84 @@ impl BridgeHandler {
209294
ethereum_l1,
210295
taiko,
211296
rx,
212-
l2_rx,
213297
status_store,
298+
l1_chain_id,
299+
l2_chain_id,
214300
})
215301
}
216302

217303
pub fn status_store(&self) -> UserOpStatusStore {
218304
self.status_store.clone()
219305
}
220306

221-
pub async fn next_user_op_and_l2_call(
307+
/// Dequeue the next UserOp and route it to the correct chain.
308+
///
309+
/// Parses the EIP-712 signature in the `executeBatch` calldata to determine
310+
/// which chain the UserOp targets. If signed for L1, simulates on L1 to
311+
/// extract the bridge message. If signed for L2, returns it for direct
312+
/// L2 block inclusion.
313+
pub async fn next_user_op_routed(
222314
&mut self,
223-
) -> Result<Option<(UserOp, L2Call)>, anyhow::Error> {
224-
if let Ok(user_op) = self.rx.try_recv() {
225-
if let Some((message_from_l1, signal_slot_on_l2)) = self
226-
.ethereum_l1
227-
.execution_layer
228-
.find_message_and_signal_slot(user_op.clone())
229-
.await?
230-
{
231-
return Ok(Some((
232-
user_op,
233-
L2Call {
234-
message_from_l1,
235-
signal_slot_on_l2,
315+
) -> Result<Option<UserOpRouting>, anyhow::Error> {
316+
let Ok(user_op) = self.rx.try_recv() else {
317+
return Ok(None);
318+
};
319+
320+
let target_chain = detect_target_chain(&user_op, self.l1_chain_id, self.l2_chain_id);
321+
322+
match target_chain {
323+
Some(chain_id) if chain_id == self.l1_chain_id => {
324+
// L1 UserOp — simulate on L1 to extract bridge message
325+
if let Some((message_from_l1, signal_slot_on_l2)) = self
326+
.ethereum_l1
327+
.execution_layer
328+
.find_message_and_signal_slot(user_op.clone())
329+
.await?
330+
{
331+
return Ok(Some(UserOpRouting::L1ToL2 {
332+
user_op,
333+
l2_call: L2Call {
334+
message_from_l1,
335+
signal_slot_on_l2,
336+
},
337+
}));
338+
}
339+
340+
// L1 simulation found no bridge message — still an L1 UserOp (non-bridge)
341+
warn!(
342+
"UserOp id={} targets L1 but no bridge message found, treating as L1 UserOp without bridge",
343+
user_op.id
344+
);
345+
self.status_store.set(
346+
user_op.id,
347+
&UserOpStatus::Rejected {
348+
reason: "L1 UserOp with no bridge message".to_string(),
236349
},
237-
)));
350+
);
351+
Ok(None)
352+
}
353+
Some(chain_id) if chain_id == self.l2_chain_id => {
354+
// L2 UserOp — execute directly on L2
355+
info!(
356+
"UserOp id={} targets L2 (chain_id={}), queueing for L2 execution",
357+
user_op.id, chain_id
358+
);
359+
Ok(Some(UserOpRouting::L2Direct { user_op }))
360+
}
361+
_ => {
362+
warn!(
363+
"UserOp id={} rejected: could not determine target chain from signature",
364+
user_op.id
365+
);
366+
self.status_store.set(
367+
user_op.id,
368+
&UserOpStatus::Rejected {
369+
reason: "Could not determine target chain from signature".to_string(),
370+
},
371+
);
372+
Ok(None)
238373
}
239-
240-
warn!(
241-
"UserOp id={} rejected: no L2 call found in user op",
242-
user_op.id
243-
);
244-
self.status_store.set(
245-
user_op.id,
246-
&UserOpStatus::Rejected {
247-
reason: "No L2 call found in user op".to_string(),
248-
},
249-
);
250374
}
251-
252-
Ok(None)
253375
}
254376

255377
pub async fn find_l1_call(
@@ -278,13 +400,4 @@ impl BridgeHandler {
278400
pub fn has_pending_user_ops(&self) -> bool {
279401
!self.rx.is_empty()
280402
}
281-
282-
/// Dequeue the next L2 UserOp (for bridge-out / L2 execution).
283-
pub fn next_l2_user_op(&mut self) -> Option<UserOp> {
284-
self.l2_rx.try_recv().ok()
285-
}
286-
287-
pub fn has_pending_l2_user_ops(&self) -> bool {
288-
!self.l2_rx.is_empty()
289-
}
290403
}

0 commit comments

Comments
 (0)