diff --git a/.github/workflows/node_docker_build.yml b/.github/workflows/node_docker_build.yml index 4211b7b0..56cbdbe5 100644 --- a/.github/workflows/node_docker_build.yml +++ b/.github/workflows/node_docker_build.yml @@ -66,6 +66,8 @@ jobs: file: Dockerfile platforms: ${{ matrix.platform }} push: true + build-args: | + BLST_PORTABLE=1 outputs: type=image,name=${{ env.DOCKER_REGISTRY }}/${{ env.DOCKER_REPOSITORY_PROD }},push-by-digest=true,name-canonical=true - name: Set digest output 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 . 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/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..f915be75 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; @@ -41,21 +51,27 @@ 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 { - Risc0, // 0b00000001 - Sp1, // 0b00000010 - Zisk, // 0b00000100 + 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::Risc0 => 1 << 1, + ProofType::Sp1 => 1 << 2, + ProofType::Zisk => 1 << 3, } } @@ -69,6 +85,9 @@ 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; diff --git a/realtime/src/l1/config.rs b/realtime/src/l1/config.rs index d4634cb1..8a090b8f 100644 --- a/realtime/src/l1/config.rs +++ b/realtime/src/l1/config.rs @@ -8,13 +8,16 @@ 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 mock_mode: bool, pub raiko_client: RaikoClient, } @@ -27,7 +30,9 @@ 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, + mock_mode: config.mock_mode, raiko_client, }) } diff --git a/realtime/src/l1/execution_layer.rs b/realtime/src/l1/execution_layer.rs index 50d906d2..71ec6c13 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::{ @@ -48,6 +48,8 @@ pub struct ExecutionLayer { #[allow(dead_code)] raiko_client: RaikoClient, proof_type: crate::l1::bindings::ProofType, + mock_mode: bool, + extra_gas_percentage: u64, } impl ELTrait for ExecutionLayer { @@ -98,10 +100,13 @@ 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; + 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, @@ -112,6 +117,8 @@ impl ELTrait for ExecutionLayer { realtime_inbox, raiko_client, proof_type, + mock_mode, + extra_gas_percentage, }) } @@ -180,6 +187,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, @@ -195,7 +209,12 @@ 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(), + self.extra_gas_percentage, + self.proof_type, + self.mock_mode, + ); let tx = builder .build_propose_tx( @@ -265,6 +284,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 +384,175 @@ 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}; + + // 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 + .hashMessage(message_from_l2.clone()) + .call() + .await + .map_err(|e| anyhow!("Failed to call Bridge.hashMessage for sim: {e}"))?; + + // 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. + // 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. 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); + + let tracer_config = serde_json::json!({"onlyTopCall": false}); + + let tracing_options = GethDebugTracingOptions { + tracer: Some(GethDebugTracerType::BuiltInTracer( + GethDebugBuiltInTracerType::CallTracer, + )), + tracer_config: 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) => { + return Err(anyhow!("L1 callback simulation RPC failed: {e}")); + } + }; + + // 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 { + 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) = 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={}", + signal_slot, + m.destChainId + ); + Ok(Some((m, signal_slot))) + } else { + tracing::debug!( + "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/l1/proposal_tx_builder.rs b/realtime/src/l1/proposal_tx_builder.rs index 0e713525..d4063818 100644 --- a/realtime/src/l1/proposal_tx_builder.rs +++ b/realtime/src/l1/proposal_tx_builder.rs @@ -1,5 +1,8 @@ use crate::l1::{ - bindings::{BlobReference, Multicall, ProofType, ProposeInput, RealTimeInbox, SubProof}, + bindings::{ + BlobReference, Multicall, ProofType, ProposeInput, ProposeInputV2, RealTimeInbox, SubProof, + MOCK_ECDSA_BIT_FLAG, + }, config::ContractAddresses, }; use crate::node::proposal_manager::{ @@ -9,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}, @@ -31,17 +34,33 @@ 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, } } + /// 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, @@ -52,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, @@ -84,39 +95,95 @@ 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 { + // 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. + + // 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 { + 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]) — verifies requiredReturnSignals + 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()); - // 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); + 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 +206,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, BlobTransactionSidecarEip7594), anyhow::Error> { let mut block_manifests = >::with_capacity(batch.l2_blocks.len()); for l2_block in &batch.l2_blocks { block_manifests.push(BlockManifest { @@ -169,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()); @@ -180,49 +259,94 @@ 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()); - // 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, )) } 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); Multicall::Call { target: bridge_address, diff --git a/realtime/src/l2/execution_layer.rs b/realtime/src/l2/execution_layer.rs index 9811fda4..3202f34b 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(); @@ -238,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 { @@ -484,4 +421,138 @@ impl L2BridgeHandlerOps for L2ExecutionLayer { Ok(Bytes::from(vec![hop_proof].abi_encode_params())) } + +} + +// 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, + from: Address, + to: Address, + input: &[u8], + value: Option, + ) -> Result, anyhow::Error> { + 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 + }); + + let tracing_options = GethDebugTracingOptions { + tracer: Some(GethDebugTracerType::BuiltInTracer( + GethDebugBuiltInTracerType::CallTracer, + )), + tracer_config: 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) => { + return Err(anyhow::anyhow!( + "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 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(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 trace found outbound sendMessage: destChainId={}, to={}, from={}", + m.destChainId, m.to, m.from + ); + } else { + debug!("L2 trace found no outbound sendMessage"); + } + + Ok(message) + } +} + +/// 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)); + } + } + } + } + + 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/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..7e64627f 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); @@ -142,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, @@ -160,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/async_submitter.rs b/realtime/src/node/proposal_manager/async_submitter.rs index ba732622..2270f778 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); }); @@ -144,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 @@ -253,6 +284,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 { @@ -276,6 +316,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); } @@ -286,15 +334,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 { @@ -306,7 +356,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 { @@ -325,6 +375,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); } @@ -343,6 +401,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(_) => { @@ -354,6 +415,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 } }; @@ -364,6 +433,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 { @@ -374,6 +446,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 { @@ -384,6 +464,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(), + }, + ); + } } } } @@ -391,11 +479,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 0d981e5c..93cc343f 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)] @@ -64,8 +92,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 @@ -73,6 +99,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 @@ -82,14 +114,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)] @@ -105,6 +133,7 @@ struct BridgeRpcContext { tx: mpsc::Sender, status_store: UserOpStatusStore, next_id: Arc, + ethereum_l1: Arc>, taiko: Arc, last_finalized_block_number: Arc, } @@ -115,7 +144,6 @@ pub struct BridgeHandler { rx: Receiver, status_store: UserOpStatusStore, l1_chain_id: u64, - l2_chain_id: u64, } impl BridgeHandler { @@ -125,7 +153,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); @@ -135,6 +162,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, }; @@ -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( @@ -260,6 +302,105 @@ 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, + /// 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={}, value={:?}", + req.from, + req.to, + 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, req.value) + .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); @@ -275,7 +416,6 @@ impl BridgeHandler { rx, status_store, l1_chain_id, - l2_chain_id, }) } @@ -283,39 +423,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 @@ -323,7 +437,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, @@ -345,23 +459,53 @@ 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?; + 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, signal_slot_proof, + required_return_signal, })); } diff --git a/realtime/src/node/proposal_manager/mod.rs b/realtime/src/node/proposal_manager/mod.rs index 5a32445a..5d6315a1 100644 --- a/realtime/src/node/proposal_manager/mod.rs +++ b/realtime/src/node/proposal_manager/mod.rs @@ -5,10 +5,12 @@ pub mod l2_block_payload; pub mod proposal; 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::{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; @@ -48,6 +50,16 @@ 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>, + /// 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 { @@ -65,7 +77,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\ @@ -98,7 +109,6 @@ impl BatchManager { taiko.clone(), cancel_token.clone(), l1_chain_id, - l2_chain_id, last_finalized_block_number.clone(), ) .await?, @@ -126,6 +136,8 @@ impl BatchManager { cancel_token, last_finalized_block_hash, last_finalized_block_number, + pending_return_signal: None, + pending_mempool_tx_hash: None, }) } @@ -288,87 +300,112 @@ 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); + info!( + "Processing L1→L2 deposit: UserOp id={}", + routed.user_op.id + ); - let l2_call_bridge_tx = self - .taiko - .l2_execution_layer() - .construct_l2_call_tx(l2_call.message_from_l1) - .await?; + let l2_call_bridge_tx = self + .taiko + .l2_execution_layer() + .construct_l2_call_tx(routed.l2_call.message_from_l1) + .await?; - info!("Inserting processMessage tx into L2 block"); - l2_draft_block - .prebuilt_tx_list - .tx_list - .push(l2_call_bridge_tx); + info!("Inserting processMessage tx into L2 block"); + l2_draft_block + .prebuilt_tx_list + .tx_list + .push(l2_call_bridge_tx); - 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 - ); + Ok(Some((routed.user_op, routed.l2_call.signal_slot_on_l2))) + } - match self - .taiko - .l2_execution_layer() - .construct_l2_user_op_tx(&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), - }, - ); - } + /// 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 l1_el = &self.ethereum_l1.execution_layer; + + 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. + // 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, Some(tx_value)) + .await + { + Ok(Some(msg)) => msg, + Ok(None) => continue, + Err(e) => { + debug!("Mempool tx trace failed: {e}"); + continue; + } + }; + + 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))) => { + let tx_hash = *tx.inner.tx_hash(); + info!( + "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; + } + Ok(None) => { + debug!("L1 callback produced no return signal"); + } + Err(e) => { + warn!("L1 callback simulation failed: {e}"); } - // No L1 UserOp or signal slot for L2-direct ops - Ok(None) } } } @@ -381,7 +418,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? @@ -390,7 +428,39 @@ 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"); + } + + // 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; + + // 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={}", + return_slot + ); + self.batch_builder.add_signal_slot(return_slot)?; + 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)?; @@ -417,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)?; @@ -573,3 +647,4 @@ impl BatchManager { Ok(block) } } + 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, diff --git a/realtime/src/utils/config.rs b/realtime/src/utils/config.rs index 01ed2b92..90ca566d 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, @@ -19,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 { @@ -33,6 +42,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()); @@ -66,10 +77,16 @@ 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, bridge, + signal_service, + l2_signal_service, raiko_url, raiko_api_key, proof_type, @@ -80,6 +97,7 @@ impl ConfigTrait for RealtimeConfig { bridge_rpc_addr, preconf_only, proof_request_bypass, + mock_mode, }) } } 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 {