feat(cross-zone): reject user-origin calls to the sequencer-only inbox

This commit is contained in:
moudyellaz 2026-06-24 14:52:10 +02:00
parent 4fd465b529
commit fef7ce2c38
4 changed files with 122 additions and 0 deletions

View File

@ -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

View File

@ -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<SequencerClient> {
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")
}

View File

@ -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()));
}
}

View File

@ -73,6 +73,21 @@ impl<BC: BlockPublisherTrait + Send + 'static> 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