From 383abdb360d48e553788ef51ca18cc29953898df Mon Sep 17 00:00:00 2001 From: AnshuJalan Date: Mon, 13 Apr 2026 14:13:30 +0530 Subject: [PATCH 01/13] =?UTF-8?q?feat:=20support=20L2=E2=86=92L1=E2=86=92L?= =?UTF-8?q?2=20deferred-finalization=20multicall=20in=20builder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a proposal's L1Calls include required L1→L2 return signals (produced by the L1 callback of a Bridge.processMessage triggered during the same L1 multicall), restructure the multicall as: [tentativePropose, user_ops..., l1_calls..., finalizePropose] instead of the classic: [user_ops..., propose, l1_calls...] The inbox's new tentativePropose saves the checkpoint and emits ProposedAndProved up front so processMessage later in the multicall can verify L2→L1 signals against the tentative L2 state root. finalizePropose verifies that the required L1→L2 return signals were actually produced by the L1 callbacks before committing. Changes: - L1Call gains required_return_signal: Option>. Application-level orchestration (e.g. a FlashLoanExecutorL2) populates this from its outbound bridge message's expected L1→L2 return slot. - Bindings add ProposeInputV2 (existingSignals + requiredReturnSignals) and the RealTimeInbox ABI is refreshed to expose tentativePropose/finalizePropose. - proposal_tx_builder splits the signal-slot union across the two input fields based on which slots are required-return, then builds the two inbox calls. Classic propose() path is unchanged when no required signals are present. Co-Authored-By: Claude Opus 4.6 (1M context) --- realtime/src/l1/abi/RealTimeInbox.json | 976 +++++++++++++++++- realtime/src/l1/bindings.rs | 10 + realtime/src/l1/proposal_tx_builder.rs | 192 +++- .../node/proposal_manager/bridge_handler.rs | 7 + 4 files changed, 1140 insertions(+), 45 deletions(-) diff --git a/realtime/src/l1/abi/RealTimeInbox.json b/realtime/src/l1/abi/RealTimeInbox.json index 18dfc773..68ef732d 100644 --- a/realtime/src/l1/abi/RealTimeInbox.json +++ b/realtime/src/l1/abi/RealTimeInbox.json @@ -1 +1,975 @@ -{"abi":[{"type":"function","name":"activate","inputs":[{"name":"_genesisBlockHash","type":"bytes32","internalType":"bytes32"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"getConfig","inputs":[],"outputs":[{"name":"config_","type":"tuple","internalType":"struct IRealTimeInbox.Config","components":[{"name":"proofVerifier","type":"address","internalType":"address"},{"name":"signalService","type":"address","internalType":"address"},{"name":"basefeeSharingPctg","type":"uint8","internalType":"uint8"}]}],"stateMutability":"view"},{"type":"function","name":"getLastFinalizedBlockHash","inputs":[],"outputs":[{"name":"","type":"bytes32","internalType":"bytes32"}],"stateMutability":"view"},{"type":"function","name":"propose","inputs":[{"name":"_data","type":"bytes","internalType":"bytes"},{"name":"_checkpoint","type":"tuple","internalType":"struct ICheckpointStore.Checkpoint","components":[{"name":"blockNumber","type":"uint48","internalType":"uint48"},{"name":"blockHash","type":"bytes32","internalType":"bytes32"},{"name":"stateRoot","type":"bytes32","internalType":"bytes32"}]},{"name":"_proof","type":"bytes","internalType":"bytes"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"event","name":"Activated","inputs":[{"name":"genesisBlockHash","type":"bytes32","indexed":false,"internalType":"bytes32"}],"anonymous":false},{"type":"event","name":"ProposedAndProved","inputs":[{"name":"proposalHash","type":"bytes32","indexed":true,"internalType":"bytes32"},{"name":"lastFinalizedBlockHash","type":"bytes32","indexed":false,"internalType":"bytes32"},{"name":"maxAnchorBlockNumber","type":"uint48","indexed":false,"internalType":"uint48"},{"name":"basefeeSharingPctg","type":"uint8","indexed":false,"internalType":"uint8"},{"name":"sources","type":"tuple[]","indexed":false,"internalType":"struct IInbox.DerivationSource[]","components":[{"name":"isForcedInclusion","type":"bool","internalType":"bool"},{"name":"blobSlice","type":"tuple","internalType":"struct LibBlobs.BlobSlice","components":[{"name":"blobHashes","type":"bytes32[]","internalType":"bytes32[]"},{"name":"offset","type":"uint24","internalType":"uint24"},{"name":"timestamp","type":"uint48","internalType":"uint48"}]}]},{"name":"signalSlots","type":"bytes32[]","indexed":false,"internalType":"bytes32[]"},{"name":"checkpoint","type":"tuple","indexed":false,"internalType":"struct ICheckpointStore.Checkpoint","components":[{"name":"blockNumber","type":"uint48","internalType":"uint48"},{"name":"blockHash","type":"bytes32","internalType":"bytes32"},{"name":"stateRoot","type":"bytes32","internalType":"bytes32"}]}],"anonymous":false}]} \ No newline at end of file +{ + "abi": [ + { + "type": "constructor", + "inputs": [ + { + "name": "_config", + "type": "tuple", + "internalType": "struct IRealTimeInbox.Config", + "components": [ + { + "name": "proofVerifier", + "type": "address", + "internalType": "address" + }, + { + "name": "signalService", + "type": "address", + "internalType": "address" + }, + { + "name": "basefeeSharingPctg", + "type": "uint8", + "internalType": "uint8" + } + ] + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "acceptOwnership", + "inputs": [], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "activate", + "inputs": [ + { + "name": "_genesisBlockHash", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "decodeProposeInput", + "inputs": [ + { + "name": "_data", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [ + { + "name": "input_", + "type": "tuple", + "internalType": "struct IRealTimeInbox.ProposeInput", + "components": [ + { + "name": "blobReference", + "type": "tuple", + "internalType": "struct LibBlobs.BlobReference", + "components": [ + { + "name": "blobStartIndex", + "type": "uint16", + "internalType": "uint16" + }, + { + "name": "numBlobs", + "type": "uint16", + "internalType": "uint16" + }, + { + "name": "offset", + "type": "uint24", + "internalType": "uint24" + } + ] + }, + { + "name": "signalSlots", + "type": "bytes32[]", + "internalType": "bytes32[]" + }, + { + "name": "maxAnchorBlockNumber", + "type": "uint48", + "internalType": "uint48" + } + ] + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "encodeProposeInput", + "inputs": [ + { + "name": "_input", + "type": "tuple", + "internalType": "struct IRealTimeInbox.ProposeInput", + "components": [ + { + "name": "blobReference", + "type": "tuple", + "internalType": "struct LibBlobs.BlobReference", + "components": [ + { + "name": "blobStartIndex", + "type": "uint16", + "internalType": "uint16" + }, + { + "name": "numBlobs", + "type": "uint16", + "internalType": "uint16" + }, + { + "name": "offset", + "type": "uint24", + "internalType": "uint24" + } + ] + }, + { + "name": "signalSlots", + "type": "bytes32[]", + "internalType": "bytes32[]" + }, + { + "name": "maxAnchorBlockNumber", + "type": "uint48", + "internalType": "uint48" + } + ] + } + ], + "outputs": [ + { + "name": "encoded_", + "type": "bytes", + "internalType": "bytes" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "finalizePropose", + "inputs": [ + { + "name": "_requiredReturnSignals", + "type": "bytes32[]", + "internalType": "bytes32[]" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "getConfig", + "inputs": [], + "outputs": [ + { + "name": "config_", + "type": "tuple", + "internalType": "struct IRealTimeInbox.Config", + "components": [ + { + "name": "proofVerifier", + "type": "address", + "internalType": "address" + }, + { + "name": "signalService", + "type": "address", + "internalType": "address" + }, + { + "name": "basefeeSharingPctg", + "type": "uint8", + "internalType": "uint8" + } + ] + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getLastFinalizedBlockHash", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "hashCommitment", + "inputs": [ + { + "name": "_commitment", + "type": "tuple", + "internalType": "struct IRealTimeInbox.Commitment", + "components": [ + { + "name": "proposalHash", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "lastFinalizedBlockHash", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "checkpoint", + "type": "tuple", + "internalType": "struct ICheckpointStore.Checkpoint", + "components": [ + { + "name": "blockNumber", + "type": "uint48", + "internalType": "uint48" + }, + { + "name": "blockHash", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "stateRoot", + "type": "bytes32", + "internalType": "bytes32" + } + ] + } + ] + } + ], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "hashProposal", + "inputs": [ + { + "name": "_proposal", + "type": "tuple", + "internalType": "struct IRealTimeInbox.Proposal", + "components": [ + { + "name": "maxAnchorBlockNumber", + "type": "uint48", + "internalType": "uint48" + }, + { + "name": "maxAnchorBlockHash", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "basefeeSharingPctg", + "type": "uint8", + "internalType": "uint8" + }, + { + "name": "sources", + "type": "tuple[]", + "internalType": "struct IInbox.DerivationSource[]", + "components": [ + { + "name": "isForcedInclusion", + "type": "bool", + "internalType": "bool" + }, + { + "name": "blobSlice", + "type": "tuple", + "internalType": "struct LibBlobs.BlobSlice", + "components": [ + { + "name": "blobHashes", + "type": "bytes32[]", + "internalType": "bytes32[]" + }, + { + "name": "offset", + "type": "uint24", + "internalType": "uint24" + }, + { + "name": "timestamp", + "type": "uint48", + "internalType": "uint48" + } + ] + } + ] + }, + { + "name": "signalSlotsHash", + "type": "bytes32", + "internalType": "bytes32" + } + ] + } + ], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "hashSignalSlots", + "inputs": [ + { + "name": "_signalSlots", + "type": "bytes32[]", + "internalType": "bytes32[]" + } + ], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "impl", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "inNonReentrant", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "init", + "inputs": [ + { + "name": "_owner", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "lastFinalizedBlockHash", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "owner", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "pause", + "inputs": [], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "paused", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "pendingOwner", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "propose", + "inputs": [ + { + "name": "_data", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "_checkpoint", + "type": "tuple", + "internalType": "struct ICheckpointStore.Checkpoint", + "components": [ + { + "name": "blockNumber", + "type": "uint48", + "internalType": "uint48" + }, + { + "name": "blockHash", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "stateRoot", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "name": "_proof", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "proxiableUUID", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "renounceOwnership", + "inputs": [], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "resolver", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "tentativePropose", + "inputs": [ + { + "name": "_data", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "_checkpoint", + "type": "tuple", + "internalType": "struct ICheckpointStore.Checkpoint", + "components": [ + { + "name": "blockNumber", + "type": "uint48", + "internalType": "uint48" + }, + { + "name": "blockHash", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "stateRoot", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "name": "_proof", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [ + { + "name": "proposalId_", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "transferOwnership", + "inputs": [ + { + "name": "newOwner", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "unpause", + "inputs": [], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "upgradeTo", + "inputs": [ + { + "name": "newImplementation", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "upgradeToAndCall", + "inputs": [ + { + "name": "newImplementation", + "type": "address", + "internalType": "address" + }, + { + "name": "data", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [], + "stateMutability": "payable" + }, + { + "type": "event", + "name": "Activated", + "inputs": [ + { + "name": "genesisBlockHash", + "type": "bytes32", + "indexed": false, + "internalType": "bytes32" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "AdminChanged", + "inputs": [ + { + "name": "previousAdmin", + "type": "address", + "indexed": false, + "internalType": "address" + }, + { + "name": "newAdmin", + "type": "address", + "indexed": false, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "BeaconUpgraded", + "inputs": [ + { + "name": "beacon", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Initialized", + "inputs": [ + { + "name": "version", + "type": "uint8", + "indexed": false, + "internalType": "uint8" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "OwnershipTransferStarted", + "inputs": [ + { + "name": "previousOwner", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "newOwner", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "OwnershipTransferred", + "inputs": [ + { + "name": "previousOwner", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "newOwner", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Paused", + "inputs": [ + { + "name": "account", + "type": "address", + "indexed": false, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "ProposedAndProved", + "inputs": [ + { + "name": "proposalHash", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "lastFinalizedBlockHash", + "type": "bytes32", + "indexed": false, + "internalType": "bytes32" + }, + { + "name": "maxAnchorBlockNumber", + "type": "uint48", + "indexed": false, + "internalType": "uint48" + }, + { + "name": "basefeeSharingPctg", + "type": "uint8", + "indexed": false, + "internalType": "uint8" + }, + { + "name": "sources", + "type": "tuple[]", + "indexed": false, + "internalType": "struct IInbox.DerivationSource[]", + "components": [ + { + "name": "isForcedInclusion", + "type": "bool", + "internalType": "bool" + }, + { + "name": "blobSlice", + "type": "tuple", + "internalType": "struct LibBlobs.BlobSlice", + "components": [ + { + "name": "blobHashes", + "type": "bytes32[]", + "internalType": "bytes32[]" + }, + { + "name": "offset", + "type": "uint24", + "internalType": "uint24" + }, + { + "name": "timestamp", + "type": "uint48", + "internalType": "uint48" + } + ] + } + ] + }, + { + "name": "signalSlots", + "type": "bytes32[]", + "indexed": false, + "internalType": "bytes32[]" + }, + { + "name": "checkpoint", + "type": "tuple", + "indexed": false, + "internalType": "struct ICheckpointStore.Checkpoint", + "components": [ + { + "name": "blockNumber", + "type": "uint48", + "internalType": "uint48" + }, + { + "name": "blockHash", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "stateRoot", + "type": "bytes32", + "internalType": "bytes32" + } + ] + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "TentativeProposed", + "inputs": [ + { + "name": "proposalId", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "requiredReturnSignalsHash", + "type": "bytes32", + "indexed": false, + "internalType": "bytes32" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Unpaused", + "inputs": [ + { + "name": "account", + "type": "address", + "indexed": false, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Upgraded", + "inputs": [ + { + "name": "implementation", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "error", + "name": "ACCESS_DENIED", + "inputs": [] + }, + { + "type": "error", + "name": "AlreadyActivated", + "inputs": [] + }, + { + "type": "error", + "name": "BlobNotFound", + "inputs": [] + }, + { + "type": "error", + "name": "FUNC_NOT_IMPLEMENTED", + "inputs": [] + }, + { + "type": "error", + "name": "INVALID_PAUSE_STATUS", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidGenesisBlockHash", + "inputs": [] + }, + { + "type": "error", + "name": "MaxAnchorBlockTooOld", + "inputs": [] + }, + { + "type": "error", + "name": "NoBlobs", + "inputs": [] + }, + { + "type": "error", + "name": "NoPendingProposal", + "inputs": [] + }, + { + "type": "error", + "name": "NotActivated", + "inputs": [] + }, + { + "type": "error", + "name": "PendingProposalAlreadyExists", + "inputs": [] + }, + { + "type": "error", + "name": "REENTRANT_CALL", + "inputs": [] + }, + { + "type": "error", + "name": "RequiredSignalNotSent", + "inputs": [ + { + "name": "slot", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "RequiredSignalsMismatch", + "inputs": [] + }, + { + "type": "error", + "name": "SignalSlotNotSent", + "inputs": [ + { + "name": "slot", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "ZERO_ADDRESS", + "inputs": [] + }, + { + "type": "error", + "name": "ZERO_VALUE", + "inputs": [] + } + ] +} diff --git a/realtime/src/l1/bindings.rs b/realtime/src/l1/bindings.rs index d07df623..58d964be 100644 --- a/realtime/src/l1/bindings.rs +++ b/realtime/src/l1/bindings.rs @@ -34,6 +34,16 @@ sol! { uint48 maxAnchorBlockNumber; } + /// Input for `tentativePropose` — splits signals into existing (verified + /// immediately) and requiredReturn (verified at finalizePropose after the + /// L1 callback in the same multicall produces them). + struct ProposeInputV2 { + BlobReference blobReference; + bytes32[] existingSignals; + bytes32[] requiredReturnSignals; + uint48 maxAnchorBlockNumber; + } + // SurgeVerifier SubProof encoding struct SubProof { uint8 proofBitFlag; diff --git a/realtime/src/l1/proposal_tx_builder.rs b/realtime/src/l1/proposal_tx_builder.rs index 0e713525..5c32be59 100644 --- a/realtime/src/l1/proposal_tx_builder.rs +++ b/realtime/src/l1/proposal_tx_builder.rs @@ -1,5 +1,7 @@ use crate::l1::{ - bindings::{BlobReference, Multicall, ProofType, ProposeInput, RealTimeInbox, SubProof}, + bindings::{ + BlobReference, Multicall, ProofType, ProposeInput, ProposeInputV2, RealTimeInbox, SubProof, + }, config::ContractAddresses, }; use crate::node::proposal_manager::{ @@ -84,39 +86,89 @@ impl ProposalTxBuilder { from: Address, contract_addresses: ContractAddresses, ) -> Result { - let mut multicalls: Vec = vec![]; + // Collect required return signals from all l1_calls that expect an L1→L2 + // return signal to be produced by their invoked target. When non-empty, the + // multicall is structured as: + // [tentativePropose, user_ops..., l1_calls..., finalizePropose] + // so that processMessage runs against the tentative state root, its invoked + // L1 callback produces the required return signal via Bridge.sendMessage, + // and finalizePropose verifies those signals at the end. + let required_return_signals: Vec> = batch + .l1_calls + .iter() + .filter_map(|c| c.required_return_signal) + .collect(); - // Add all user ops to multicall - for user_op in &batch.user_ops { - let user_op_call = self.build_user_op_call(user_op.clone()); - info!("Added user op to Multicall: {:?}", &user_op_call); - multicalls.push(user_op_call); - } + let use_deferred = !required_return_signals.is_empty(); - // Build the propose call and blob sidecar - let (propose_call, blob_sidecar) = self - .build_propose_call(&batch, contract_addresses.realtime_inbox) + // Build the inbox call(s) + blob sidecar. Returns either a single + // `propose` call (classic flow) or a pair of (tentative, finalize) calls. + let (inbox_calls, blob_sidecar) = self + .build_inbox_calls( + &batch, + contract_addresses.realtime_inbox, + use_deferred, + &required_return_signals, + ) .await?; - // If no user ops or L1 calls, send directly to inbox (skip multicall) + // If no user ops and no L1 calls and no deferred flow, go direct. if batch.user_ops.is_empty() && batch.l1_calls.is_empty() { - info!("Sending proposal directly to RealTimeInbox (no multicall)"); - let tx = TransactionRequest::default() - .to(contract_addresses.realtime_inbox) - .from(from) - .input(propose_call.data.into()) - .with_blob_sidecar(blob_sidecar); - return Ok(tx); + if inbox_calls.len() == 1 { + info!("Sending proposal directly to RealTimeInbox (no multicall)"); + let tx = TransactionRequest::default() + .to(contract_addresses.realtime_inbox) + .from(from) + .input(inbox_calls.into_iter().next().unwrap().data.into()) + .with_blob_sidecar(blob_sidecar); + return Ok(tx); + } + // Otherwise fall through to multicall assembly } - info!("Added proposal to Multicall: {:?}", &propose_call); - multicalls.push(propose_call.clone()); + let mut multicalls: Vec = vec![]; + + if use_deferred { + // 1. tentativePropose (inbox_calls[0]) + info!("Added tentativePropose to Multicall: {:?}", &inbox_calls[0]); + multicalls.push(inbox_calls[0].clone()); + + // 2. user ops + for user_op in &batch.user_ops { + let user_op_call = self.build_user_op_call(user_op.clone()); + info!("Added user op to Multicall: {:?}", &user_op_call); + multicalls.push(user_op_call); + } + + // 3. L1 calls (processMessage for L2→L1 signals — each triggers its + // target's L1 callback which produces an L1→L2 return signal) + for l1_call in &batch.l1_calls { + let l1_call_call = + self.build_l1_call_call(l1_call.clone(), contract_addresses.bridge); + info!("Added L1 call to Multicall: {:?}", &l1_call_call); + multicalls.push(l1_call_call); + } - // Add all L1 calls - for l1_call in &batch.l1_calls { - let l1_call_call = self.build_l1_call_call(l1_call.clone(), contract_addresses.bridge); - info!("Added L1 call to Multicall: {:?}", &l1_call_call); - multicalls.push(l1_call_call); + // 4. finalizePropose (inbox_calls[1]) + info!("Added finalizePropose to Multicall: {:?}", &inbox_calls[1]); + multicalls.push(inbox_calls[1].clone()); + } else { + // Classic flow: [user_ops..., propose, l1_calls...] + for user_op in &batch.user_ops { + let user_op_call = self.build_user_op_call(user_op.clone()); + info!("Added user op to Multicall: {:?}", &user_op_call); + multicalls.push(user_op_call); + } + + info!("Added proposal to Multicall: {:?}", &inbox_calls[0]); + multicalls.push(inbox_calls[0].clone()); + + for l1_call in &batch.l1_calls { + let l1_call_call = + self.build_l1_call_call(l1_call.clone(), contract_addresses.bridge); + info!("Added L1 call to Multicall: {:?}", &l1_call_call); + multicalls.push(l1_call_call); + } } let multicall = Multicall::new(contract_addresses.proposer_multicall, &self.provider); @@ -139,11 +191,23 @@ impl ProposalTxBuilder { } } - async fn build_propose_call( + /// Build the inbox call(s) + blob sidecar. + /// + /// When `use_deferred` is false, returns `[propose_call]` — the classic single + /// atomic propose path. + /// + /// When `use_deferred` is true, returns `[tentativePropose_call, finalizePropose_call]`. + /// `batch.signal_slots` is split into `existing_signals` (signals already on L1 + /// at proposal time, verified by tentativePropose) and `required_return_signals` + /// (signals produced later in the multicall by L1 callbacks, verified by + /// finalizePropose). The ZK proof commits to the union hash. + async fn build_inbox_calls( &self, batch: &Proposal, inbox_address: Address, - ) -> Result<(Multicall::Call, BlobTransactionSidecar), anyhow::Error> { + use_deferred: bool, + required_return_signals: &[alloy::primitives::FixedBytes<32>], + ) -> Result<(Vec, BlobTransactionSidecar), anyhow::Error> { let mut block_manifests = >::with_capacity(batch.l2_blocks.len()); for l2_block in &batch.l2_blocks { block_manifests.push(BlockManifest { @@ -186,36 +250,76 @@ impl ProposalTxBuilder { }]; let proof = Bytes::from(sub_proofs.abi_encode()); - // Build ProposeInput and ABI-encode it as the _data parameter let blob_reference = BlobReference { blobStartIndex: 0, numBlobs: sidecar.blobs.len().try_into()?, offset: U24::ZERO, }; - let propose_input = ProposeInput { - blobReference: blob_reference, - signalSlots: batch.signal_slots.clone(), - maxAnchorBlockNumber: U48::from(batch.max_anchor_block_number), - }; - - let encoded_input = Bytes::from(propose_input.abi_encode()); - - // Convert L1 Checkpoint type for the propose call + // Convert L1 Checkpoint type for the inbox call let checkpoint = crate::l1::bindings::ICheckpointStore::Checkpoint { blockNumber: batch.checkpoint.blockNumber, blockHash: batch.checkpoint.blockHash, stateRoot: batch.checkpoint.stateRoot, }; - let call = inbox.propose(encoded_input, checkpoint, proof); + if !use_deferred { + // Classic propose flow + let propose_input = ProposeInput { + blobReference: blob_reference, + signalSlots: batch.signal_slots.clone(), + maxAnchorBlockNumber: U48::from(batch.max_anchor_block_number), + }; + let encoded_input = Bytes::from(propose_input.abi_encode()); + let call = inbox.propose(encoded_input, checkpoint, proof); + + return Ok(( + vec![Multicall::Call { + target: inbox_address, + value: U256::ZERO, + data: call.calldata().clone(), + }], + sidecar, + )); + } + + // Deferred propose flow — split signal slots. + // `batch.signal_slots` should carry the UNION of existing and required-return + // slots (the anchor on L2 consumes the union as fast signals). We derive + // `existing_signals` by subtracting the required-return list from the union. + let required_set: std::collections::HashSet<_> = + required_return_signals.iter().copied().collect(); + let existing_signals: Vec> = batch + .signal_slots + .iter() + .copied() + .filter(|s| !required_set.contains(s)) + .collect(); + + let propose_input_v2 = ProposeInputV2 { + blobReference: blob_reference, + existingSignals: existing_signals, + requiredReturnSignals: required_return_signals.to_vec(), + maxAnchorBlockNumber: U48::from(batch.max_anchor_block_number), + }; + let encoded_input = Bytes::from(propose_input_v2.abi_encode()); + + let tentative_call = inbox.tentativePropose(encoded_input, checkpoint, proof); + let finalize_call = inbox.finalizePropose(required_return_signals.to_vec()); Ok(( - Multicall::Call { - target: inbox_address, - value: U256::ZERO, - data: call.calldata().clone(), - }, + vec![ + Multicall::Call { + target: inbox_address, + value: U256::ZERO, + data: tentative_call.calldata().clone(), + }, + Multicall::Call { + target: inbox_address, + value: U256::ZERO, + data: finalize_call.calldata().clone(), + }, + ], sidecar, )) } diff --git a/realtime/src/node/proposal_manager/bridge_handler.rs b/realtime/src/node/proposal_manager/bridge_handler.rs index 0d981e5c..b678a700 100644 --- a/realtime/src/node/proposal_manager/bridge_handler.rs +++ b/realtime/src/node/proposal_manager/bridge_handler.rs @@ -73,6 +73,12 @@ pub struct UserOp { pub struct L1Call { pub message_from_l2: Message, pub signal_slot_proof: Bytes, + /// Optional: if the L1 callback triggered by `processMessage` produces an + /// L1→L2 return signal that the same L2 block consumes as a fast signal, + /// this is that signal slot. When present, the inbox must defer finalization + /// of the proposal until this slot is populated on L1 — triggering the + /// tentativePropose + finalizePropose multicall shape. + pub required_return_signal: Option>, } // Data required to build the L2 call transaction initiated by an L1 contract via the bridge @@ -362,6 +368,7 @@ impl BridgeHandler { return Ok(Some(L1Call { message_from_l2, signal_slot_proof, + required_return_signal: None, })); } From 8c56145d3cd96bd5878db0f9085ca419ac684c69 Mon Sep 17 00:00:00 2001 From: AnshuJalan Date: Tue, 14 Apr 2026 11:23:42 +0530 Subject: [PATCH 02/13] =?UTF-8?q?feat:=20complete=20L2=E2=86=92L1=E2=86=92?= =?UTF-8?q?L2=20synchronous=20composability=20pipeline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire end-to-end L2→L1→L2 flow for L2Direct UserOps: - L1 callback simulator (simulate_l1_callback_return_signal) with state_override on L1 SignalService to bypass signal verification - L2 outbound pre-simulator (trace_user_op_for_outbound_message) to detect bridge-out before real block execution - Three-pass block build in proposal_manager: detect outbound, simulate L1 callback, inject return signal into anchor, patch calldata - Fix multicall ordering: user_ops before tentativePropose - All contract addresses from env (no auto-derivation), consolidate L2_BRIDGE_ADDRESS as single env var Co-Authored-By: Claude Opus 4.6 (1M context) --- common/src/config/mod.rs | 2 +- realtime/src/l1/bindings.rs | 30 +++ realtime/src/l1/config.rs | 3 + realtime/src/l1/execution_layer.rs | 193 +++++++++++++++++- realtime/src/l1/proposal_tx_builder.rs | 16 +- realtime/src/l2/execution_layer.rs | 131 +++++++++++- realtime/src/l2/taiko.rs | 12 +- realtime/src/lib.rs | 2 + .../node/proposal_manager/bridge_handler.rs | 43 +++- realtime/src/node/proposal_manager/mod.rs | 184 ++++++++++++++++- realtime/src/utils/config.rs | 9 + 11 files changed, 598 insertions(+), 27 deletions(-) diff --git a/common/src/config/mod.rs b/common/src/config/mod.rs index a45c882b..db584bde 100644 --- a/common/src/config/mod.rs +++ b/common/src/config/mod.rs @@ -239,7 +239,7 @@ impl Config { let taiko_anchor_address = Address::from_str(&taiko_anchor_address_str) .map_err(|e| address_parse_error(TAIKO_ANCHOR_ADDRESS, e, &taiko_anchor_address_str))?; - const BRIDGE_ADDRESS: &str = "TAIKO_BRIDGE_L2_ADDRESS"; + const BRIDGE_ADDRESS: &str = "L2_BRIDGE_ADDRESS"; let taiko_bridge_address_str = std::env::var(BRIDGE_ADDRESS).unwrap_or_else(|_| { warn!( "No Bridge contract address found in {} env var, using default", diff --git a/realtime/src/l1/bindings.rs b/realtime/src/l1/bindings.rs index 58d964be..6ae6600e 100644 --- a/realtime/src/l1/bindings.rs +++ b/realtime/src/l1/bindings.rs @@ -51,6 +51,36 @@ sol! { } } +// Binding for the L2 flash loan executor's `execute` entry point. Used by the +// proposal manager to detect UserOps that target this ABI and patch their +// placeholder `returnMessage` with the simulated L1→L2 return. +// +// NOTE: this mirrors `IBridge.Message` struct layout. The field types must +// match exactly or abi_decode/abi_encode_sequence will fail. +alloy::sol! { + #[allow(missing_docs)] + struct FlashLoanReturnMessage { + uint64 id; + uint64 fee; + uint32 gasLimit; + address from; + uint64 srcChainId; + address srcOwner; + uint64 destChainId; + address destOwner; + address to; + uint256 value; + bytes data; + } + + #[allow(missing_docs)] + function execute( + uint256 amount, + address beneficiary, + FlashLoanReturnMessage returnMessage + ) external; +} + /// Proof types supported by the SurgeVerifier. /// Each variant maps to a bit flag used in `SubProof.proofBitFlag`. #[derive(Debug, Clone, Copy)] diff --git a/realtime/src/l1/config.rs b/realtime/src/l1/config.rs index d4634cb1..2508f3bb 100644 --- a/realtime/src/l1/config.rs +++ b/realtime/src/l1/config.rs @@ -8,12 +8,14 @@ pub struct ContractAddresses { pub realtime_inbox: Address, pub proposer_multicall: Address, pub bridge: Address, + pub signal_service: Address, } pub struct EthereumL1Config { pub realtime_inbox: Address, pub proposer_multicall: Address, pub bridge: Address, + pub signal_service: Address, pub proof_type: ProofType, pub raiko_client: RaikoClient, } @@ -27,6 +29,7 @@ impl TryFrom for EthereumL1Config { realtime_inbox: config.realtime_inbox, proposer_multicall: config.proposer_multicall, bridge: config.bridge, + signal_service: config.signal_service, proof_type: config.proof_type, raiko_client, }) diff --git a/realtime/src/l1/execution_layer.rs b/realtime/src/l1/execution_layer.rs index 50d906d2..e80ac31a 100644 --- a/realtime/src/l1/execution_layer.rs +++ b/realtime/src/l1/execution_layer.rs @@ -5,12 +5,12 @@ use crate::l1::bindings::RealTimeInbox::{self, RealTimeInboxInstance}; use crate::node::proposal_manager::proposal::Proposal; use crate::raiko::RaikoClient; use crate::shared_abi::bindings::{ - Bridge::MessageSent, IBridge::Message, SignalService::SignalSent, + Bridge, Bridge::MessageSent, IBridge::Message, SignalService::SignalSent, }; use crate::{l1::config::ContractAddresses, node::proposal_manager::bridge_handler::UserOp}; use alloy::{ eips::{BlockId, BlockNumberOrTag}, - primitives::{Address, B256, FixedBytes}, + primitives::{Address, B256, Bytes, FixedBytes}, providers::{DynProvider, ext::DebugApi}, rpc::types::{ TransactionRequest, @@ -19,7 +19,7 @@ use alloy::{ GethDebugTracingOptions, }, }, - sol_types::SolEvent, + sol_types::{SolCall, SolEvent}, }; use anyhow::{Error, anyhow}; use common::{ @@ -98,6 +98,7 @@ impl ELTrait for ExecutionLayer { realtime_inbox: specific_config.realtime_inbox, proposer_multicall: specific_config.proposer_multicall, bridge: specific_config.bridge, + signal_service: specific_config.signal_service, }; let proof_type = specific_config.proof_type; @@ -180,6 +181,13 @@ impl ExecutionLayer { &self.raiko_client } + /// Returns a clone of the configured contract addresses (L1 inbox, + /// bridge, signal service, proposer multicall). Useful for callers that + /// need to reference these during block building. + pub fn contract_addresses(&self) -> ContractAddresses { + self.contract_addresses.clone() + } + pub async fn send_batch_to_l1( &self, batch: Proposal, @@ -265,6 +273,23 @@ pub trait L1BridgeHandlerOps { &self, user_op: UserOp, ) -> Result)>, anyhow::Error>; + + /// Simulate `Bridge.processMessage(msg, proof)` on L1 and inspect the trace + /// for any `MessageSent` event the invoked L1 callback emits. If it does, + /// the return message is an L1→L2 bridge message that the originating L2 + /// block expects to consume as a fast signal — the slot of that return + /// signal is what the inbox's `requiredReturnSignals` list must include. + /// + /// Returns `Some((return_message, return_signal_slot))` if a return is + /// produced, `None` otherwise. Returns an error only for RPC failures; a + /// callback that reverts during simulation yields `None` (no signal). + async fn simulate_l1_callback_return_signal( + &self, + message_from_l2: Message, + signal_slot_proof: Bytes, + bridge_address: Address, + l2_bridge_address: Address, + ) -> Result)>, anyhow::Error>; } impl L1BridgeHandlerOps for ExecutionLayer { @@ -348,4 +373,166 @@ impl L1BridgeHandlerOps for ExecutionLayer { Ok(None) } + + async fn simulate_l1_callback_return_signal( + &self, + message_from_l2: Message, + _signal_slot_proof: Bytes, + bridge_address: Address, + l2_bridge_address: Address, + ) -> Result)>, anyhow::Error> { + use alloy::primitives::{B256, U256, keccak256}; + use alloy::rpc::types::state::{AccountOverride, StateOverride}; + + // Compute the L2→L1 signal slot that the bridge will check during + // proveSignalReceived. On L1 SignalService: + // slot = keccak256(abi.encodePacked("SIGNAL", srcChainId, app, msgHash)) + // where app = the L2 bridge that emitted the signal. + // + // hashMessage = keccak256(abi.encode("TAIKO_MESSAGE", message)) + // Compute it on-chain via Bridge.hashMessage to avoid replicating the + // exact Solidity abi.encode of a (string, struct) tuple in Rust. + + let bridge = Bridge::new(bridge_address, self.provider.clone()); + let msg_hash: B256 = bridge + .hashMessage(message_from_l2.clone()) + .call() + .await + .map_err(|e| anyhow!("Failed to call Bridge.hashMessage for sim: {e}"))?; + + // Bridge.processMessage on L1 passes `app = resolve(srcChainId, + // B_BRIDGE) = L2 bridge address` to SignalService.proveSignalReceived. + // That's what the signal slot was derived from on L2 (msg.sender of + // SignalService.sendSignal was the L2 bridge). Caller passes in the + // L2 bridge address (auto-derived from L2 chain id on the L2 side). + let app = l2_bridge_address; + let src_chain_id = message_from_l2.srcChainId; + + // Mirror SignalService.getSignalSlot: keccak256(abi.encodePacked( + // "SIGNAL", uint64 chainId, address app, bytes32 signal)) + let mut preimage = Vec::with_capacity(6 + 8 + 20 + 32); + preimage.extend_from_slice(b"SIGNAL"); + preimage.extend_from_slice(&src_chain_id.to_be_bytes()); + preimage.extend_from_slice(app.as_slice()); + preimage.extend_from_slice(msg_hash.as_slice()); + let signal_slot_key: B256 = keccak256(&preimage); + + // Storage slot of `_receivedSignals[signal_slot_key]` on L1 SignalService + // `_receivedSignals` is at storage slot 253 (see SignalService_Layout.sol). + let received_signals_base_slot = U256::from(253u64); + let mut key_preimage = Vec::with_capacity(64); + key_preimage.extend_from_slice(signal_slot_key.as_slice()); + key_preimage.extend_from_slice(&B256::from(received_signals_base_slot).0); + let received_signals_storage_slot: B256 = keccak256(&key_preimage); + + // Build calldata for `Bridge.processMessage(message_from_l2, "")` with + // empty proof. With `_receivedSignals[slot] = true` state-overridden, + // the fast-signal path in proveSignalReceived succeeds, so the bridge + // proceeds to invoke the target's onMessageInvocation, whose trace we + // then scan for the L1→L2 return. + let calldata = Bridge::processMessageCall { + _message: message_from_l2, + _proof: Bytes::new(), + } + .abi_encode(); + + let tx_request = TransactionRequest::default() + .from(self.preconfer_address) + .to(bridge_address) + .input(calldata.into()); + + // State-override: mark the signal as received on the L1 SignalService. + let signal_service_address = self.contract_addresses.signal_service; + let account_override = AccountOverride::default().with_state_diff( + std::iter::once(( + received_signals_storage_slot, + B256::from(U256::from(1)), + )), + ); + let mut state_overrides = StateOverride::default(); + state_overrides.insert(signal_service_address, account_override); + + let mut tracer_config = serde_json::Map::new(); + tracer_config.insert("withLog".to_string(), serde_json::Value::Bool(true)); + tracer_config.insert("onlyTopCall".to_string(), serde_json::Value::Bool(false)); + + let tracing_options = GethDebugTracingOptions { + tracer: Some(GethDebugTracerType::BuiltInTracer( + GethDebugBuiltInTracerType::CallTracer, + )), + tracer_config: serde_json::Value::Object(tracer_config).into(), + ..Default::default() + }; + + let call_options = GethDebugTracingCallOptions { + tracing_options, + state_overrides: Some(state_overrides), + ..Default::default() + }; + + let trace_result = match self + .provider + .debug_trace_call( + tx_request, + BlockId::Number(BlockNumberOrTag::Latest), + call_options, + ) + .await + { + Ok(t) => t, + Err(e) => { + // RPC-level failure (not a revert inside the trace). Surface as error. + return Err(anyhow!("L1 callback simulation RPC failed: {e}")); + } + }; + + let mut message: Option = None; + let mut slot: Option> = None; + + if let alloy::rpc::types::trace::geth::GethTrace::CallTracer(call_frame) = trace_result { + // Collect logs regardless of whether the simulation reverted — the + // MessageSent event is emitted during processMessage's invoked + // callback, which may succeed even if a later sub-call reverts. + let all_logs = collect_logs_recursive(&call_frame); + for log in all_logs { + if let Some(topics) = &log.topics + && !topics.is_empty() + { + if topics[0] == MessageSent::SIGNATURE_HASH { + let log_data = alloy::primitives::LogData::new_unchecked( + topics.clone(), + log.data.clone().unwrap_or_default(), + ); + let decoded = MessageSent::decode_log_data(&log_data).map_err(|e| { + anyhow!("Failed to decode MessageSent from L1 callback sim: {e}") + })?; + message = Some(decoded.message); + } else if topics[0] == SignalSent::SIGNATURE_HASH { + let log_data = alloy::primitives::LogData::new_unchecked( + topics.clone(), + log.data.clone().unwrap_or_default(), + ); + let decoded = SignalSent::decode_log_data(&log_data).map_err(|e| { + anyhow!("Failed to decode SignalSent from L1 callback sim: {e}") + })?; + slot = Some(decoded.slot); + } + } + } + } + + if let (Some(m), Some(s)) = (message, slot) { + tracing::info!( + "L1 callback simulation found return signal: slot={}, destChainId={}", + s, + m.destChainId + ); + Ok(Some((m, s))) + } else { + tracing::debug!( + "L1 callback simulation produced no MessageSent/SignalSent pair" + ); + Ok(None) + } + } } diff --git a/realtime/src/l1/proposal_tx_builder.rs b/realtime/src/l1/proposal_tx_builder.rs index 5c32be59..5e637e1a 100644 --- a/realtime/src/l1/proposal_tx_builder.rs +++ b/realtime/src/l1/proposal_tx_builder.rs @@ -129,17 +129,23 @@ impl ProposalTxBuilder { let mut multicalls: Vec = vec![]; if use_deferred { - // 1. tentativePropose (inbox_calls[0]) - info!("Added tentativePropose to Multicall: {:?}", &inbox_calls[0]); - multicalls.push(inbox_calls[0].clone()); + // Deferred flow: [user_ops..., tentativePropose, l1_calls..., finalizePropose] + // + // User ops must run before tentativePropose because L1 UserOps are what + // emit the existingSignals that tentativePropose verifies. Ordering them + // after would leave those signals unsent and tentativePropose would revert. - // 2. user ops + // 1. User ops (emit existingSignals on L1) for user_op in &batch.user_ops { let user_op_call = self.build_user_op_call(user_op.clone()); info!("Added user op to Multicall: {:?}", &user_op_call); multicalls.push(user_op_call); } + // 2. tentativePropose (inbox_calls[0]) — verifies existingSignals now present + info!("Added tentativePropose to Multicall: {:?}", &inbox_calls[0]); + multicalls.push(inbox_calls[0].clone()); + // 3. L1 calls (processMessage for L2→L1 signals — each triggers its // target's L1 callback which produces an L1→L2 return signal) for l1_call in &batch.l1_calls { @@ -149,7 +155,7 @@ impl ProposalTxBuilder { multicalls.push(l1_call_call); } - // 4. finalizePropose (inbox_calls[1]) + // 4. finalizePropose (inbox_calls[1]) — verifies requiredReturnSignals info!("Added finalizePropose to Multicall: {:?}", &inbox_calls[1]); multicalls.push(inbox_calls[1].clone()); } else { diff --git a/realtime/src/l2/execution_layer.rs b/realtime/src/l2/execution_layer.rs index 9811fda4..c6a12a05 100644 --- a/realtime/src/l2/execution_layer.rs +++ b/realtime/src/l2/execution_layer.rs @@ -9,9 +9,16 @@ use alloy::{ consensus::{ SignableTransaction, Transaction as AnchorTransaction, TxEnvelope, transaction::Recovered, }, + eips::{BlockId, BlockNumberOrTag}, primitives::{Address, B256, Bytes, FixedBytes}, - providers::{DynProvider, Provider}, - rpc::types::Transaction, + providers::{DynProvider, Provider, ext::DebugApi}, + rpc::types::{ + Transaction, TransactionRequest, + trace::geth::{ + CallFrame, GethDebugBuiltInTracerType, GethDebugTracerType, + GethDebugTracingCallOptions, GethDebugTracingOptions, + }, + }, signers::{Signature, Signer as AlloySigner}, sol_types::SolEvent, }; @@ -41,7 +48,11 @@ pub struct L2ExecutionLayer { } impl L2ExecutionLayer { - pub async fn new(taiko_config: TaikoConfig) -> Result { + pub async fn new( + taiko_config: TaikoConfig, + bridge_address: Address, + signal_service: Address, + ) -> Result { let provider = alloy_tools::create_alloy_provider_without_wallet(&taiko_config.taiko_geth_url).await?; @@ -52,16 +63,8 @@ impl L2ExecutionLayer { info!("L2 Chain ID: {}", chain_id); let anchor = Anchor::new(taiko_config.taiko_anchor_address, provider.clone()); - - let chain_id_string = format!("{}", chain_id); - let zeros_needed = 38usize.saturating_sub(chain_id_string.len()); - let bridge_address: Address = - format!("0x{}{}01", chain_id_string, "0".repeat(zeros_needed)).parse()?; let bridge = Bridge::new(bridge_address, provider.clone()); - let signal_service: Address = - format!("0x{}{}05", chain_id_string, "0".repeat(zeros_needed)).parse()?; - let common = ExecutionLayerCommon::new(provider.clone(), taiko_config.signer.get_address()).await?; let l2_call_signer = taiko_config.signer.clone(); @@ -318,6 +321,19 @@ pub trait L2BridgeHandlerOps { block_id: u64, state_root: B256, ) -> Result; + + /// Dry-run a UserOp on the L2 node to discover any L2→L1 bridge message it + /// emits via `Bridge.sendMessage`. The trace is captured even when the + /// overall tx reverts — e.g. because the UserOp later calls + /// `Bridge.processMessage(returnMsg, "")` which requires a fast signal + /// that hasn't been injected yet. + /// + /// Returns the first `MessageSent` payload observed (Message struct), + /// or `None` if the UserOp never calls `Bridge.sendMessage`. + async fn trace_user_op_for_outbound_message( + &self, + user_op: &UserOp, + ) -> Result, anyhow::Error>; } impl L2BridgeHandlerOps for L2ExecutionLayer { @@ -484,4 +500,97 @@ impl L2BridgeHandlerOps for L2ExecutionLayer { Ok(Bytes::from(vec![hop_proof].abi_encode_params())) } + + async fn trace_user_op_for_outbound_message( + &self, + user_op: &UserOp, + ) -> Result, anyhow::Error> { + let tx_request = TransactionRequest::default() + .from(user_op.submitter) + .to(user_op.submitter) + .input(user_op.calldata.clone().into()); + + let mut tracer_config = serde_json::Map::new(); + tracer_config.insert("withLog".to_string(), serde_json::Value::Bool(true)); + tracer_config.insert("onlyTopCall".to_string(), serde_json::Value::Bool(false)); + + let tracing_options = GethDebugTracingOptions { + tracer: Some(GethDebugTracerType::BuiltInTracer( + GethDebugBuiltInTracerType::CallTracer, + )), + tracer_config: serde_json::Value::Object(tracer_config).into(), + ..Default::default() + }; + + let call_options = GethDebugTracingCallOptions { + tracing_options, + ..Default::default() + }; + + let trace_result = match self + .provider + .debug_trace_call( + tx_request, + BlockId::Number(BlockNumberOrTag::Latest), + call_options, + ) + .await + { + Ok(t) => t, + Err(e) => { + // For the "simulate a reverting tx" case, some L2 nodes still + // return the trace even when the tx fails. RPC-level errors are + // distinct from reverts and we surface them. + return Err(anyhow::anyhow!( + "L2 user-op simulation RPC failed: {e}" + )); + } + }; + + let mut message: Option = None; + + if let alloy::rpc::types::trace::geth::GethTrace::CallTracer(call_frame) = trace_result { + // Walk the entire call tree. The MessageSent event may be emitted + // by a nested Bridge.sendMessage call long before any later revert. + let all_logs = collect_logs_from_frame(&call_frame); + for log in all_logs { + if let Some(topics) = &log.topics + && !topics.is_empty() + && topics[0] == MessageSent::SIGNATURE_HASH + { + let log_data = alloy::primitives::LogData::new_unchecked( + topics.clone(), + log.data.clone().unwrap_or_default(), + ); + let decoded = MessageSent::decode_log_data(&log_data).map_err(|e| { + anyhow::anyhow!("Failed to decode MessageSent from L2 sim: {e}") + })?; + message = Some(decoded.message); + break; // first MessageSent wins for the POC + } + } + } + + if let Some(m) = &message { + debug!( + "L2 pre-sim found outbound sendMessage: destChainId={}, to={}", + m.destChainId, m.to + ); + } else { + debug!("L2 pre-sim found no outbound sendMessage in UserOp trace"); + } + + Ok(message) + } +} + +/// Recursively collect all logs emitted during a traced call (including from +/// sub-calls). Mirrors the L1-side helper in `crate::l1::execution_layer`. +fn collect_logs_from_frame(frame: &CallFrame) -> Vec { + let mut logs = Vec::new(); + logs.extend(frame.logs.iter().cloned()); + for sub_frame in &frame.calls { + logs.extend(collect_logs_from_frame(sub_frame)); + } + logs } diff --git a/realtime/src/l2/taiko.rs b/realtime/src/l2/taiko.rs index 2649d8d2..e2c9ed6f 100644 --- a/realtime/src/l2/taiko.rs +++ b/realtime/src/l2/taiko.rs @@ -50,6 +50,8 @@ impl Taiko { metrics: Arc, taiko_config: TaikoConfig, l2_engine: L2Engine, + l2_bridge_address: Address, + l2_signal_service_address: Address, ) -> Result { let driver_config: TaikoDriverConfig = TaikoDriverConfig { driver_url: taiko_config.driver_url.clone(), @@ -61,9 +63,13 @@ impl Taiko { Ok(Self { protocol_config, l2_execution_layer: Arc::new( - L2ExecutionLayer::new(taiko_config.clone()) - .await - .map_err(|e| anyhow::anyhow!("Failed to create L2ExecutionLayer: {}", e))?, + L2ExecutionLayer::new( + taiko_config.clone(), + l2_bridge_address, + l2_signal_service_address, + ) + .await + .map_err(|e| anyhow::anyhow!("Failed to create L2ExecutionLayer: {}", e))?, ), driver: Arc::new(TaikoDriver::new(&driver_config, metrics).await?), slot_clock, diff --git a/realtime/src/lib.rs b/realtime/src/lib.rs index 9c6ddf3e..1ae2ae2d 100644 --- a/realtime/src/lib.rs +++ b/realtime/src/lib.rs @@ -65,6 +65,8 @@ pub async fn create_realtime_node( metrics.clone(), taiko_config, l2_engine, + config.taiko_bridge_address, + realtime_config.l2_signal_service, ) .await?; let taiko = Arc::new(taiko); diff --git a/realtime/src/node/proposal_manager/bridge_handler.rs b/realtime/src/node/proposal_manager/bridge_handler.rs index b678a700..e52d9f05 100644 --- a/realtime/src/node/proposal_manager/bridge_handler.rs +++ b/realtime/src/node/proposal_manager/bridge_handler.rs @@ -365,10 +365,51 @@ impl BridgeHandler { .get_hop_proof(signal_slot, block_id, state_root) .await?; + // Simulate the L1 callback (Bridge.processMessage) to detect any + // L1→L2 return signal the callback will produce. If found, it + // must be pre-injected into the L2 block's anchor fast signals + // and committed as a requiredReturnSignal in the inbox proposal. + // + // The simulator uses state_override on L1 SignalService so the + // signal-verification step passes even before the real checkpoint + // is committed. A None result means the callback does not produce + // an outbound — classic L1→L2→L1 flow, no deferred-finalize needed. + let l1_el = &self.ethereum_l1.execution_layer; + let contracts = l1_el.contract_addresses(); + // L2 bridge address is auto-derived from L2 chain id on the L2 + // side — pull it from there rather than duplicating in config. + let l2_bridge_address = *l2_el.bridge.address(); + let required_return_signal = match l1_el + .simulate_l1_callback_return_signal( + message_from_l2.clone(), + signal_slot_proof.clone(), + contracts.bridge, + l2_bridge_address, + ) + .await + { + Ok(Some((_return_msg, slot))) => { + info!( + "L1 callback simulation found return signal slot={} — will use deferred finalize", + slot + ); + Some(slot) + } + Ok(None) => None, + Err(e) => { + // Simulation failure is not fatal: fall back to classic flow. + warn!( + "L1 callback simulation failed ({}) — falling back to classic propose", + e + ); + None + } + }; + return Ok(Some(L1Call { message_from_l2, signal_slot_proof, - required_return_signal: None, + required_return_signal, })); } diff --git a/realtime/src/node/proposal_manager/mod.rs b/realtime/src/node/proposal_manager/mod.rs index 5a32445a..d954494b 100644 --- a/realtime/src/node/proposal_manager/mod.rs +++ b/realtime/src/node/proposal_manager/mod.rs @@ -4,10 +4,14 @@ pub mod bridge_handler; pub mod l2_block_payload; pub mod proposal; -use crate::l1::bindings::ICheckpointStore::Checkpoint; +use crate::l1::bindings::{ + FlashLoanReturnMessage, ICheckpointStore::Checkpoint, executeCall, +}; +use crate::l1::execution_layer::L1BridgeHandlerOps; use crate::l2::execution_layer::L2BridgeHandlerOps; use crate::node::proposal_manager::bridge_handler::UserOp; use crate::raiko::RaikoClient; +use crate::shared_abi::bindings::IBridge::Message as BridgeMessage; use crate::{l1::execution_layer::ExecutionLayer, l2::taiko::Taiko}; use alloy::primitives::aliases::U48; use alloy::primitives::{B256, FixedBytes}; @@ -48,6 +52,11 @@ pub struct BatchManager { cancel_token: CancellationToken, last_finalized_block_hash: B256, last_finalized_block_number: Arc, + /// L1→L2 return signal slot discovered during Pass 2 (L2Direct pre-sim). + /// Pushed into the L2 block's anchor fast signals before real execution + /// so that `bridge.processMessage(returnMsg, "")` in the UserOp succeeds. + /// Cleared after each block build. + pending_return_signal: Option>, } impl BatchManager { @@ -126,6 +135,7 @@ impl BatchManager { cancel_token, last_finalized_block_hash, last_finalized_block_number, + pending_return_signal: None, }) } @@ -332,10 +342,45 @@ impl BatchManager { user_op.id, user_op.submitter ); + // --- Pass 2: pre-simulate L2→L1 outbound + L1 callback return --- + // + // Dry-run the UserOp to extract any `bridge.sendMessage` it emits + // (even if the tx reverts on a later `bridge.processMessage` + // fast-signal check). If found, simulate the L1 callback to + // discover the L1→L2 return signal. If present, the UserOp's + // placeholder `returnMessage` is patched with the simulated one. + let patched_user_op = match self + .prepare_l2_direct_user_op(user_op.clone()) + .await + { + Ok((patched, Some((_return_msg, slot)))) => { + info!( + "L2Direct pre-sim extracted L1→L2 return slot={} for UserOp id={}", + slot, user_op.id + ); + self.pending_return_signal = Some(slot); + patched + } + Ok((patched, None)) => { + debug!( + "L2Direct pre-sim found no return signal for UserOp id={}", + user_op.id + ); + patched + } + Err(e) => { + warn!( + "L2Direct pre-sim failed for UserOp id={}: {}. Proceeding with original calldata.", + user_op.id, e + ); + user_op.clone() + } + }; + match self .taiko .l2_execution_layer() - .construct_l2_user_op_tx(&user_op) + .construct_l2_user_op_tx(&patched_user_op) .await { Ok(tx) => { @@ -367,12 +412,63 @@ impl BatchManager { ); } } - // No L1 UserOp or signal slot for L2-direct ops + // No L1 UserOp or signal slot returned here for L2-direct ops; + // `pending_return_signal` on `self` carries any return slot. Ok(None) } } } + /// Pre-simulate an L2Direct UserOp to detect its L2→L1 outbound and the + /// L1→L2 return produced by the L1 callback. If a return is found and the + /// UserOp targets `FlashLoanExecutorL2.execute(uint256,address,IBridge.Message)`, + /// the placeholder returnMessage in its calldata is substituted with the + /// simulated Message before the real L2 block execution. + async fn prepare_l2_direct_user_op( + &self, + user_op: UserOp, + ) -> Result<(UserOp, Option<(BridgeMessage, FixedBytes<32>)>), Error> { + use alloy::primitives::Bytes; + + let l2_el = self.taiko.l2_execution_layer(); + let Some(outbound) = l2_el + .trace_user_op_for_outbound_message(&user_op) + .await? + else { + return Ok((user_op, None)); + }; + + let l1_el = &self.ethereum_l1.execution_layer; + let bridge_addr = l1_el.contract_addresses().bridge; + let l2_bridge_addr = *l2_el.bridge.address(); + let Some((return_msg, return_slot)) = l1_el + .simulate_l1_callback_return_signal( + outbound, + Bytes::new(), + bridge_addr, + l2_bridge_addr, + ) + .await? + else { + return Ok((user_op, None)); + }; + + // Attempt calldata patching. + let patched_user_op = match maybe_patch_flash_loan_execute_calldata(&user_op, &return_msg) { + Ok(patched) => patched, + Err(e) => { + warn!( + "FlashLoanExecutor calldata patch failed ({e}); using original calldata. \ + The L2 block may revert at processMessage if the placeholder return \ + Message's hash doesn't match the simulated one." + ); + user_op.clone() + } + }; + + Ok((patched_user_op, Some((return_msg, return_slot)))) + } + async fn add_draft_block_to_proposal( &mut self, mut l2_draft_block: L2BlockV2Draft, @@ -393,6 +489,21 @@ impl BatchManager { debug!("No L1→L2 UserOps (L2 direct ops, if any, were handled inline)"); } + // If Pass 2 (L2Direct pre-sim) discovered an L1→L2 return signal, + // inject it into the anchor's fast signals so `bridge.processMessage` + // inside the UserOp's onFlashLoan / equivalent callback succeeds when + // the real L2 block executes. The same slot is also recorded in + // batch_builder.signal_slots so `ProposeInputV2` can split it out as + // `requiredReturnSignals` when the multicall is built. + if let Some(return_slot) = self.pending_return_signal.take() { + info!( + "Injecting pre-simulated L1→L2 return signal into anchor fast signals: slot={}", + return_slot + ); + self.batch_builder.add_signal_slot(return_slot)?; + anchor_signal_slots.push(return_slot); + } + let payload = self.batch_builder.add_l2_draft_block(l2_draft_block)?; match self @@ -573,3 +684,70 @@ impl BatchManager { Ok(block) } } + +/// If `user_op.calldata` matches the FlashLoanExecutorL2.execute ABI +/// `execute(uint256 amount, address beneficiary, IBridge.Message returnMessage)`, +/// decode it, substitute `returnMessage` with the simulated `return_msg`, and +/// return a new UserOp with the patched calldata. Otherwise return an error +/// (the caller falls back to the original calldata with a warning). +fn maybe_patch_flash_loan_execute_calldata( + user_op: &UserOp, + return_msg: &BridgeMessage, +) -> Result { + use alloy::primitives::Bytes; + use alloy::sol_types::SolCall; + + let calldata_bytes = user_op.calldata.as_ref(); + if calldata_bytes.len() < 4 { + return Err(anyhow::anyhow!("calldata too short for selector")); + } + + let selector: [u8; 4] = calldata_bytes[0..4] + .try_into() + .map_err(|_| anyhow::anyhow!("selector parse failed"))?; + + if selector != executeCall::SELECTOR { + return Err(anyhow::anyhow!( + "selector 0x{} does not match FlashLoanExecutor.execute", + hex_encode(&selector) + )); + } + + let decoded = executeCall::abi_decode_raw(&calldata_bytes[4..]).map_err(|e| { + anyhow::anyhow!("failed to decode execute(uint256,address,(...)) calldata: {e}") + })?; + + // Build the patched FlashLoanReturnMessage from the simulated BridgeMessage. + let patched_return = FlashLoanReturnMessage { + id: return_msg.id, + fee: return_msg.fee, + gasLimit: return_msg.gasLimit, + from: return_msg.from, + srcChainId: return_msg.srcChainId, + srcOwner: return_msg.srcOwner, + destChainId: return_msg.destChainId, + destOwner: return_msg.destOwner, + to: return_msg.to, + value: return_msg.value, + data: return_msg.data.clone(), + }; + + let patched_call = executeCall { + amount: decoded.amount, + beneficiary: decoded.beneficiary, + returnMessage: patched_return, + }; + + let patched_calldata = Bytes::from(patched_call.abi_encode()); + let mut patched_user_op = user_op.clone(); + patched_user_op.calldata = patched_calldata; + Ok(patched_user_op) +} + +fn hex_encode(bytes: &[u8]) -> String { + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + s.push_str(&format!("{:02x}", b)); + } + s +} diff --git a/realtime/src/utils/config.rs b/realtime/src/utils/config.rs index 01ed2b92..382054aa 100644 --- a/realtime/src/utils/config.rs +++ b/realtime/src/utils/config.rs @@ -9,6 +9,11 @@ pub struct RealtimeConfig { pub realtime_inbox: Address, pub proposer_multicall: Address, pub bridge: Address, + /// L1 SignalService — needed for L1 callback simulation + /// (state_override on `_receivedSignals` to pass fast-signal check). + pub signal_service: Address, + /// L2 SignalService address — used on the L2 side for signal operations. + pub l2_signal_service: Address, pub raiko_url: String, pub raiko_api_key: Option, pub proof_type: ProofType, @@ -33,6 +38,8 @@ impl ConfigTrait for RealtimeConfig { let realtime_inbox = read_contract_address("REALTIME_INBOX_ADDRESS")?; let proposer_multicall = read_contract_address("PROPOSER_MULTICALL_ADDRESS")?; let bridge = read_contract_address("L1_BRIDGE_ADDRESS")?; + let signal_service = read_contract_address("L1_SIGNAL_SERVICE_ADDRESS")?; + let l2_signal_service = read_contract_address("L2_SIGNAL_SERVICE_ADDRESS")?; let raiko_url = std::env::var("RAIKO_URL").unwrap_or_else(|_| "http://localhost:8080".to_string()); @@ -70,6 +77,8 @@ impl ConfigTrait for RealtimeConfig { realtime_inbox, proposer_multicall, bridge, + signal_service, + l2_signal_service, raiko_url, raiko_api_key, proof_type, From 9bcde20896f932dd9f5b1e5aef48ea2c2a79dbfc Mon Sep 17 00:00:00 2001 From: AnshuJalan Date: Tue, 14 Apr 2026 11:48:53 +0530 Subject: [PATCH 03/13] feat: add MockEcdsa proof type, shift bit flags to match SurgeVerifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MOCK_ECDSA=1, RISC0=2, SP1=4, ZISK=8 — mirrors the updated SurgeVerifier constants. PROOF_TYPE=mock_ecdsa selects the dummy verifier path. Co-Authored-By: Claude Opus 4.6 (1M context) --- realtime/src/l1/bindings.rs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/realtime/src/l1/bindings.rs b/realtime/src/l1/bindings.rs index 6ae6600e..df80e3ce 100644 --- a/realtime/src/l1/bindings.rs +++ b/realtime/src/l1/bindings.rs @@ -83,25 +83,29 @@ alloy::sol! { /// Proof types supported by the SurgeVerifier. /// Each variant maps to a bit flag used in `SubProof.proofBitFlag`. +/// Must match the constants in `SurgeVerifier.sol`. #[derive(Debug, Clone, Copy)] pub enum ProofType { - Risc0, // 0b00000001 - Sp1, // 0b00000010 - Zisk, // 0b00000100 + MockEcdsa, // 0b00000001 + Risc0, // 0b00000010 + Sp1, // 0b00000100 + Zisk, // 0b00001000 } impl ProofType { pub fn proof_bit_flag(&self) -> u8 { match self { - ProofType::Risc0 => 1, - ProofType::Sp1 => 1 << 1, - ProofType::Zisk => 1 << 2, + ProofType::MockEcdsa => 1, + ProofType::Risc0 => 1 << 1, + ProofType::Sp1 => 1 << 2, + ProofType::Zisk => 1 << 3, } } /// Returns the proof type string expected by Raiko. pub fn raiko_proof_type(&self) -> &'static str { match self { + ProofType::MockEcdsa => "mock_ecdsa", ProofType::Risc0 => "risc0", ProofType::Sp1 => "sp1", ProofType::Zisk => "zisk", @@ -114,11 +118,12 @@ impl std::str::FromStr for ProofType { fn from_str(s: &str) -> Result { match s.to_lowercase().as_str() { + "mock_ecdsa" => Ok(ProofType::MockEcdsa), "risc0" => Ok(ProofType::Risc0), "sp1" => Ok(ProofType::Sp1), "zisk" => Ok(ProofType::Zisk), _ => Err(anyhow::anyhow!( - "Invalid PROOF_TYPE '{}'. Must be one of: sp1, risc0, zisk", + "Invalid PROOF_TYPE '{}'. Must be one of: mock_ecdsa, sp1, risc0, zisk", s )), } From 98010a0559ab8c62f3d3644f871660c17fdf9f56 Mon Sep 17 00:00:00 2001 From: AnshuJalan Date: Tue, 14 Apr 2026 13:49:18 +0530 Subject: [PATCH 04/13] feat: add MOCK_MODE env to override SubProof bit flag to MOCK_ECDSA When MOCK_MODE=true, the on-chain SubProof bit flag is set to 1 (MOCK_ECDSA) regardless of PROOF_TYPE. This allows using a real Raiko proof type string (zisk/sp1/risc0) while routing on-chain to the DummyProofVerifier. Co-Authored-By: Claude Opus 4.6 (1M context) --- realtime/src/l1/bindings.rs | 19 +++++++++++-------- realtime/src/l1/config.rs | 2 ++ realtime/src/l1/execution_layer.rs | 6 +++++- realtime/src/l1/proposal_tx_builder.rs | 17 +++++++++++++++-- realtime/src/utils/config.rs | 9 +++++++++ 5 files changed, 42 insertions(+), 11 deletions(-) diff --git a/realtime/src/l1/bindings.rs b/realtime/src/l1/bindings.rs index df80e3ce..ed057eed 100644 --- a/realtime/src/l1/bindings.rs +++ b/realtime/src/l1/bindings.rs @@ -84,18 +84,20 @@ alloy::sol! { /// Proof types supported by the SurgeVerifier. /// Each variant maps to a bit flag used in `SubProof.proofBitFlag`. /// Must match the constants in `SurgeVerifier.sol`. +/// +/// Note: MOCK_ECDSA (0b00000001) is not a variant here — it is selected +/// at runtime via the `MOCK_MODE` env flag, which overrides the bit flag +/// to 1 regardless of the proof type. #[derive(Debug, Clone, Copy)] pub enum ProofType { - MockEcdsa, // 0b00000001 - Risc0, // 0b00000010 - Sp1, // 0b00000100 - Zisk, // 0b00001000 + Risc0, // 0b00000010 + Sp1, // 0b00000100 + Zisk, // 0b00001000 } impl ProofType { pub fn proof_bit_flag(&self) -> u8 { match self { - ProofType::MockEcdsa => 1, ProofType::Risc0 => 1 << 1, ProofType::Sp1 => 1 << 2, ProofType::Zisk => 1 << 3, @@ -105,7 +107,6 @@ impl ProofType { /// Returns the proof type string expected by Raiko. pub fn raiko_proof_type(&self) -> &'static str { match self { - ProofType::MockEcdsa => "mock_ecdsa", ProofType::Risc0 => "risc0", ProofType::Sp1 => "sp1", ProofType::Zisk => "zisk", @@ -113,17 +114,19 @@ impl ProofType { } } +/// SurgeVerifier MOCK_ECDSA bit flag — used when `MOCK_MODE=true`. +pub const MOCK_ECDSA_BIT_FLAG: u8 = 1; + impl std::str::FromStr for ProofType { type Err = anyhow::Error; fn from_str(s: &str) -> Result { match s.to_lowercase().as_str() { - "mock_ecdsa" => Ok(ProofType::MockEcdsa), "risc0" => Ok(ProofType::Risc0), "sp1" => Ok(ProofType::Sp1), "zisk" => Ok(ProofType::Zisk), _ => Err(anyhow::anyhow!( - "Invalid PROOF_TYPE '{}'. Must be one of: mock_ecdsa, sp1, risc0, zisk", + "Invalid PROOF_TYPE '{}'. Must be one of: sp1, risc0, zisk", s )), } diff --git a/realtime/src/l1/config.rs b/realtime/src/l1/config.rs index 2508f3bb..8a090b8f 100644 --- a/realtime/src/l1/config.rs +++ b/realtime/src/l1/config.rs @@ -17,6 +17,7 @@ pub struct EthereumL1Config { pub bridge: Address, pub signal_service: Address, pub proof_type: ProofType, + pub mock_mode: bool, pub raiko_client: RaikoClient, } @@ -31,6 +32,7 @@ impl TryFrom for EthereumL1Config { bridge: config.bridge, signal_service: config.signal_service, proof_type: config.proof_type, + mock_mode: config.mock_mode, raiko_client, }) } diff --git a/realtime/src/l1/execution_layer.rs b/realtime/src/l1/execution_layer.rs index e80ac31a..5c85ffe3 100644 --- a/realtime/src/l1/execution_layer.rs +++ b/realtime/src/l1/execution_layer.rs @@ -48,6 +48,7 @@ pub struct ExecutionLayer { #[allow(dead_code)] raiko_client: RaikoClient, proof_type: crate::l1::bindings::ProofType, + mock_mode: bool, } impl ELTrait for ExecutionLayer { @@ -102,6 +103,7 @@ impl ELTrait for ExecutionLayer { }; let proof_type = specific_config.proof_type; + let mock_mode = specific_config.mock_mode; let raiko_client = specific_config.raiko_client; Ok(Self { @@ -113,6 +115,7 @@ impl ELTrait for ExecutionLayer { realtime_inbox, raiko_client, proof_type, + mock_mode, }) } @@ -203,7 +206,8 @@ impl ExecutionLayer { batch.zk_proof.is_some(), ); - let builder = ProposalTxBuilder::new(self.provider.clone(), 10, self.proof_type); + let builder = + ProposalTxBuilder::new(self.provider.clone(), 10, self.proof_type, self.mock_mode); let tx = builder .build_propose_tx( diff --git a/realtime/src/l1/proposal_tx_builder.rs b/realtime/src/l1/proposal_tx_builder.rs index 5e637e1a..f9a26869 100644 --- a/realtime/src/l1/proposal_tx_builder.rs +++ b/realtime/src/l1/proposal_tx_builder.rs @@ -1,6 +1,7 @@ use crate::l1::{ bindings::{ BlobReference, Multicall, ProofType, ProposeInput, ProposeInputV2, RealTimeInbox, SubProof, + MOCK_ECDSA_BIT_FLAG, }, config::ContractAddresses, }; @@ -33,14 +34,21 @@ pub struct ProposalTxBuilder { provider: DynProvider, extra_gas_percentage: u64, proof_type: ProofType, + mock_mode: bool, } impl ProposalTxBuilder { - pub fn new(provider: DynProvider, extra_gas_percentage: u64, proof_type: ProofType) -> Self { + pub fn new( + provider: DynProvider, + extra_gas_percentage: u64, + proof_type: ProofType, + mock_mode: bool, + ) -> Self { Self { provider, extra_gas_percentage, proof_type, + mock_mode, } } @@ -250,8 +258,13 @@ impl ProposalTxBuilder { .ok_or_else(|| anyhow::anyhow!("ZK proof not set on proposal"))? .clone(); + let bit_flag = if self.mock_mode { + MOCK_ECDSA_BIT_FLAG + } else { + self.proof_type.proof_bit_flag() + }; let sub_proofs = vec![SubProof { - proofBitFlag: self.proof_type.proof_bit_flag(), + proofBitFlag: bit_flag, data: Bytes::from(raw_proof), }]; let proof = Bytes::from(sub_proofs.abi_encode()); diff --git a/realtime/src/utils/config.rs b/realtime/src/utils/config.rs index 382054aa..90ca566d 100644 --- a/realtime/src/utils/config.rs +++ b/realtime/src/utils/config.rs @@ -24,6 +24,10 @@ pub struct RealtimeConfig { pub bridge_rpc_addr: String, pub preconf_only: bool, pub proof_request_bypass: bool, + /// When true, overrides the SubProof bit flag to MOCK_ECDSA (0b00000001) + /// regardless of `proof_type`. Allows using a real Raiko proof type string + /// while routing on-chain to the DummyProofVerifier. + pub mock_mode: bool, } impl ConfigTrait for RealtimeConfig { @@ -73,6 +77,10 @@ impl ConfigTrait for RealtimeConfig { .map(|v| v.to_lowercase() != "false" && v != "0") .unwrap_or(false); + let mock_mode = std::env::var("MOCK_MODE") + .map(|v| v.to_lowercase() != "false" && v != "0") + .unwrap_or(false); + Ok(RealtimeConfig { realtime_inbox, proposer_multicall, @@ -89,6 +97,7 @@ impl ConfigTrait for RealtimeConfig { bridge_rpc_addr, preconf_only, proof_request_bypass, + mock_mode, }) } } From ca89c4f65a64e8710333d6a6b2806bb6070a08ad Mon Sep 17 00:00:00 2001 From: AnshuJalan Date: Wed, 15 Apr 2026 12:00:31 +0530 Subject: [PATCH 05/13] =?UTF-8?q?feat:=20redesign=20L2=E2=86=92L1=E2=86=92?= =?UTF-8?q?L2=20detection=20to=20mempool=20scanning=20+=20simulation=20RPC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous design routed L2 txs through surge_sendUserOp with a chainId field, which broke existing UserOp RPC consumers and conflated two conceptually different flows. Replaced with: - surge_sendUserOp: back to L1→L2→L1 only (no chainId field) - Mempool scanning: during block build, trace each pending L2 tx for Bridge.sendMessage and inject the return signal into the anchor - surge_simulateReturnMessage RPC: apps call this to get the exact return IBridge.Message before submitting to L2 mempool Key implementation details: - Call-based detection (not event logs): Nethermind's callTracer doesn't surface event logs through UUPS proxy DELEGATECALLs, so we scan for CALL frames to the bridge with the sendMessage selector (0x1bdb0037) and decode the Message from the call input - Bridge-assigned field patching: from/srcChainId/id are zero in the call input, filled by the bridge during execution. Patched with the caller address, chain_id, and nextMessageId respectively - L1 callback direct invocation: instead of Bridge.processMessage (which requires L1 signal verification we can't bypass), call callback.onMessageInvocation directly with from=bridge and state-override the bridge's __ctx (slot 253-254) so context() returns the correct msgHash/from/srcChainId Removed: L2Direct UserOp routing, FlashLoanExecutor calldata patching, executeCall/FlashLoanReturnMessage bindings. Tested end-to-end on devnet: full L2→L1→L2 flash loan completes atomically with 1% fee to beneficiary, pool fully repaid. Co-Authored-By: Claude Opus 4.6 (1M context) --- realtime/src/l1/bindings.rs | 29 -- realtime/src/l1/execution_layer.rs | 195 +++++------ realtime/src/l2/execution_layer.rs | 201 +++++------ realtime/src/lib.rs | 2 - realtime/src/node/mod.rs | 2 - .../node/proposal_manager/bridge_handler.rs | 147 +++++--- realtime/src/node/proposal_manager/mod.rs | 323 +++++------------- 7 files changed, 376 insertions(+), 523 deletions(-) diff --git a/realtime/src/l1/bindings.rs b/realtime/src/l1/bindings.rs index ed057eed..f915be75 100644 --- a/realtime/src/l1/bindings.rs +++ b/realtime/src/l1/bindings.rs @@ -51,35 +51,6 @@ sol! { } } -// Binding for the L2 flash loan executor's `execute` entry point. Used by the -// proposal manager to detect UserOps that target this ABI and patch their -// placeholder `returnMessage` with the simulated L1→L2 return. -// -// NOTE: this mirrors `IBridge.Message` struct layout. The field types must -// match exactly or abi_decode/abi_encode_sequence will fail. -alloy::sol! { - #[allow(missing_docs)] - struct FlashLoanReturnMessage { - uint64 id; - uint64 fee; - uint32 gasLimit; - address from; - uint64 srcChainId; - address srcOwner; - uint64 destChainId; - address destOwner; - address to; - uint256 value; - bytes data; - } - - #[allow(missing_docs)] - function execute( - uint256 amount, - address beneficiary, - FlashLoanReturnMessage returnMessage - ) external; -} /// Proof types supported by the SurgeVerifier. /// Each variant maps to a bit flag used in `SubProof.proofBitFlag`. diff --git a/realtime/src/l1/execution_layer.rs b/realtime/src/l1/execution_layer.rs index 5c85ffe3..f7da5ae8 100644 --- a/realtime/src/l1/execution_layer.rs +++ b/realtime/src/l1/execution_layer.rs @@ -383,19 +383,18 @@ impl L1BridgeHandlerOps for ExecutionLayer { message_from_l2: Message, _signal_slot_proof: Bytes, bridge_address: Address, - l2_bridge_address: Address, + _l2_bridge_address: Address, ) -> Result)>, anyhow::Error> { use alloy::primitives::{B256, U256, keccak256}; use alloy::rpc::types::state::{AccountOverride, StateOverride}; - // Compute the L2→L1 signal slot that the bridge will check during - // proveSignalReceived. On L1 SignalService: - // slot = keccak256(abi.encodePacked("SIGNAL", srcChainId, app, msgHash)) - // where app = the L2 bridge that emitted the signal. - // - // hashMessage = keccak256(abi.encode("TAIKO_MESSAGE", message)) - // Compute it on-chain via Bridge.hashMessage to avoid replicating the - // exact Solidity abi.encode of a (string, struct) tuple in Rust. + // Instead of simulating Bridge.processMessage (which requires L1 + // signal verification we can't bypass), we call the L1 callback's + // onMessageInvocation(data) directly with from=bridge. To make + // bridge.context() return the correct values, we state-override the + // bridge's __ctx storage (slots 253-254, see Bridge_Layout.sol): + // slot 253: msgHash (bytes32) + // slot 254: from (address, 20 bytes) | srcChainId (uint64, 8 bytes) let bridge = Bridge::new(bridge_address, self.provider.clone()); let msg_hash: B256 = bridge @@ -404,67 +403,39 @@ impl L1BridgeHandlerOps for ExecutionLayer { .await .map_err(|e| anyhow!("Failed to call Bridge.hashMessage for sim: {e}"))?; - // Bridge.processMessage on L1 passes `app = resolve(srcChainId, - // B_BRIDGE) = L2 bridge address` to SignalService.proveSignalReceived. - // That's what the signal slot was derived from on L2 (msg.sender of - // SignalService.sendSignal was the L2 bridge). Caller passes in the - // L2 bridge address (auto-derived from L2 chain id on the L2 side). - let app = l2_bridge_address; - let src_chain_id = message_from_l2.srcChainId; - - // Mirror SignalService.getSignalSlot: keccak256(abi.encodePacked( - // "SIGNAL", uint64 chainId, address app, bytes32 signal)) - let mut preimage = Vec::with_capacity(6 + 8 + 20 + 32); - preimage.extend_from_slice(b"SIGNAL"); - preimage.extend_from_slice(&src_chain_id.to_be_bytes()); - preimage.extend_from_slice(app.as_slice()); - preimage.extend_from_slice(msg_hash.as_slice()); - let signal_slot_key: B256 = keccak256(&preimage); - - // Storage slot of `_receivedSignals[signal_slot_key]` on L1 SignalService - // `_receivedSignals` is at storage slot 253 (see SignalService_Layout.sol). - let received_signals_base_slot = U256::from(253u64); - let mut key_preimage = Vec::with_capacity(64); - key_preimage.extend_from_slice(signal_slot_key.as_slice()); - key_preimage.extend_from_slice(&B256::from(received_signals_base_slot).0); - let received_signals_storage_slot: B256 = keccak256(&key_preimage); - - // Build calldata for `Bridge.processMessage(message_from_l2, "")` with - // empty proof. With `_receivedSignals[slot] = true` state-overridden, - // the fast-signal path in proveSignalReceived succeeds, so the bridge - // proceeds to invoke the target's onMessageInvocation, whose trace we - // then scan for the L1→L2 return. - let calldata = Bridge::processMessageCall { - _message: message_from_l2, - _proof: Bytes::new(), - } - .abi_encode(); - + // Pack slot 254: address `from` (low 20 bytes) + uint64 srcChainId (next 8 bytes) + // Solidity packs struct members right-aligned in the same slot: + // from occupies bytes [0..20), srcChainId occupies bytes [20..28) + let mut slot_254 = [0u8; 32]; + slot_254[12..32].copy_from_slice(message_from_l2.from.as_slice()); + slot_254[4..12].copy_from_slice(&message_from_l2.srcChainId.to_be_bytes()); + let slot_254_value = B256::from(slot_254); + + // message_from_l2.data is already the full ABI-encoded calldata for + // onMessageInvocation(bytes) — exactly what Bridge.processMessage + // would pass to the target. Use it directly. + let callback_address = message_from_l2.to; let tx_request = TransactionRequest::default() - .from(self.preconfer_address) - .to(bridge_address) - .input(calldata.into()); - - // State-override: mark the signal as received on the L1 SignalService. - let signal_service_address = self.contract_addresses.signal_service; - let account_override = AccountOverride::default().with_state_diff( - std::iter::once(( - received_signals_storage_slot, - B256::from(U256::from(1)), - )), - ); + .from(bridge_address) // msg.sender = bridge (passes ONLY_BRIDGE check) + .to(callback_address) + .input(message_from_l2.data.clone().into()); + + // State-override the bridge's __ctx storage so context() returns + // the correct msgHash, from, and srcChainId. + let bridge_ctx_override = AccountOverride::default().with_state_diff([ + (B256::from(U256::from(253u64)), msg_hash), // __ctx.msgHash + (B256::from(U256::from(254u64)), slot_254_value), // __ctx.from + srcChainId + ]); let mut state_overrides = StateOverride::default(); - state_overrides.insert(signal_service_address, account_override); + state_overrides.insert(bridge_address, bridge_ctx_override); - let mut tracer_config = serde_json::Map::new(); - tracer_config.insert("withLog".to_string(), serde_json::Value::Bool(true)); - tracer_config.insert("onlyTopCall".to_string(), serde_json::Value::Bool(false)); + let tracer_config = serde_json::json!({"onlyTopCall": false}); let tracing_options = GethDebugTracingOptions { tracer: Some(GethDebugTracerType::BuiltInTracer( GethDebugBuiltInTracerType::CallTracer, )), - tracer_config: serde_json::Value::Object(tracer_config).into(), + tracer_config: tracer_config.into(), ..Default::default() }; @@ -485,58 +456,90 @@ impl L1BridgeHandlerOps for ExecutionLayer { { Ok(t) => t, Err(e) => { - // RPC-level failure (not a revert inside the trace). Surface as error. return Err(anyhow!("L1 callback simulation RPC failed: {e}")); } }; - let mut message: Option = None; - let mut slot: Option> = None; + // Scan the trace for a sendMessage call to the L1 bridge. + let mut return_msg: Option = None; if let alloy::rpc::types::trace::geth::GethTrace::CallTracer(call_frame) = trace_result { - // Collect logs regardless of whether the simulation reverted — the - // MessageSent event is emitted during processMessage's invoked - // callback, which may succeed even if a later sub-call reverts. - let all_logs = collect_logs_recursive(&call_frame); - for log in all_logs { - if let Some(topics) = &log.topics - && !topics.is_empty() - { - if topics[0] == MessageSent::SIGNATURE_HASH { - let log_data = alloy::primitives::LogData::new_unchecked( - topics.clone(), - log.data.clone().unwrap_or_default(), - ); - let decoded = MessageSent::decode_log_data(&log_data).map_err(|e| { - anyhow!("Failed to decode MessageSent from L1 callback sim: {e}") - })?; - message = Some(decoded.message); - } else if topics[0] == SignalSent::SIGNATURE_HASH { - let log_data = alloy::primitives::LogData::new_unchecked( - topics.clone(), - log.data.clone().unwrap_or_default(), - ); - let decoded = SignalSent::decode_log_data(&log_data).map_err(|e| { - anyhow!("Failed to decode SignalSent from L1 callback sim: {e}") - })?; - slot = Some(decoded.slot); - } + if let Some((mut msg, caller)) = + find_send_message_in_call_tree(&call_frame, bridge_address) + { + // Patch bridge-assigned fields (from, srcChainId, id) + msg.from = caller; + msg.srcChainId = self.common.chain_id(); + // Query nextMessageId for the id the bridge would assign + let bridge_contract = Bridge::new(bridge_address, self.provider.clone()); + if let Ok(next_id) = bridge_contract.nextMessageId().call().await { + msg.id = next_id; } + return_msg = Some(msg); } } - if let (Some(m), Some(s)) = (message, slot) { + if let Some(m) = return_msg { + // Compute the signal slot: keccak256("SIGNAL", L1_chain_id, L1_bridge, msgHash) + let return_msg_hash: B256 = bridge + .hashMessage(m.clone()) + .call() + .await + .map_err(|e| anyhow!("Failed to call Bridge.hashMessage for return msg: {e}"))?; + + let l1_chain_id = self.common.chain_id(); + let mut slot_preimage = Vec::with_capacity(6 + 8 + 20 + 32); + slot_preimage.extend_from_slice(b"SIGNAL"); + slot_preimage.extend_from_slice(&l1_chain_id.to_be_bytes()); + slot_preimage.extend_from_slice(bridge_address.as_slice()); + slot_preimage.extend_from_slice(return_msg_hash.as_slice()); + let signal_slot: FixedBytes<32> = keccak256(&slot_preimage); + tracing::info!( "L1 callback simulation found return signal: slot={}, destChainId={}", - s, + signal_slot, m.destChainId ); - Ok(Some((m, s))) + Ok(Some((m, signal_slot))) } else { tracing::debug!( - "L1 callback simulation produced no MessageSent/SignalSent pair" + "L1 callback simulation found no sendMessage call in trace" ); Ok(None) } } } + +/// `Bridge.sendMessage(Message)` selector. +const SEND_MESSAGE_SELECTOR: [u8; 4] = [0x1b, 0xdb, 0x00, 0x37]; + +/// Recursively search call frames for a CALL to `bridge_address` with the +/// `sendMessage` function selector. Returns the decoded `IBridge.Message` +/// and the caller address (msg.sender of the sendMessage call). +fn find_send_message_in_call_tree( + frame: &CallFrame, + bridge_address: Address, +) -> Option<(Message, Address)> { + use alloy::sol_types::SolCall; + + if let Some(to_addr) = frame.to { + if to_addr == bridge_address { + let input = frame.input.as_ref(); + if input.len() >= 4 && input[0..4] == SEND_MESSAGE_SELECTOR { + if let Ok(decoded) = + Bridge::sendMessageCall::abi_decode_raw(&input[4..]) + { + return Some((decoded._message, frame.from)); + } + } + } + } + + for sub in &frame.calls { + if let Some(result) = find_send_message_in_call_tree(sub, bridge_address) { + return Some(result); + } + } + + None +} diff --git a/realtime/src/l2/execution_layer.rs b/realtime/src/l2/execution_layer.rs index c6a12a05..3e7b2221 100644 --- a/realtime/src/l2/execution_layer.rs +++ b/realtime/src/l2/execution_layer.rs @@ -241,72 +241,6 @@ impl L2ExecutionLayer { } } -// Surge: L2 UserOp execution - -use crate::node::proposal_manager::bridge_handler::UserOp; - -impl L2ExecutionLayer { - /// Construct a signed L2 transaction that executes a UserOp on L2 - /// by forwarding the calldata to the submitter smart wallet. - pub async fn construct_l2_user_op_tx(&self, user_op: &UserOp) -> Result { - use alloy::signers::local::PrivateKeySigner; - use std::str::FromStr; - - debug!( - "Constructing L2 UserOp execution tx for submitter={}", - user_op.submitter - ); - - let signer_address = self.l2_call_signer.get_address(); - - let nonce = self - .provider - .get_transaction_count(signer_address) - .await - .map_err(|e| anyhow::anyhow!("Failed to get nonce for L2 UserOp tx: {}", e))?; - - let typed_tx = alloy::consensus::TxEip1559 { - chain_id: self.chain_id, - nonce, - gas_limit: 3_000_000, - max_fee_per_gas: 1_000_000_000, - max_priority_fee_per_gas: 0, - to: alloy::primitives::TxKind::Call(user_op.submitter), - value: alloy::primitives::U256::ZERO, - input: user_op.calldata.clone(), - access_list: Default::default(), - }; - - let signature = match self.l2_call_signer.as_ref() { - Signer::Web3signer(web3signer, address) => { - let signature_bytes = web3signer.sign_transaction(&typed_tx, *address).await?; - Signature::try_from(signature_bytes.as_slice()) - .map_err(|e| anyhow::anyhow!("Failed to parse signature: {}", e))? - } - Signer::PrivateKey(private_key, _) => { - let signer = PrivateKeySigner::from_str(private_key.as_str())?; - AlloySigner::sign_hash(&signer, &typed_tx.signature_hash()).await? - } - }; - - let sig_tx = typed_tx.into_signed(signature); - let tx_envelope = TxEnvelope::from(sig_tx); - - debug!("L2 UserOp execution tx hash: {}", tx_envelope.tx_hash()); - - // SAFETY: `new_unchecked` is safe here because we just signed `tx_envelope` with - // `l2_call_signer` and `signer_address` is derived from the same key. - let tx = Transaction { - inner: Recovered::new_unchecked(tx_envelope, signer_address), - block_hash: None, - block_number: None, - transaction_index: None, - effective_gas_price: None, - }; - Ok(tx) - } -} - // Surge: L2 EL ops for Bridge Handler pub trait L2BridgeHandlerOps { @@ -321,19 +255,6 @@ pub trait L2BridgeHandlerOps { block_id: u64, state_root: B256, ) -> Result; - - /// Dry-run a UserOp on the L2 node to discover any L2→L1 bridge message it - /// emits via `Bridge.sendMessage`. The trace is captured even when the - /// overall tx reverts — e.g. because the UserOp later calls - /// `Bridge.processMessage(returnMsg, "")` which requires a fast signal - /// that hasn't been injected yet. - /// - /// Returns the first `MessageSent` payload observed (Message struct), - /// or `None` if the UserOp never calls `Bridge.sendMessage`. - async fn trace_user_op_for_outbound_message( - &self, - user_op: &UserOp, - ) -> Result, anyhow::Error>; } impl L2BridgeHandlerOps for L2ExecutionLayer { @@ -501,24 +422,41 @@ impl L2BridgeHandlerOps for L2ExecutionLayer { Ok(Bytes::from(vec![hop_proof].abi_encode_params())) } - async fn trace_user_op_for_outbound_message( +} + +// Surge: L2 mempool tx scanning and simulation + +/// `Bridge.sendMessage(Message)` selector — used for call-based detection +/// in the trace tree because the L2 bridge is behind a DELEGATECALL proxy +/// and the Nethermind callTracer doesn't surface event logs from proxied calls. +const SEND_MESSAGE_SELECTOR: [u8; 4] = [0x1b, 0xdb, 0x00, 0x37]; + +impl L2ExecutionLayer { + /// Trace a transaction to detect any `Bridge.sendMessage` call it makes. + /// Instead of relying on `MessageSent` event logs (which the L2 Nethermind + /// callTracer doesn't emit through DELEGATECALL proxies), we scan the call + /// tree for CALL frames targeting the L2 bridge with the `sendMessage` + /// selector, and decode the Message from the call input. + pub async fn trace_tx_for_outbound_message( &self, - user_op: &UserOp, + from: Address, + to: Address, + input: &[u8], ) -> Result, anyhow::Error> { let tx_request = TransactionRequest::default() - .from(user_op.submitter) - .to(user_op.submitter) - .input(user_op.calldata.clone().into()); + .from(from) + .to(to) + .input(input.to_vec().into()); - let mut tracer_config = serde_json::Map::new(); - tracer_config.insert("withLog".to_string(), serde_json::Value::Bool(true)); - tracer_config.insert("onlyTopCall".to_string(), serde_json::Value::Bool(false)); + let tracer_config = serde_json::json!({ + "onlyTopCall": false + }); let tracing_options = GethDebugTracingOptions { tracer: Some(GethDebugTracerType::BuiltInTracer( GethDebugBuiltInTracerType::CallTracer, )), - tracer_config: serde_json::Value::Object(tracer_config).into(), + tracer_config: tracer_config.into(), ..Default::default() }; @@ -538,59 +476,78 @@ impl L2BridgeHandlerOps for L2ExecutionLayer { { Ok(t) => t, Err(e) => { - // For the "simulate a reverting tx" case, some L2 nodes still - // return the trace even when the tx fails. RPC-level errors are - // distinct from reverts and we surface them. return Err(anyhow::anyhow!( - "L2 user-op simulation RPC failed: {e}" + "L2 tx trace RPC failed: {e}" )); } }; + let bridge_address = *self.bridge.address(); let mut message: Option = None; + let mut send_message_caller: Option
= None; if let alloy::rpc::types::trace::geth::GethTrace::CallTracer(call_frame) = trace_result { - // Walk the entire call tree. The MessageSent event may be emitted - // by a nested Bridge.sendMessage call long before any later revert. - let all_logs = collect_logs_from_frame(&call_frame); - for log in all_logs { - if let Some(topics) = &log.topics - && !topics.is_empty() - && topics[0] == MessageSent::SIGNATURE_HASH - { - let log_data = alloy::primitives::LogData::new_unchecked( - topics.clone(), - log.data.clone().unwrap_or_default(), - ); - let decoded = MessageSent::decode_log_data(&log_data).map_err(|e| { - anyhow::anyhow!("Failed to decode MessageSent from L2 sim: {e}") - })?; - message = Some(decoded.message); - break; // first MessageSent wins for the POC - } + // Walk the call tree looking for CALL frames to the bridge with + // the sendMessage selector. The Message struct is ABI-encoded as + // the first (and only) parameter after the 4-byte selector. + if let Some((msg, caller)) = find_send_message_in_calls(&call_frame, bridge_address) { + message = Some(msg); + send_message_caller = Some(caller); } } - if let Some(m) = &message { + if let Some(ref mut m) = message { + // The bridge fills `from`, `srcChainId`, and `id` during sendMessage + // execution, but the call-based detection reads the INPUT before + // those are set. Patch them with what the bridge would assign. + m.from = send_message_caller.unwrap_or(from); + m.srcChainId = self.chain_id; + // For `id`, query the bridge's nextMessageId (this is what it would assign) + if let Ok(next_id) = self.bridge.nextMessageId().call().await { + m.id = next_id; + } + debug!( - "L2 pre-sim found outbound sendMessage: destChainId={}, to={}", - m.destChainId, m.to + "L2 trace found outbound sendMessage: destChainId={}, to={}, from={}", + m.destChainId, m.to, m.from ); } else { - debug!("L2 pre-sim found no outbound sendMessage in UserOp trace"); + debug!("L2 trace found no outbound sendMessage"); } Ok(message) } } -/// Recursively collect all logs emitted during a traced call (including from -/// sub-calls). Mirrors the L1-side helper in `crate::l1::execution_layer`. -fn collect_logs_from_frame(frame: &CallFrame) -> Vec { - let mut logs = Vec::new(); - logs.extend(frame.logs.iter().cloned()); - for sub_frame in &frame.calls { - logs.extend(collect_logs_from_frame(sub_frame)); +/// Recursively search call frames for a CALL to `bridge_address` with the +/// `sendMessage` function selector. Returns the decoded Message and the +/// caller address (msg.sender of the sendMessage call). +fn find_send_message_in_calls( + frame: &CallFrame, + bridge_address: Address, +) -> Option<(Message, Address)> { + use alloy::sol_types::SolCall; + use crate::shared_abi::bindings::Bridge; + + // Check this frame: is it a CALL to the bridge with sendMessage selector? + if let Some(to_addr) = frame.to { + if to_addr == bridge_address { + let input = frame.input.as_ref(); + if input.len() >= 4 && input[0..4] == SEND_MESSAGE_SELECTOR { + if let Ok(decoded) = Bridge::sendMessageCall::abi_decode_raw(&input[4..]) { + // `frame.from` is the msg.sender of this call + let caller = frame.from; + return Some((decoded._message, caller)); + } + } + } } - logs + + for sub in &frame.calls { + if let Some(result) = find_send_message_in_calls(sub, bridge_address) { + return Some(result); + } + } + + None } diff --git a/realtime/src/lib.rs b/realtime/src/lib.rs index 1ae2ae2d..7e64627f 100644 --- a/realtime/src/lib.rs +++ b/realtime/src/lib.rs @@ -144,7 +144,6 @@ pub async fn create_realtime_node( use common::l1::traits::ELTrait; ethereum_l1.execution_layer.common().chain_id() }; - let l2_chain_id = taiko.l2_execution_layer().chain_id; let node = Node::new( node_config, @@ -162,7 +161,6 @@ pub async fn create_realtime_node( proof_request_bypass, bridge_rpc_addr, l1_chain_id, - l2_chain_id, ) .await .map_err(|e| anyhow::anyhow!("Failed to create Node: {}", e))?; diff --git a/realtime/src/node/mod.rs b/realtime/src/node/mod.rs index f107f710..89402bba 100644 --- a/realtime/src/node/mod.rs +++ b/realtime/src/node/mod.rs @@ -59,7 +59,6 @@ impl Node { proof_request_bypass: bool, bridge_rpc_addr: String, l1_chain_id: u64, - l2_chain_id: u64, ) -> Result { let operator = Operator::new( ethereum_l1.execution_layer.clone(), @@ -91,7 +90,6 @@ impl Node { proof_request_bypass, bridge_rpc_addr, l1_chain_id, - l2_chain_id, ) .await .map_err(|e| anyhow::anyhow!("Failed to create BatchManager: {}", e))?; diff --git a/realtime/src/node/proposal_manager/bridge_handler.rs b/realtime/src/node/proposal_manager/bridge_handler.rs index e52d9f05..10d937bb 100644 --- a/realtime/src/node/proposal_manager/bridge_handler.rs +++ b/realtime/src/node/proposal_manager/bridge_handler.rs @@ -64,8 +64,6 @@ pub struct UserOp { pub id: u64, pub submitter: Address, pub calldata: Bytes, - #[serde(default, rename = "chainId")] - pub chain_id: u64, } // Data required to build the L1 call transaction initiated by an L2 contract via the bridge @@ -88,14 +86,10 @@ pub struct L2Call { pub signal_slot_on_l2: FixedBytes<32>, } -/// Result of routing a UserOp: either it targets L1 (and triggers an L2 bridge call) -/// or it targets L2 (for direct execution on L2, e.g. bridge-out). -#[allow(clippy::large_enum_variant)] -pub enum UserOpRouting { - /// L1 UserOp that triggers a bridge deposit (L1→L2). - L1ToL2 { user_op: UserOp, l2_call: L2Call }, - /// L2 UserOp for direct execution on L2 (e.g. bridge-out L2→L1). - L2Direct { user_op: UserOp }, +/// Routed L1→L2 UserOp: triggers an L2 bridge call via processMessage. +pub struct RoutedUserOp { + pub user_op: UserOp, + pub l2_call: L2Call, } #[derive(Debug, Deserialize)] @@ -111,6 +105,7 @@ struct BridgeRpcContext { tx: mpsc::Sender, status_store: UserOpStatusStore, next_id: Arc, + ethereum_l1: Arc>, taiko: Arc, last_finalized_block_number: Arc, } @@ -121,7 +116,6 @@ pub struct BridgeHandler { rx: Receiver, status_store: UserOpStatusStore, l1_chain_id: u64, - l2_chain_id: u64, } impl BridgeHandler { @@ -131,7 +125,6 @@ impl BridgeHandler { taiko: Arc, cancellation_token: CancellationToken, l1_chain_id: u64, - l2_chain_id: u64, last_finalized_block_number: Arc, ) -> Result { let (tx, rx) = mpsc::channel::(1024); @@ -141,6 +134,7 @@ impl BridgeHandler { tx, status_store: status_store.clone(), next_id: Arc::new(AtomicU64::new(1)), + ethereum_l1: ethereum_l1.clone(), taiko: taiko.clone(), last_finalized_block_number, }; @@ -266,6 +260,100 @@ impl BridgeHandler { } })?; + // surge_simulateReturnMessage: given a raw L2 tx (from, to, data), + // trace it for an L2→L1 outbound, simulate the L1 callback, and return + // the IBridge.Message that the L1 callback would produce. Users call this + // before submitting to the L2 mempool so they can embed the correct + // returnMessage in their calldata. + module.register_async_method( + "surge_simulateReturnMessage", + |params, ctx, _| async move { + use crate::l1::execution_layer::L1BridgeHandlerOps; + + #[derive(serde::Deserialize)] + struct SimRequest { + from: Address, + to: Address, + data: Bytes, + } + + let req: SimRequest = params.one()?; + info!( + "surge_simulateReturnMessage: from={}, to={}, data_len={}", + req.from, + req.to, + req.data.len() + ); + + let l2_el = ctx.taiko.l2_execution_layer(); + + // Step 1: trace the L2 tx for outbound Bridge.sendMessage + let outbound = l2_el + .trace_tx_for_outbound_message(req.from, req.to, &req.data) + .await + .map_err(|e| { + jsonrpsee::types::ErrorObjectOwned::owned( + -32000, + "L2 trace failed", + Some(format!("{e}")), + ) + })? + .ok_or_else(|| { + jsonrpsee::types::ErrorObjectOwned::owned( + -32001, + "No outbound Bridge.sendMessage found in trace", + None::, + ) + })?; + + // Step 2: simulate the L1 callback + let l1_el = &ctx.ethereum_l1.execution_layer; + let bridge_addr = l1_el.contract_addresses().bridge; + let l2_bridge_addr = *l2_el.bridge.address(); + + let (return_msg, return_slot) = l1_el + .simulate_l1_callback_return_signal( + outbound, + Bytes::new(), + bridge_addr, + l2_bridge_addr, + ) + .await + .map_err(|e| { + jsonrpsee::types::ErrorObjectOwned::owned( + -32000, + "L1 callback simulation failed", + Some(format!("{e}")), + ) + })? + .ok_or_else(|| { + jsonrpsee::types::ErrorObjectOwned::owned( + -32002, + "L1 callback produced no return message", + None::, + ) + })?; + + // Return the Message struct fields + signal slot as JSON + Ok::(serde_json::json!({ + "message": { + "id": return_msg.id, + "fee": return_msg.fee, + "gasLimit": return_msg.gasLimit, + "from": format!("{}", return_msg.from), + "srcChainId": return_msg.srcChainId, + "srcOwner": format!("{}", return_msg.srcOwner), + "destChainId": return_msg.destChainId, + "destOwner": format!("{}", return_msg.destOwner), + "to": format!("{}", return_msg.to), + "value": format!("{}", return_msg.value), + "data": format!("0x{}", hex::encode(&return_msg.data)), + }, + "signalSlot": format!("{}", return_slot), + })) + }, + )?; + info!("Bridge handler RPC server starting on {}", addr); let handle = server.start(module); @@ -281,7 +369,6 @@ impl BridgeHandler { rx, status_store, l1_chain_id, - l2_chain_id, }) } @@ -289,39 +376,13 @@ impl BridgeHandler { self.status_store.clone() } - /// Dequeue the next UserOp and route it based on the `chainId` param. - /// - /// If `chainId` matches L1, simulates on L1 to extract bridge message (L1→L2 deposit). - /// If `chainId` matches L2, returns it for direct L2 block inclusion (bridge-out). - /// If `chainId` is 0 or missing, defaults to L1 (backwards compatible). - pub async fn next_user_op_routed(&mut self) -> Result, anyhow::Error> { + /// Dequeue the next UserOp, simulate on L1 to extract the bridge message + /// (L1→L2 deposit). UserOps always target L1. + pub async fn next_user_op(&mut self) -> Result, anyhow::Error> { let Ok(user_op) = self.rx.try_recv() else { return Ok(None); }; - if user_op.chain_id == self.l2_chain_id { - info!( - "UserOp id={} targets L2 (chainId={}), queueing for L2 execution", - user_op.id, user_op.chain_id - ); - return Ok(Some(UserOpRouting::L2Direct { user_op })); - } - - // Reject unknown chain IDs (0 is allowed as default-to-L1) - 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); - } - // L1 UserOp — simulate on L1 to extract bridge message if let Some((message_from_l1, signal_slot_on_l2)) = self .ethereum_l1 @@ -329,7 +390,7 @@ impl BridgeHandler { .find_message_and_signal_slot(user_op.clone()) .await? { - return Ok(Some(UserOpRouting::L1ToL2 { + return Ok(Some(RoutedUserOp { user_op, l2_call: L2Call { message_from_l1, diff --git a/realtime/src/node/proposal_manager/mod.rs b/realtime/src/node/proposal_manager/mod.rs index d954494b..cee3f323 100644 --- a/realtime/src/node/proposal_manager/mod.rs +++ b/realtime/src/node/proposal_manager/mod.rs @@ -4,15 +4,13 @@ pub mod bridge_handler; pub mod l2_block_payload; pub mod proposal; -use crate::l1::bindings::{ - FlashLoanReturnMessage, ICheckpointStore::Checkpoint, executeCall, -}; +use crate::l1::bindings::ICheckpointStore::Checkpoint; use crate::l1::execution_layer::L1BridgeHandlerOps; use crate::l2::execution_layer::L2BridgeHandlerOps; use crate::node::proposal_manager::bridge_handler::UserOp; use crate::raiko::RaikoClient; -use crate::shared_abi::bindings::IBridge::Message as BridgeMessage; use crate::{l1::execution_layer::ExecutionLayer, l2::taiko::Taiko}; +use alloy::consensus::Transaction as _; use alloy::primitives::aliases::U48; use alloy::primitives::{B256, FixedBytes}; use anyhow::Error; @@ -74,7 +72,6 @@ impl BatchManager { proof_request_bypass: bool, bridge_rpc_addr: String, l1_chain_id: u64, - l2_chain_id: u64, ) -> Result { info!( "Batch builder config:\n\ @@ -107,7 +104,6 @@ impl BatchManager { taiko.clone(), cancel_token.clone(), l1_chain_id, - l2_chain_id, last_finalized_block_number.clone(), ) .await?, @@ -298,175 +294,109 @@ impl BatchManager { self.bridge_handler.lock().await.has_pending_user_ops() } - /// Process all pending UserOps: route each to L1 or L2 based on its chainId field. - /// - /// - L1→L2 deposits: UserOp added to proposal (for L1 multicall), processMessage tx added to L2 block - /// - L2 direct (bridge-out): UserOp execution tx added to L2 block, L2→L1 relay handled post-execution + /// Process pending L1 UserOps: simulate on L1 to extract bridge message, + /// then insert processMessage tx into the L2 block. async fn add_pending_user_ops_to_draft_block( &mut self, l2_draft_block: &mut L2BlockV2Draft, ) -> Result)>, anyhow::Error> { - use bridge_handler::UserOpRouting; - - let (routing, status_store) = { + let routed = { let mut handler = self.bridge_handler.lock().await; - let routing = handler.next_user_op_routed().await?; - (routing, handler.status_store()) + handler.next_user_op().await? }; - let Some(routing) = routing else { + let Some(routed) = routed else { return Ok(None); }; - match routing { - UserOpRouting::L1ToL2 { user_op, l2_call } => { - info!("Processing L1→L2 deposit: UserOp id={}", user_op.id); - - let l2_call_bridge_tx = self - .taiko - .l2_execution_layer() - .construct_l2_call_tx(l2_call.message_from_l1) - .await?; + info!( + "Processing L1→L2 deposit: UserOp id={}", + routed.user_op.id + ); - info!("Inserting processMessage tx into L2 block"); - l2_draft_block - .prebuilt_tx_list - .tx_list - .push(l2_call_bridge_tx); + let l2_call_bridge_tx = self + .taiko + .l2_execution_layer() + .construct_l2_call_tx(routed.l2_call.message_from_l1) + .await?; - Ok(Some((user_op, l2_call.signal_slot_on_l2))) - } - UserOpRouting::L2Direct { user_op } => { - info!( - "Processing L2 UserOp (bridge-out): id={} submitter={}", - user_op.id, user_op.submitter - ); + info!("Inserting processMessage tx into L2 block"); + l2_draft_block + .prebuilt_tx_list + .tx_list + .push(l2_call_bridge_tx); - // --- Pass 2: pre-simulate L2→L1 outbound + L1 callback return --- - // - // Dry-run the UserOp to extract any `bridge.sendMessage` it emits - // (even if the tx reverts on a later `bridge.processMessage` - // fast-signal check). If found, simulate the L1 callback to - // discover the L1→L2 return signal. If present, the UserOp's - // placeholder `returnMessage` is patched with the simulated one. - let patched_user_op = match self - .prepare_l2_direct_user_op(user_op.clone()) - .await - { - Ok((patched, Some((_return_msg, slot)))) => { - info!( - "L2Direct pre-sim extracted L1→L2 return slot={} for UserOp id={}", - slot, user_op.id - ); - self.pending_return_signal = Some(slot); - patched - } - Ok((patched, None)) => { - debug!( - "L2Direct pre-sim found no return signal for UserOp id={}", - user_op.id - ); - patched - } - Err(e) => { - warn!( - "L2Direct pre-sim failed for UserOp id={}: {}. Proceeding with original calldata.", - user_op.id, e - ); - user_op.clone() - } - }; - - match self - .taiko - .l2_execution_layer() - .construct_l2_user_op_tx(&patched_user_op) - .await - { - Ok(tx) => { - // Track L2 UserOp ID first — only insert tx if tracking succeeds, - // otherwise we'd execute on L2 but show Rejected in the status store. - if let Err(e) = self.batch_builder.add_l2_user_op_id(user_op.id) { - error!( - "Failed to track L2 UserOp id={}: {}. Dropping tx.", - user_op.id, e - ); - status_store.set( - user_op.id, - &bridge_handler::UserOpStatus::Rejected { - reason: format!("Failed to track UserOp: {}", e), - }, - ); - } else { - info!("Inserting L2 UserOp execution tx into block"); - l2_draft_block.prebuilt_tx_list.tx_list.push(tx); - } - } - Err(e) => { - error!("Failed to construct L2 UserOp tx: {}", e); - status_store.set( - user_op.id, - &bridge_handler::UserOpStatus::Rejected { - reason: format!("Failed to construct L2 tx: {}", e), - }, - ); - } - } - // No L1 UserOp or signal slot returned here for L2-direct ops; - // `pending_return_signal` on `self` carries any return slot. - Ok(None) - } - } + Ok(Some((routed.user_op, routed.l2_call.signal_slot_on_l2))) } - /// Pre-simulate an L2Direct UserOp to detect its L2→L1 outbound and the - /// L1→L2 return produced by the L1 callback. If a return is found and the - /// UserOp targets `FlashLoanExecutorL2.execute(uint256,address,IBridge.Message)`, - /// the placeholder returnMessage in its calldata is substituted with the - /// simulated Message before the real L2 block execution. - async fn prepare_l2_direct_user_op( - &self, - user_op: UserOp, - ) -> Result<(UserOp, Option<(BridgeMessage, FixedBytes<32>)>), Error> { + /// Scan mempool transactions for any that emit `Bridge.sendMessage` (L2→L1 + /// outbound). For each such tx, simulate the L1 callback to discover an + /// L1→L2 return signal. If found, inject the return signal into the anchor's + /// fast signals so the tx's `bridge.processMessage(returnMsg)` call succeeds + /// on L2, and record the slot for the deferred-finalize multicall. + async fn scan_mempool_for_outbound_signals( + &mut self, + pending_tx_list: &mut common::shared::l2_tx_lists::PreBuiltTxList, + ) { use alloy::primitives::Bytes; let l2_el = self.taiko.l2_execution_layer(); - let Some(outbound) = l2_el - .trace_user_op_for_outbound_message(&user_op) - .await? - else { - return Ok((user_op, None)); - }; - let l1_el = &self.ethereum_l1.execution_layer; - let bridge_addr = l1_el.contract_addresses().bridge; - let l2_bridge_addr = *l2_el.bridge.address(); - let Some((return_msg, return_slot)) = l1_el - .simulate_l1_callback_return_signal( - outbound, - Bytes::new(), - bridge_addr, - l2_bridge_addr, - ) - .await? - else { - return Ok((user_op, None)); - }; - // Attempt calldata patching. - let patched_user_op = match maybe_patch_flash_loan_execute_calldata(&user_op, &return_msg) { - Ok(patched) => patched, - Err(e) => { - warn!( - "FlashLoanExecutor calldata patch failed ({e}); using original calldata. \ - The L2 block may revert at processMessage if the placeholder return \ - Message's hash doesn't match the simulated one." - ); - user_op.clone() - } - }; + for tx in &pending_tx_list.tx_list { + let from = tx.inner.signer(); + let Some(to) = tx.inner.to() else { + continue; // skip contract creation txs + }; + let input = tx.inner.input(); + + // Trace the tx to check for outbound bridge.sendMessage + let outbound = match l2_el + .trace_tx_for_outbound_message(from, to, input) + .await + { + Ok(Some(msg)) => msg, + Ok(None) => continue, + Err(e) => { + debug!("Mempool tx trace failed: {e}"); + continue; + } + }; - Ok((patched_user_op, Some((return_msg, return_slot)))) + info!( + "Mempool tx from={} emits L2→L1 outbound to destChainId={}", + from, outbound.destChainId + ); + + // Simulate the L1 callback to find the return signal + let bridge_addr = l1_el.contract_addresses().bridge; + let l2_bridge_addr = *l2_el.bridge.address(); + match l1_el + .simulate_l1_callback_return_signal( + outbound, + Bytes::new(), + bridge_addr, + l2_bridge_addr, + ) + .await + { + Ok(Some((_return_msg, return_slot))) => { + info!( + "L1 callback simulation found return signal slot={} — injecting into anchor", + return_slot + ); + self.pending_return_signal = Some(return_slot); + // Only handle one L2→L1→L2 tx per block for now + break; + } + Ok(None) => { + debug!("L1 callback produced no return signal"); + } + Err(e) => { + warn!("L1 callback simulation failed: {e}"); + } + } + } } async fn add_draft_block_to_proposal( @@ -477,7 +407,8 @@ impl BatchManager { ) -> Result { let mut anchor_signal_slots: Vec> = vec![]; - debug!("Checking for pending UserOps (L1→L2 deposits and L2 direct)"); + // Process L1→L2 UserOps (via surge_sendUserOp RPC) + debug!("Checking for pending UserOps (L1→L2 deposits)"); if let Some((user_op_data, signal_slot)) = self .add_pending_user_ops_to_draft_block(&mut l2_draft_block) .await? @@ -486,18 +417,18 @@ impl BatchManager { self.batch_builder.add_signal_slot(signal_slot)?; anchor_signal_slots.push(signal_slot); } else { - debug!("No L1→L2 UserOps (L2 direct ops, if any, were handled inline)"); + debug!("No L1→L2 UserOps pending"); } - // If Pass 2 (L2Direct pre-sim) discovered an L1→L2 return signal, - // inject it into the anchor's fast signals so `bridge.processMessage` - // inside the UserOp's onFlashLoan / equivalent callback succeeds when - // the real L2 block executes. The same slot is also recorded in - // batch_builder.signal_slots so `ProposeInputV2` can split it out as - // `requiredReturnSignals` when the multicall is built. + // Scan mempool txs for L2→L1→L2 outbound signals (e.g. flash loans). + // If found, the L1 callback is simulated and the return signal is + // injected into the anchor so the tx succeeds on L2. + self.scan_mempool_for_outbound_signals(&mut l2_draft_block.prebuilt_tx_list) + .await; + if let Some(return_slot) = self.pending_return_signal.take() { info!( - "Injecting pre-simulated L1→L2 return signal into anchor fast signals: slot={}", + "Injecting L2→L1→L2 return signal into anchor fast signals: slot={}", return_slot ); self.batch_builder.add_signal_slot(return_slot)?; @@ -685,69 +616,3 @@ impl BatchManager { } } -/// If `user_op.calldata` matches the FlashLoanExecutorL2.execute ABI -/// `execute(uint256 amount, address beneficiary, IBridge.Message returnMessage)`, -/// decode it, substitute `returnMessage` with the simulated `return_msg`, and -/// return a new UserOp with the patched calldata. Otherwise return an error -/// (the caller falls back to the original calldata with a warning). -fn maybe_patch_flash_loan_execute_calldata( - user_op: &UserOp, - return_msg: &BridgeMessage, -) -> Result { - use alloy::primitives::Bytes; - use alloy::sol_types::SolCall; - - let calldata_bytes = user_op.calldata.as_ref(); - if calldata_bytes.len() < 4 { - return Err(anyhow::anyhow!("calldata too short for selector")); - } - - let selector: [u8; 4] = calldata_bytes[0..4] - .try_into() - .map_err(|_| anyhow::anyhow!("selector parse failed"))?; - - if selector != executeCall::SELECTOR { - return Err(anyhow::anyhow!( - "selector 0x{} does not match FlashLoanExecutor.execute", - hex_encode(&selector) - )); - } - - let decoded = executeCall::abi_decode_raw(&calldata_bytes[4..]).map_err(|e| { - anyhow::anyhow!("failed to decode execute(uint256,address,(...)) calldata: {e}") - })?; - - // Build the patched FlashLoanReturnMessage from the simulated BridgeMessage. - let patched_return = FlashLoanReturnMessage { - id: return_msg.id, - fee: return_msg.fee, - gasLimit: return_msg.gasLimit, - from: return_msg.from, - srcChainId: return_msg.srcChainId, - srcOwner: return_msg.srcOwner, - destChainId: return_msg.destChainId, - destOwner: return_msg.destOwner, - to: return_msg.to, - value: return_msg.value, - data: return_msg.data.clone(), - }; - - let patched_call = executeCall { - amount: decoded.amount, - beneficiary: decoded.beneficiary, - returnMessage: patched_return, - }; - - let patched_calldata = Bytes::from(patched_call.abi_encode()); - let mut patched_user_op = user_op.clone(); - patched_user_op.calldata = patched_calldata; - Ok(patched_user_op) -} - -fn hex_encode(bytes: &[u8]) -> String { - let mut s = String::with_capacity(bytes.len() * 2); - for b in bytes { - s.push_str(&format!("{:02x}", b)); - } - s -} From 3341031e0f1ee852efb8bc51a7891eaa96fc3826 Mon Sep 17 00:00:00 2001 From: AnshuJalan Date: Fri, 17 Apr 2026 12:53:42 +0530 Subject: [PATCH 06/13] =?UTF-8?q?feat:=20end-to-end=20L1=E2=86=92L2?= =?UTF-8?q?=E2=86=92L1=20swap=20=E2=80=94=20gas=20budget=20+=20value=20for?= =?UTF-8?q?warding=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Thread EXTRA_GAS_PERCENTAGE from common_config through realtime ExecutionLayer into ProposalTxBuilder (was hardcoded to 10, ignoring the env). - Raise BLOB_TX_GAS_LIMIT from 500k to 3M and drop the estimate_gas attempt (eth_estimateGas can't simulate blob txs — BLOBHASH returns 0 — so any multicall that included Bridge.processMessage was OOM'ing and getting rewrapped as B_SIGNAL_NOT_RECEIVED). - Forward message.value on the proposer-multicall l1_call entry and on the bridge-impersonated processMessage trace, and override the bridge balance so payable L1 callbacks receive ETH on fresh devnets. - Pass tx.value into trace_tx_for_outbound_message from both the mempool scan and the surge_simulateReturnMessage RPC so payable L2 entry points (e.g. swapETHForTokenViaL1) don't revert with ZERO_AMOUNT during tracing. - In AsyncSubmitter, mark every in-flight user op as Rejected if submission_task bails before reaching its own status-update path (e.g. manifest encoding / sidecar build failures), so ops don't sit at Pending forever. Co-Authored-By: Claude Opus 4.7 --- realtime/src/l1/execution_layer.rs | 27 +++++++++++---- realtime/src/l1/proposal_tx_builder.rs | 33 +++++++++++-------- realtime/src/l2/execution_layer.rs | 7 +++- .../node/proposal_manager/async_submitter.rs | 30 +++++++++++++++++ .../node/proposal_manager/bridge_handler.rs | 11 +++++-- realtime/src/node/proposal_manager/mod.rs | 7 ++-- 6 files changed, 89 insertions(+), 26 deletions(-) diff --git a/realtime/src/l1/execution_layer.rs b/realtime/src/l1/execution_layer.rs index f7da5ae8..71ec6c13 100644 --- a/realtime/src/l1/execution_layer.rs +++ b/realtime/src/l1/execution_layer.rs @@ -49,6 +49,7 @@ pub struct ExecutionLayer { raiko_client: RaikoClient, proof_type: crate::l1::bindings::ProofType, mock_mode: bool, + extra_gas_percentage: u64, } impl ELTrait for ExecutionLayer { @@ -105,6 +106,7 @@ impl ELTrait for ExecutionLayer { let proof_type = specific_config.proof_type; let mock_mode = specific_config.mock_mode; let raiko_client = specific_config.raiko_client; + let extra_gas_percentage = common_config.extra_gas_percentage; Ok(Self { common, @@ -116,6 +118,7 @@ impl ELTrait for ExecutionLayer { raiko_client, proof_type, mock_mode, + extra_gas_percentage, }) } @@ -206,8 +209,12 @@ impl ExecutionLayer { batch.zk_proof.is_some(), ); - let builder = - ProposalTxBuilder::new(self.provider.clone(), 10, self.proof_type, self.mock_mode); + let builder = ProposalTxBuilder::new( + self.provider.clone(), + self.extra_gas_percentage, + self.proof_type, + self.mock_mode, + ); let tx = builder .build_propose_tx( @@ -414,18 +421,24 @@ impl L1BridgeHandlerOps for ExecutionLayer { // message_from_l2.data is already the full ABI-encoded calldata for // onMessageInvocation(bytes) — exactly what Bridge.processMessage // would pass to the target. Use it directly. + // Forward message.value as msg.value so payable callbacks receive ETH. let callback_address = message_from_l2.to; let tx_request = TransactionRequest::default() .from(bridge_address) // msg.sender = bridge (passes ONLY_BRIDGE check) .to(callback_address) + .value(message_from_l2.value) .input(message_from_l2.data.clone().into()); // State-override the bridge's __ctx storage so context() returns - // the correct msgHash, from, and srcChainId. - let bridge_ctx_override = AccountOverride::default().with_state_diff([ - (B256::from(U256::from(253u64)), msg_hash), // __ctx.msgHash - (B256::from(U256::from(254u64)), slot_254_value), // __ctx.from + srcChainId - ]); + // the correct msgHash, from, and srcChainId. Also give the bridge + // enough ETH balance so the value transfer succeeds in the trace. + let bridge_balance = message_from_l2.value.saturating_add(U256::from(10u64).pow(U256::from(18u64))); + let bridge_ctx_override = AccountOverride::default() + .with_balance(bridge_balance) + .with_state_diff([ + (B256::from(U256::from(253u64)), msg_hash), // __ctx.msgHash + (B256::from(U256::from(254u64)), slot_254_value), // __ctx.from + srcChainId + ]); let mut state_overrides = StateOverride::default(); state_overrides.insert(bridge_address, bridge_ctx_override); diff --git a/realtime/src/l1/proposal_tx_builder.rs b/realtime/src/l1/proposal_tx_builder.rs index f9a26869..5ae94b1b 100644 --- a/realtime/src/l1/proposal_tx_builder.rs +++ b/realtime/src/l1/proposal_tx_builder.rs @@ -52,6 +52,15 @@ impl ProposalTxBuilder { } } + /// Gas estimation is skipped for blob transactions because `eth_estimateGas` + /// cannot simulate blobs — the `BLOBHASH` opcode returns zero during estimation, + /// causing spurious reverts that mask the real outcome. Instead we use a fixed + /// gas limit and rely on the `TransactionMonitor`'s receipt check: if the on-chain + /// execution reverts, the monitor sends `TransactionError::TransactionReverted` + /// through the error channel, and the node's main loop triggers + /// `recover_from_failed_submission` (reorg back to last finalized head). + const BLOB_TX_GAS_LIMIT: u64 = 3_000_000; + #[allow(clippy::too_many_arguments)] pub async fn build_propose_tx( &self, @@ -62,17 +71,9 @@ impl ProposalTxBuilder { let tx_blob = self .build_propose_blob(batch, from, contract_addresses) .await?; - let tx_blob_gas = match self.provider.estimate_gas(tx_blob.clone()).await { - Ok(gas) => gas, - Err(e) => { - warn!( - "Build proposeBatch: Failed to estimate gas for blob transaction: {}. Force-sending with 500000 gas.", - e - ); - 500_000 - } - }; - let tx_blob_gas = tx_blob_gas + tx_blob_gas * self.extra_gas_percentage / 100; + + let tx_blob_gas = Self::BLOB_TX_GAS_LIMIT + + Self::BLOB_TX_GAS_LIMIT * self.extra_gas_percentage / 100; let fees_per_gas = match FeesPerGas::get_fees_per_gas(&self.provider).await { Ok(fees_per_gas) => fees_per_gas, @@ -345,11 +346,17 @@ impl ProposalTxBuilder { fn build_l1_call_call(&self, l1_call: L1Call, bridge_address: Address) -> Multicall::Call { let bridge = Bridge::new(bridge_address, &self.provider); - let call = bridge.processMessage(l1_call.message_from_l2, l1_call.signal_slot_proof); + let call = bridge.processMessage(l1_call.message_from_l2.clone(), l1_call.signal_slot_proof); + + // Forward the message's ETH value so the bridge can deliver it to the + // callback. Without this, processMessage reverts when the L1 bridge + // balance is insufficient (common on fresh devnets where no L1→L2 + // deposits have funded the bridge). + let value = l1_call.message_from_l2.value; Multicall::Call { target: bridge_address, - value: U256::ZERO, + value, data: call.calldata().clone(), } } diff --git a/realtime/src/l2/execution_layer.rs b/realtime/src/l2/execution_layer.rs index 3e7b2221..3202f34b 100644 --- a/realtime/src/l2/execution_layer.rs +++ b/realtime/src/l2/execution_layer.rs @@ -442,12 +442,17 @@ impl L2ExecutionLayer { from: Address, to: Address, input: &[u8], + value: Option, ) -> Result, anyhow::Error> { - let tx_request = TransactionRequest::default() + let mut tx_request = TransactionRequest::default() .from(from) .to(to) .input(input.to_vec().into()); + if let Some(v) = value { + tx_request = tx_request.value(v); + } + let tracer_config = serde_json::json!({ "onlyTopCall": false }); diff --git a/realtime/src/node/proposal_manager/async_submitter.rs b/realtime/src/node/proposal_manager/async_submitter.rs index ba732622..914bec78 100644 --- a/realtime/src/node/proposal_manager/async_submitter.rs +++ b/realtime/src/node/proposal_manager/async_submitter.rs @@ -85,6 +85,17 @@ impl AsyncSubmitter { let ethereum_l1 = self.ethereum_l1.clone(); let proof_request_bypass = self.proof_request_bypass; + // Collect user-op IDs before moving `proposal` so the catch-all below can + // mark them as Rejected if `submission_task` returns an error before the + // status is updated (e.g. blob encoding / sidecar building failures). + let all_user_op_ids: Vec = proposal + .user_ops + .iter() + .map(|op| op.id) + .chain(proposal.l2_user_op_ids.iter().copied()) + .collect(); + let fallback_store = status_store.clone(); + let handle = tokio::spawn(async move { let result = submission_task( proposal, @@ -95,6 +106,25 @@ impl AsyncSubmitter { proof_request_bypass, ) .await; + + // Catch-all: if submission_task errored, ensure every user op is marked + // Rejected. The task itself handles Raiko and L1-send errors, but + // pre-proof failures (manifest encoding, sidecar building) bail via `?` + // before any status update — leaving ops stuck at Pending forever. + if let Err(ref e) = result + && let Some(ref store) = fallback_store + { + let reason = format!("Submission failed: {}", e); + for id in &all_user_op_ids { + store.set( + *id, + &UserOpStatus::Rejected { + reason: reason.clone(), + }, + ); + } + } + let _ = result_tx.send(result); }); diff --git a/realtime/src/node/proposal_manager/bridge_handler.rs b/realtime/src/node/proposal_manager/bridge_handler.rs index 10d937bb..5a140c66 100644 --- a/realtime/src/node/proposal_manager/bridge_handler.rs +++ b/realtime/src/node/proposal_manager/bridge_handler.rs @@ -275,21 +275,26 @@ impl BridgeHandler { from: Address, to: Address, data: Bytes, + /// ETH value to attach to the traced tx (required for payable + /// L2 entry points like swapETHForTokenViaL1). + #[serde(default)] + value: Option, } let req: SimRequest = params.one()?; info!( - "surge_simulateReturnMessage: from={}, to={}, data_len={}", + "surge_simulateReturnMessage: from={}, to={}, data_len={}, value={:?}", req.from, req.to, - req.data.len() + req.data.len(), + req.value, ); let l2_el = ctx.taiko.l2_execution_layer(); // Step 1: trace the L2 tx for outbound Bridge.sendMessage let outbound = l2_el - .trace_tx_for_outbound_message(req.from, req.to, &req.data) + .trace_tx_for_outbound_message(req.from, req.to, &req.data, req.value) .await .map_err(|e| { jsonrpsee::types::ErrorObjectOwned::owned( diff --git a/realtime/src/node/proposal_manager/mod.rs b/realtime/src/node/proposal_manager/mod.rs index cee3f323..a318197f 100644 --- a/realtime/src/node/proposal_manager/mod.rs +++ b/realtime/src/node/proposal_manager/mod.rs @@ -350,9 +350,12 @@ impl BatchManager { }; let input = tx.inner.input(); - // Trace the tx to check for outbound bridge.sendMessage + // Trace the tx to check for outbound bridge.sendMessage. + // Forward the tx value so payable entry points (swapETHForTokenViaL1) + // don't revert with ZERO_AMOUNT during the trace. + let tx_value = tx.inner.value(); let outbound = match l2_el - .trace_tx_for_outbound_message(from, to, input) + .trace_tx_for_outbound_message(from, to, input, Some(tx_value)) .await { Ok(Some(msg)) => msg, From 0ed636cbfdf217ddbddc72eb8a5523af2bc861e0 Mon Sep 17 00:00:00 2001 From: AnshuJalan Date: Fri, 17 Apr 2026 14:56:49 +0530 Subject: [PATCH 07/13] fix(realtime): stop prefunding Multicall for bridge.processMessage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bridge pays the callback value from its own reserves via raw assembly call; prefunding the Multicall contract only made `call{value: X}` revert with INSUFFICIENT_BALANCE since Multicall holds 0 ETH. Set value=0 on the sub-call — if the Bridge is underfunded the tx now reverts naturally at Bridge rather than masquerading as a prefund failure. Co-Authored-By: Claude Opus 4.7 --- realtime/src/l1/proposal_tx_builder.rs | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/realtime/src/l1/proposal_tx_builder.rs b/realtime/src/l1/proposal_tx_builder.rs index 5ae94b1b..5d5d64d7 100644 --- a/realtime/src/l1/proposal_tx_builder.rs +++ b/realtime/src/l1/proposal_tx_builder.rs @@ -348,15 +348,9 @@ impl ProposalTxBuilder { let bridge = Bridge::new(bridge_address, &self.provider); let call = bridge.processMessage(l1_call.message_from_l2.clone(), l1_call.signal_slot_proof); - // Forward the message's ETH value so the bridge can deliver it to the - // callback. Without this, processMessage reverts when the L1 bridge - // balance is insufficient (common on fresh devnets where no L1→L2 - // deposits have funded the bridge). - let value = l1_call.message_from_l2.value; - Multicall::Call { target: bridge_address, - value, + value: U256::ZERO, data: call.calldata().clone(), } } From d29d27b55fd72129e6d22ab8051d8294bfcfd1d4 Mon Sep 17 00:00:00 2001 From: AnshuJalan Date: Fri, 17 Apr 2026 14:56:57 +0530 Subject: [PATCH 08/13] feat(realtime): track mempool-picked L2 tx status by hash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends UserOpStatusStore with a B256-keyed API (separate sled tree) so mempool-scanned L2→L1→L2 txs get the same sequencing → proving → proposing → complete lifecycle as L1→L2→L1 UserOps. async_submitter mirrors each transition site onto the hash-keyed entries, and surge_txStatus checks the store before falling back to on-chain lookup. Lets the UI poll by L2 tx hash and drive the unified overlay. Co-Authored-By: Claude Opus 4.7 --- .../node/proposal_manager/async_submitter.rs | 69 +++++++++++++++++-- .../node/proposal_manager/batch_builder.rs | 10 +++ .../node/proposal_manager/bridge_handler.rs | 46 ++++++++++++- realtime/src/node/proposal_manager/mod.rs | 25 ++++++- .../src/node/proposal_manager/proposal.rs | 5 ++ 5 files changed, 147 insertions(+), 8 deletions(-) diff --git a/realtime/src/node/proposal_manager/async_submitter.rs b/realtime/src/node/proposal_manager/async_submitter.rs index 914bec78..84cdcf75 100644 --- a/realtime/src/node/proposal_manager/async_submitter.rs +++ b/realtime/src/node/proposal_manager/async_submitter.rs @@ -283,6 +283,15 @@ async fn submission_task( }, ); } + // L2→L1→L2 mempool-picked txs tracked by L2 tx hash + for tx_hash in &proposal.l2_mempool_tx_hashes { + store.set_by_hash( + *tx_hash, + &UserOpStatus::ProvingBlock { + block_id: proposal.checkpoint.blockNumber.to::(), + }, + ); + } } let proof = match raiko_client.get_proof(&request).await { @@ -306,6 +315,14 @@ async fn submission_task( }, ); } + for tx_hash in &proposal.l2_mempool_tx_hashes { + store.set_by_hash( + *tx_hash, + &UserOpStatus::Rejected { + reason: reason.clone(), + }, + ); + } } return Err(e); } @@ -316,15 +333,17 @@ async fn submission_task( // Step 2: Send L1 transaction let mut user_op_ids: Vec = proposal.user_ops.iter().map(|op| op.id).collect(); user_op_ids.extend(&proposal.l2_user_op_ids); - let has_user_ops = !user_op_ids.is_empty() && status_store.is_some(); + let l2_mempool_tx_hashes: Vec = proposal.l2_mempool_tx_hashes.clone(); + let has_tracked_entries = (!user_op_ids.is_empty() || !l2_mempool_tx_hashes.is_empty()) + && status_store.is_some(); - let (tx_hash_sender, tx_hash_receiver) = if has_user_ops { + let (tx_hash_sender, tx_hash_receiver) = if has_tracked_entries { let (s, r) = tokio::sync::oneshot::channel(); (Some(s), Some(r)) } else { (None, None) }; - let (tx_result_sender, tx_result_receiver) = if has_user_ops { + let (tx_result_sender, tx_result_receiver) = if has_tracked_entries { let (s, r) = tokio::sync::oneshot::channel(); (Some(s), Some(r)) } else { @@ -336,7 +355,7 @@ async fn submission_task( .send_batch_to_l1(proposal.clone(), tx_hash_sender, tx_result_sender) .await { - // Mark all user ops (L1 and L2) as rejected on failure + // Mark all tracked entries (L1/L2 UserOps and mempool-picked L2 txs) as rejected if let Some(ref store) = status_store { let reason = format!("L1 multicall failed: {}", err); for op in &proposal.user_ops { @@ -355,6 +374,14 @@ async fn submission_task( }, ); } + for tx_hash in &proposal.l2_mempool_tx_hashes { + store.set_by_hash( + *tx_hash, + &UserOpStatus::Rejected { + reason: reason.clone(), + }, + ); + } } return Err(err); } @@ -373,6 +400,9 @@ async fn submission_task( for id in &user_op_ids { store.set(*id, &UserOpStatus::Processing { tx_hash }); } + for l2_tx_hash in &l2_mempool_tx_hashes { + store.set_by_hash(*l2_tx_hash, &UserOpStatus::Processing { tx_hash }); + } Some(tx_hash) } Err(_) => { @@ -384,6 +414,14 @@ async fn submission_task( }, ); } + for l2_tx_hash in &l2_mempool_tx_hashes { + store.set_by_hash( + *l2_tx_hash, + &UserOpStatus::Rejected { + reason: "Transaction failed to send".to_string(), + }, + ); + } None } }; @@ -394,6 +432,9 @@ async fn submission_task( for id in &user_op_ids { store.set(*id, &UserOpStatus::Executed); } + for l2_tx_hash in &l2_mempool_tx_hashes { + store.set_by_hash(*l2_tx_hash, &UserOpStatus::Executed); + } } Ok(false) => { for id in &user_op_ids { @@ -404,6 +445,14 @@ async fn submission_task( }, ); } + for l2_tx_hash in &l2_mempool_tx_hashes { + store.set_by_hash( + *l2_tx_hash, + &UserOpStatus::Rejected { + reason: "L1 multicall reverted".to_string(), + }, + ); + } } Err(_) => { for id in &user_op_ids { @@ -414,6 +463,14 @@ async fn submission_task( }, ); } + for l2_tx_hash in &l2_mempool_tx_hashes { + store.set_by_hash( + *l2_tx_hash, + &UserOpStatus::Rejected { + reason: "Transaction monitor dropped".to_string(), + }, + ); + } } } } @@ -421,11 +478,15 @@ async fn submission_task( // Clean up status entries after 60s (client should have polled by then) let cleanup_store = store.clone(); let cleanup_ids = user_op_ids.clone(); + let cleanup_hashes = l2_mempool_tx_hashes.clone(); tokio::spawn(async move { tokio::time::sleep(tokio::time::Duration::from_secs(60)).await; for id in &cleanup_ids { cleanup_store.remove(*id); } + for tx_hash in &cleanup_hashes { + cleanup_store.remove_by_hash(*tx_hash); + } }); }); } diff --git a/realtime/src/node/proposal_manager/batch_builder.rs b/realtime/src/node/proposal_manager/batch_builder.rs index 90d7cf74..76e99b66 100644 --- a/realtime/src/node/proposal_manager/batch_builder.rs +++ b/realtime/src/node/proposal_manager/batch_builder.rs @@ -95,6 +95,7 @@ impl BatchBuilder { last_finalized_block_hash, user_ops: vec![], l2_user_op_ids: vec![], + l2_mempool_tx_hashes: vec![], signal_slots: vec![], l1_calls: vec![], zk_proof: None, @@ -151,6 +152,15 @@ impl BatchBuilder { } } + pub fn add_l2_mempool_tx_hash(&mut self, tx_hash: B256) -> Result<(), Error> { + if let Some(current_proposal) = self.current_proposal.as_mut() { + current_proposal.l2_mempool_tx_hashes.push(tx_hash); + Ok(()) + } else { + Err(anyhow::anyhow!("No current batch for L2 mempool tx hash")) + } + } + pub fn add_signal_slot(&mut self, signal_slot: FixedBytes<32>) -> Result<&Proposal, Error> { if let Some(current_proposal) = self.current_proposal.as_mut() { current_proposal.signal_slots.push(signal_slot); diff --git a/realtime/src/node/proposal_manager/bridge_handler.rs b/realtime/src/node/proposal_manager/bridge_handler.rs index 5a140c66..a34cc2dd 100644 --- a/realtime/src/node/proposal_manager/bridge_handler.rs +++ b/realtime/src/node/proposal_manager/bridge_handler.rs @@ -25,16 +25,24 @@ pub enum UserOpStatus { } /// Disk-backed user op status store using sled. +/// +/// Two keyspaces live in this store: +/// - default tree: keyed by `u64` UserOp id (L1→L2→L1 path). +/// - `by_hash` tree: keyed by L2 tx hash `B256` (L2→L1→L2 mempool-picked txs). #[derive(Clone)] pub struct UserOpStatusStore { db: sled::Db, + by_hash: sled::Tree, } impl UserOpStatusStore { pub fn open(path: &str) -> Result { let db = sled::open(path) .map_err(|e| anyhow::anyhow!("Failed to open user op status store: {}", e))?; - Ok(Self { db }) + let by_hash = db + .open_tree("by_hash") + .map_err(|e| anyhow::anyhow!("Failed to open by_hash tree: {}", e))?; + Ok(Self { db, by_hash }) } pub fn set(&self, id: u64, status: &UserOpStatus) { @@ -56,6 +64,26 @@ impl UserOpStatusStore { pub fn remove(&self, id: u64) { let _ = self.db.remove(id.to_be_bytes()); } + + pub fn set_by_hash(&self, hash: B256, status: &UserOpStatus) { + if let Ok(value) = serde_json::to_vec(status) + && let Err(e) = self.by_hash.insert(hash.as_slice(), value) + { + error!("Failed to write tx status by hash: {}", e); + } + } + + pub fn get_by_hash(&self, hash: B256) -> Option { + self.by_hash + .get(hash.as_slice()) + .ok() + .flatten() + .and_then(|v| serde_json::from_slice(&v).ok()) + } + + pub fn remove_by_hash(&self, hash: B256) { + let _ = self.by_hash.remove(hash.as_slice()); + } } #[derive(Debug, Clone, Deserialize)] @@ -216,7 +244,21 @@ impl BridgeHandler { } } (None, Some(hash)) => { - // Look up L2 transaction by hash + // Prefer the explicit status store for mempool-picked L2→L1→L2 txs — + // it carries the full `sequencing → proving → proposing → complete` + // lifecycle that async_submitter writes. + if let Some(status) = ctx.status_store.get_by_hash(hash) { + return serde_json::to_value(status).map_err(|e| { + jsonrpsee::types::ErrorObjectOwned::owned( + -32603, + "Serialization error", + Some(format!("{}", e)), + ) + }); + } + + // Fallback: derive from on-chain state (used for L1→L2→L1 UserOp + // polling by hash, where no store entry exists). let tx = ctx.taiko.get_transaction_by_hash(hash).await.map_err(|e| { debug!("Transaction {} not found on L2: {}", hash, e); jsonrpsee::types::ErrorObjectOwned::owned( diff --git a/realtime/src/node/proposal_manager/mod.rs b/realtime/src/node/proposal_manager/mod.rs index a318197f..119bfef9 100644 --- a/realtime/src/node/proposal_manager/mod.rs +++ b/realtime/src/node/proposal_manager/mod.rs @@ -55,6 +55,11 @@ pub struct BatchManager { /// so that `bridge.processMessage(returnMsg, "")` in the UserOp succeeds. /// Cleared after each block build. pending_return_signal: Option>, + /// L2 mempool tx hash paired with `pending_return_signal` — the tx that + /// triggered the L2→L1→L2 path. Recorded so the UI can poll `surge_txStatus` + /// by hash and see the full proposal lifecycle (sequencing → proving → + /// proposing → complete). Cleared after each block build. + pending_mempool_tx_hash: Option, } impl BatchManager { @@ -132,6 +137,7 @@ impl BatchManager { last_finalized_block_hash, last_finalized_block_number, pending_return_signal: None, + pending_mempool_tx_hash: None, }) } @@ -384,11 +390,13 @@ impl BatchManager { .await { Ok(Some((_return_msg, return_slot))) => { + let tx_hash = *tx.inner.tx_hash(); info!( - "L1 callback simulation found return signal slot={} — injecting into anchor", - return_slot + "L1 callback simulation found return signal slot={} for L2 tx {} — injecting into anchor", + return_slot, tx_hash, ); self.pending_return_signal = Some(return_slot); + self.pending_mempool_tx_hash = Some(tx_hash); // Only handle one L2→L1→L2 tx per block for now break; } @@ -438,6 +446,19 @@ impl BatchManager { anchor_signal_slots.push(return_slot); } + if let Some(tx_hash) = self.pending_mempool_tx_hash.take() { + self.batch_builder.add_l2_mempool_tx_hash(tx_hash)?; + let status_store = self.bridge_handler.lock().await.status_store(); + status_store.set_by_hash( + tx_hash, + &crate::node::proposal_manager::bridge_handler::UserOpStatus::Pending, + ); + info!( + "Tracking L2→L1→L2 mempool tx {} under status store (Pending)", + tx_hash + ); + } + let payload = self.batch_builder.add_l2_draft_block(l2_draft_block)?; match self diff --git a/realtime/src/node/proposal_manager/proposal.rs b/realtime/src/node/proposal_manager/proposal.rs index 39e5b059..438e43b4 100644 --- a/realtime/src/node/proposal_manager/proposal.rs +++ b/realtime/src/node/proposal_manager/proposal.rs @@ -31,6 +31,11 @@ pub struct Proposal { // Surge POC fields (carried over) pub user_ops: Vec, pub l2_user_op_ids: Vec, + /// L2 tx hashes for mempool-picked outbound txs (L2→L1→L2 path). Status + /// transitions for these are written to `UserOpStatusStore::set_by_hash` + /// so the UI can poll `surge_txStatus` by tx hash and see the same + /// sequencing → proving → proposing → complete lifecycle as UserOps. + pub l2_mempool_tx_hashes: Vec, pub signal_slots: Vec>, pub l1_calls: Vec, From 9ab2daf54a5b80aee45d158f228969054b04d225 Mon Sep 17 00:00:00 2001 From: Justin Chan Date: Wed, 22 Apr 2026 12:43:03 +1000 Subject: [PATCH 09/13] refac: fix catalyst for x86-64 --- .github/workflows/node_docker_build.yml | 6 ++++++ Dockerfile | 7 +++++++ 2 files changed, 13 insertions(+) diff --git a/.github/workflows/node_docker_build.yml b/.github/workflows/node_docker_build.yml index 4211b7b0..79c0f882 100644 --- a/.github/workflows/node_docker_build.yml +++ b/.github/workflows/node_docker_build.yml @@ -66,7 +66,13 @@ jobs: file: Dockerfile platforms: ${{ matrix.platform }} push: true +<<<<<<< Updated upstream outputs: type=image,name=${{ env.DOCKER_REGISTRY }}/${{ env.DOCKER_REPOSITORY_PROD }},push-by-digest=true,name-canonical=true +======= + build-args: | + BLST_PORTABLE=1 + outputs: type=image,name=${{ env.DOCKER_REGISTRY }}/${{ env.DOCKER_REPOSITORY_STAGING }},push-by-digest=true,name-canonical=true +>>>>>>> Stashed changes - name: Set digest output id: digest diff --git a/Dockerfile b/Dockerfile index 1ac5a4f5..71a1430b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,6 +10,13 @@ RUN apt-get update && apt-get install -y \ # Set the working directory inside the container WORKDIR /app/catalyst_node +# Force blst (and the blst vendored by c-kzg) to compile without ADX/BMI2 asm. +# Without this, binaries built on modern CI runners SIGILL on older Intel CPUs +# (e.g. pre-Broadwell Macs, and Intel-Mac Docker Desktop VMs that don't expose +# those features to the guest). +ARG BLST_PORTABLE=1 +ENV BLST_PORTABLE=${BLST_PORTABLE} + # Copy only the toolchain file first COPY rust-toolchain.toml . From ed55f410b6edc2fa4389aa54a319d001fe1605e2 Mon Sep 17 00:00:00 2001 From: Justin Chan Date: Wed, 22 Apr 2026 12:45:08 +1000 Subject: [PATCH 10/13] refac: fix catalyst for x86-64 --- .github/workflows/node_docker_build.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/node_docker_build.yml b/.github/workflows/node_docker_build.yml index 79c0f882..492790b3 100644 --- a/.github/workflows/node_docker_build.yml +++ b/.github/workflows/node_docker_build.yml @@ -66,13 +66,9 @@ jobs: file: Dockerfile platforms: ${{ matrix.platform }} push: true -<<<<<<< Updated upstream - outputs: type=image,name=${{ env.DOCKER_REGISTRY }}/${{ env.DOCKER_REPOSITORY_PROD }},push-by-digest=true,name-canonical=true -======= build-args: | BLST_PORTABLE=1 outputs: type=image,name=${{ env.DOCKER_REGISTRY }}/${{ env.DOCKER_REPOSITORY_STAGING }},push-by-digest=true,name-canonical=true ->>>>>>> Stashed changes - name: Set digest output id: digest From b259a888505300b630fd16281940bb7cb20fbc2d Mon Sep 17 00:00:00 2001 From: Justin Chan Date: Wed, 22 Apr 2026 14:19:30 +1000 Subject: [PATCH 11/13] refac: fix catalyst for x86-64 --- .github/workflows/node_docker_build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/node_docker_build.yml b/.github/workflows/node_docker_build.yml index 492790b3..56cbdbe5 100644 --- a/.github/workflows/node_docker_build.yml +++ b/.github/workflows/node_docker_build.yml @@ -68,7 +68,7 @@ jobs: push: true build-args: | BLST_PORTABLE=1 - outputs: type=image,name=${{ env.DOCKER_REGISTRY }}/${{ env.DOCKER_REPOSITORY_STAGING }},push-by-digest=true,name-canonical=true + outputs: type=image,name=${{ env.DOCKER_REGISTRY }}/${{ env.DOCKER_REPOSITORY_PROD }},push-by-digest=true,name-canonical=true - name: Set digest output id: digest From 3d82966a908a1d1f760efea1f49b6303cdd12b20 Mon Sep 17 00:00:00 2001 From: AnshuJalan Date: Thu, 23 Apr 2026 14:23:26 +0530 Subject: [PATCH 12/13] fix: emit EIP-7594 blob-tx wrappers for proposals Osaka/PeerDAS-enabled L1 nodes reject legacy v0 blob wrappers with "InvalidTxProofVersion: Version of network wrapper is not supported". Switch the shasta and realtime sidecar builders (and the realtime Raiko submitter) to `build_7594()` so blob txs carry the v1 wrapper with cell proofs, matching what pacaya already does. Co-Authored-By: Claude Opus 4.7 --- realtime/src/l1/proposal_tx_builder.rs | 8 ++++---- realtime/src/node/proposal_manager/async_submitter.rs | 3 ++- shasta/src/l1/proposal_tx_builder.rs | 8 ++++---- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/realtime/src/l1/proposal_tx_builder.rs b/realtime/src/l1/proposal_tx_builder.rs index 5d5d64d7..d4063818 100644 --- a/realtime/src/l1/proposal_tx_builder.rs +++ b/realtime/src/l1/proposal_tx_builder.rs @@ -12,8 +12,8 @@ use crate::node::proposal_manager::{ use crate::shared_abi::bindings::Bridge; use alloy::{ consensus::SidecarBuilder, - eips::eip4844::BlobTransactionSidecar, - network::TransactionBuilder4844, + eips::eip7594::BlobTransactionSidecarEip7594, + network::TransactionBuilder7594, primitives::{ Address, Bytes, U256, aliases::{U24, U48}, @@ -222,7 +222,7 @@ impl ProposalTxBuilder { inbox_address: Address, use_deferred: bool, required_return_signals: &[alloy::primitives::FixedBytes<32>], - ) -> Result<(Vec, BlobTransactionSidecar), anyhow::Error> { + ) -> Result<(Vec, BlobTransactionSidecarEip7594), anyhow::Error> { let mut block_manifests = >::with_capacity(batch.l2_blocks.len()); for l2_block in &batch.l2_blocks { block_manifests.push(BlockManifest { @@ -248,7 +248,7 @@ impl ProposalTxBuilder { .map_err(|e| Error::msg(format!("Can't encode and compress manifest: {e}")))?; let sidecar_builder: SidecarBuilder = SidecarBuilder::from_slice(&manifest_data); - let sidecar: BlobTransactionSidecar = sidecar_builder.build()?; + let sidecar: BlobTransactionSidecarEip7594 = sidecar_builder.build_7594()?; let inbox = RealTimeInbox::new(inbox_address, self.provider.clone()); diff --git a/realtime/src/node/proposal_manager/async_submitter.rs b/realtime/src/node/proposal_manager/async_submitter.rs index 84cdcf75..2270f778 100644 --- a/realtime/src/node/proposal_manager/async_submitter.rs +++ b/realtime/src/node/proposal_manager/async_submitter.rs @@ -174,7 +174,8 @@ async fn submission_task( }; let manifest_data = manifest.encode_and_compress()?; let sidecar_builder: SidecarBuilder = SidecarBuilder::from_slice(&manifest_data); - let sidecar: alloy::eips::eip4844::BlobTransactionSidecar = sidecar_builder.build()?; + let sidecar: alloy::eips::eip7594::BlobTransactionSidecarEip7594 = + sidecar_builder.build_7594()?; // Extract versioned blob hashes let blob_hashes: Vec = sidecar diff --git a/shasta/src/l1/proposal_tx_builder.rs b/shasta/src/l1/proposal_tx_builder.rs index 50e0174c..95c88ef6 100644 --- a/shasta/src/l1/proposal_tx_builder.rs +++ b/shasta/src/l1/proposal_tx_builder.rs @@ -10,8 +10,8 @@ use crate::node::proposal_manager::{ use crate::shared_abi::bindings::Bridge; use alloy::{ consensus::SidecarBuilder, - eips::eip4844::BlobTransactionSidecar, - network::TransactionBuilder4844, + eips::eip7594::BlobTransactionSidecarEip7594, + network::TransactionBuilder7594, primitives::{ Address, Bytes, U256, aliases::{U24, U48}, @@ -185,7 +185,7 @@ impl ProposalTxBuilder { &self, batch: &Proposal, inbox_address: Address, - ) -> Result<(Multicall::Call, BlobTransactionSidecar), anyhow::Error> { + ) -> Result<(Multicall::Call, BlobTransactionSidecarEip7594), anyhow::Error> { let mut block_manifests = >::with_capacity(batch.l2_blocks.len()); for l2_block in &batch.l2_blocks { block_manifests.push(BlockManifest { @@ -211,7 +211,7 @@ impl ProposalTxBuilder { .map_err(|e| Error::msg(format!("Can't encode and compress manifest: {e}")))?; let sidecar_builder: SidecarBuilder = SidecarBuilder::from_slice(&manifest_data); - let sidecar: BlobTransactionSidecar = sidecar_builder.build()?; + let sidecar: BlobTransactionSidecarEip7594 = sidecar_builder.build_7594()?; // Build the propose input. let input = ProposeInput { From bbf10472e57c6b7d3fb3c0d0f41eccdc62bcb8ed Mon Sep 17 00:00:00 2001 From: AnshuJalan Date: Thu, 23 Apr 2026 19:59:41 +0530 Subject: [PATCH 13/13] fix(realtime): make mempool scan the sole source of required return signal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The L2→L1→L2 return signal was being simulated twice — once by the mempool scan (which injects the slot into the L2 anchor's fast signals) and again by `find_l1_call` after preconf. When the two simulations disagreed (e.g. L1 state drifted between the calls, or the UI's simulate RPC produced a different slot than the actual mempool tx), the L1 call ended up with no `required_return_signal`, Catalyst fell back to classic propose, and the inbox reverted with `SignalSlotNotSent` because the slot in the anchor was never produced on L1. Plumb the pre-simulated slot from the mempool scan into `find_l1_call` as the authoritative value and remove the redundant second simulation. The anchor-injected slot and the inbox's `requiredReturnSignal` now always match by construction. Also add a short retry around `find_message_and_signal_slot` so a brief log-indexing lag on the L2 RPC right after preconf doesn't cause the L1 call to be dropped entirely. Co-Authored-By: Claude Opus 4.7 --- .../node/proposal_manager/bridge_handler.rs | 74 ++++++++----------- realtime/src/node/proposal_manager/mod.rs | 10 ++- 2 files changed, 40 insertions(+), 44 deletions(-) diff --git a/realtime/src/node/proposal_manager/bridge_handler.rs b/realtime/src/node/proposal_manager/bridge_handler.rs index a34cc2dd..93cc343f 100644 --- a/realtime/src/node/proposal_manager/bridge_handler.rs +++ b/realtime/src/node/proposal_manager/bridge_handler.rs @@ -459,60 +459,48 @@ impl BridgeHandler { Ok(None) } + /// Build an L1Call for a Bridge.sendMessage emitted in the just-preconfirmed + /// L2 block. The mempool scan is the single source of truth for the return + /// signal: if it found one, its slot was injected into the L2 anchor's fast + /// signals and must be carried here as the inbox's `requiredReturnSignal`. + /// We do not re-simulate — any drift between the two simulations would make + /// the anchor slot disagree with the inbox's verified slot, which reverts + /// `_verifySignalSlots` (classic) or `finalizePropose` (deferred). pub async fn find_l1_call( &mut self, block_id: u64, state_root: B256, + required_return_signal: Option>, ) -> Result, anyhow::Error> { let l2_el = self.taiko.l2_execution_layer(); - if let Some((message_from_l2, signal_slot)) = - l2_el.find_message_and_signal_slot(block_id).await? - { + // Retry briefly: the L2 RPC may lag indexing the just-preconfirmed + // block's logs. Without this, `find_message_and_signal_slot` returns + // None on the hot path and we skip the L1 call — causing classic + // propose to revert with `SignalSlotNotSent` if the mempool scan + // already injected a slot into the anchor. + let mut attempt = 0u32; + let message_and_slot = loop { + if let Some(pair) = l2_el.find_message_and_signal_slot(block_id).await? { + break Some(pair); + } + attempt += 1; + if attempt >= 5 { + break None; + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + }; + + if let Some((message_from_l2, signal_slot)) = message_and_slot { let signal_slot_proof = l2_el .get_hop_proof(signal_slot, block_id, state_root) .await?; - // Simulate the L1 callback (Bridge.processMessage) to detect any - // L1→L2 return signal the callback will produce. If found, it - // must be pre-injected into the L2 block's anchor fast signals - // and committed as a requiredReturnSignal in the inbox proposal. - // - // The simulator uses state_override on L1 SignalService so the - // signal-verification step passes even before the real checkpoint - // is committed. A None result means the callback does not produce - // an outbound — classic L1→L2→L1 flow, no deferred-finalize needed. - let l1_el = &self.ethereum_l1.execution_layer; - let contracts = l1_el.contract_addresses(); - // L2 bridge address is auto-derived from L2 chain id on the L2 - // side — pull it from there rather than duplicating in config. - let l2_bridge_address = *l2_el.bridge.address(); - let required_return_signal = match l1_el - .simulate_l1_callback_return_signal( - message_from_l2.clone(), - signal_slot_proof.clone(), - contracts.bridge, - l2_bridge_address, - ) - .await - { - Ok(Some((_return_msg, slot))) => { - info!( - "L1 callback simulation found return signal slot={} — will use deferred finalize", - slot - ); - Some(slot) - } - Ok(None) => None, - Err(e) => { - // Simulation failure is not fatal: fall back to classic flow. - warn!( - "L1 callback simulation failed ({}) — falling back to classic propose", - e - ); - None - } - }; + if required_return_signal.is_some() { + info!( + "Adding L1 call with pre-simulated required return signal — will use deferred finalize" + ); + } return Ok(Some(L1Call { message_from_l2, diff --git a/realtime/src/node/proposal_manager/mod.rs b/realtime/src/node/proposal_manager/mod.rs index 119bfef9..5d6315a1 100644 --- a/realtime/src/node/proposal_manager/mod.rs +++ b/realtime/src/node/proposal_manager/mod.rs @@ -437,6 +437,10 @@ impl BatchManager { self.scan_mempool_for_outbound_signals(&mut l2_draft_block.prebuilt_tx_list) .await; + // Copy rather than take — the pre-simulated slot is passed as a hint + // to `find_l1_call` after preconf so the L1Call's requiredReturnSignal + // matches the slot we inject into the anchor. Cleared below. + let pending_return_slot_hint = self.pending_return_signal; if let Some(return_slot) = self.pending_return_signal.take() { info!( "Injecting L2→L1→L2 return signal into anchor fast signals: slot={}", @@ -483,7 +487,11 @@ impl BatchManager { .bridge_handler .lock() .await - .find_l1_call(preconfed_block.number, preconfed_block.state_root) + .find_l1_call( + preconfed_block.number, + preconfed_block.state_root, + pending_return_slot_hint, + ) .await? { self.batch_builder.add_l1_call(l1_call)?;