From fef7ce2c38d3169a54927cb42de2eb2f01f519f7 Mon Sep 17 00:00:00 2001 From: moudyellaz Date: Wed, 24 Jun 2026 14:52:10 +0200 Subject: [PATCH] feat(cross-zone): reject user-origin calls to the sequencer-only inbox --- integration_tests/Cargo.toml | 1 + .../tests/cross_zone_ingress_guard.rs | 81 +++++++++++++++++++ lez/sequencer/core/src/lib.rs | 25 ++++++ lez/sequencer/service/src/service.rs | 15 ++++ 4 files changed, 122 insertions(+) create mode 100644 integration_tests/tests/cross_zone_ingress_guard.rs diff --git a/integration_tests/Cargo.toml b/integration_tests/Cargo.toml index ba4c4500..11cff7bc 100644 --- a/integration_tests/Cargo.toml +++ b/integration_tests/Cargo.toml @@ -25,6 +25,7 @@ faucet_core.workspace = true bridge_core.workspace = true ping_core.workspace = true cross_zone_outbox_core.workspace = true +cross_zone_inbox_core.workspace = true bridge_lock_core.workspace = true wrapped_token_core.workspace = true risc0-zkvm.workspace = true diff --git a/integration_tests/tests/cross_zone_ingress_guard.rs b/integration_tests/tests/cross_zone_ingress_guard.rs new file mode 100644 index 00000000..89ca0d92 --- /dev/null +++ b/integration_tests/tests/cross_zone_ingress_guard.rs @@ -0,0 +1,81 @@ +#![expect( + clippy::tests_outside_test_module, + reason = "We don't care about these in tests" +)] + +//! M6 ingress guard: the cross-zone inbox is sequencer-only. Only the watcher +//! injects inbox dispatches; a user must not be able to invoke the inbox through +//! the public RPC, or anyone could forge an inbound cross-zone delivery. The +//! inbox guest's caller-is-none assertion passes for a top-level user tx, so the +//! sequencer ingress guard is the only thing that stops this. + +use std::net::SocketAddr; + +use anyhow::{Context as _, Result}; +use common::transaction::LeeTransaction; +use cross_zone_inbox_core::{ + CrossZoneMessage, Instruction, inbox_config_account_id, inbox_seen_shard_account_id, +}; +use integration_tests::{ + config::{self, SequencerPartialConfig}, + setup::{setup_bedrock_node, setup_sequencer}, +}; +use lee::{ + PublicTransaction, + program::Program, + public_transaction::{Message, WitnessSet}, +}; +use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder}; +use tokio::test; + +#[test] +async fn user_origin_inbox_call_rejected() -> Result<()> { + let (_bedrock, bedrock_addr) = setup_bedrock_node() + .await + .context("Failed to set up Bedrock node")?; + let partial = SequencerPartialConfig::default(); + let channel = config::bedrock_channel_id(); + let (seq, _seq_home) = setup_sequencer(partial, bedrock_addr, vec![], channel, None) + .await + .context("Failed to set up sequencer")?; + + // A user hand-builds a top-level inbox Dispatch and submits it via RPC. + let inbox_id = Program::cross_zone_inbox().id(); + let msg = CrossZoneMessage { + src_zone: [2; 32], + src_block_id: 1, + src_tx_index: 0, + src_program_id: [9; 8], + target_program_id: Program::ping_receiver().id(), + payload: vec![], + l1_inclusion_witness: None, + }; + let seen_id = inbox_seen_shard_account_id(inbox_id, &msg.src_zone, msg.src_block_id); + let message = Message::try_new( + inbox_id, + vec![inbox_config_account_id(inbox_id), seen_id], + vec![], + Instruction::Dispatch(msg), + ) + .expect("build dispatch message"); + let tx = LeeTransaction::Public(PublicTransaction::new( + message, + WitnessSet::from_raw_parts(vec![]), + )); + + let result = sequencer_client(seq.addr())?.send_transaction(tx).await; + let err = result.expect_err("the sequencer must reject a user-origin inbox call"); + assert!( + err.to_string().contains("sequencer-only"), + "rejection should cite the sequencer-only guard, got: {err}" + ); + Ok(()) +} + +fn sequencer_client(addr: SocketAddr) -> Result { + let url = config::addr_to_url(config::UrlProtocol::Http, addr) + .context("Failed to build sequencer URL")?; + SequencerClientBuilder::default() + .build(url) + .context("Failed to build sequencer client") +} diff --git a/lez/sequencer/core/src/lib.rs b/lez/sequencer/core/src/lib.rs index 7fe2c113..566fcad1 100644 --- a/lez/sequencer/core/src/lib.rs +++ b/lez/sequencer/core/src/lib.rs @@ -44,6 +44,15 @@ pub enum TransactionOrigin { Sequencer, } +/// Whether a program may only be invoked by sequencer-origin transactions. The +/// cross-zone inbox is injected solely by the watcher; a user-submitted call +/// must be rejected at ingress, because `TransactionOrigin` is not carried in +/// the block and so cannot be re-checked later by the indexer. +#[must_use] +pub fn is_sequencer_only_program(program_id: lee::ProgramId) -> bool { + program_id == Program::cross_zone_inbox().id() +} + #[derive(Clone, Debug, BorshDeserialize)] struct DepositMetadata { recipient_id: lee::AccountId, @@ -1725,3 +1734,19 @@ mod tests { ); } } + +#[cfg(test)] +mod sequencer_only_program_tests { + use lee::program::Program; + + use super::is_sequencer_only_program; + + #[test] + fn only_the_cross_zone_inbox_is_sequencer_only() { + assert!(is_sequencer_only_program(Program::cross_zone_inbox().id())); + assert!(!is_sequencer_only_program(Program::cross_zone_outbox().id())); + assert!(!is_sequencer_only_program(Program::wrapped_token().id())); + assert!(!is_sequencer_only_program(Program::ping_sender().id())); + assert!(!is_sequencer_only_program(Program::clock().id())); + } +} diff --git a/lez/sequencer/service/src/service.rs b/lez/sequencer/service/src/service.rs index 1a781024..38c1e5c9 100644 --- a/lez/sequencer/service/src/service.rs +++ b/lez/sequencer/service/src/service.rs @@ -73,6 +73,21 @@ impl sequencer_service_rpc::RpcServer ) })?; + // Sequencer-only programs (the cross-zone inbox) are injected by the + // watcher; a user must not invoke them top-level, or anyone could forge + // an inbound cross-zone delivery. Chained user calls are already rejected + // by the inbox guest's caller-is-none assertion. + if let LeeTransaction::Public(public_tx) = &authenticated_tx { + if sequencer_core::is_sequencer_only_program(public_tx.message().program_id) { + return Err(ErrorObjectOwned::owned( + ErrorCode::InvalidParams.code(), + "Program is sequencer-only and cannot be invoked by a user transaction" + .to_string(), + None::<()>, + )); + } + } + self.mempool_handle .push((TransactionOrigin::User, authenticated_tx)) .await