From f26193eedc3e6bcf8a1985feca2571fb57376c47 Mon Sep 17 00:00:00 2001 From: moudyellaz Date: Mon, 10 Aug 2026 14:15:57 +0200 Subject: [PATCH] fix(cross-zone)!: drop the inbox route allowlist now targets authorize themselves BREAKING CHANGE: InboxConfig is just self_zone; allowed_routes, InboxConfig::permits and routes_permit are gone, and build_inbox_init_config_tx no longer takes the cross-zone config. The operator's per-peer routes still configure the same thing, fanned out at genesis into each target's own config instead. The watcher keeps only a hygiene filter refusing a peer that names a sequencer-only program, kept host-side so it cannot make the verifier disagree. --- .../tests/cross_zone_state_machine.rs | 74 +++---------- lez/cross_zone/src/lib.rs | 27 +---- lez/programs/cross_zone_inbox/core/src/lib.rs | 102 ++---------------- lez/programs/cross_zone_inbox/src/main.rs | 19 +--- lez/sequencer/core/src/cross_zone_watcher.rs | 96 ++++++++++------- lez/sequencer/core/src/lib.rs | 4 +- 6 files changed, 92 insertions(+), 230 deletions(-) diff --git a/integration_tests/tests/cross_zone_state_machine.rs b/integration_tests/tests/cross_zone_state_machine.rs index 09b791839..62a881059 100644 --- a/integration_tests/tests/cross_zone_state_machine.rs +++ b/integration_tests/tests/cross_zone_state_machine.rs @@ -10,10 +10,8 @@ //! `outbox::Emit`). Fast, so they pin guest logic before the e2e exercises the //! plumbing. Run with `RISC0_DEV_MODE=1`. -use std::collections::BTreeMap; - use cross_zone_inbox_core::{ - CrossZoneMessage, CrossZoneRoute, InboxConfig, Instruction as InboxInstruction, SeenShard, + CrossZoneMessage, InboxConfig, Instruction as InboxInstruction, SeenShard, inbox_config_account_id, inbox_seen_shard_account_id, inbox_source_marker_account_id, }; use cross_zone_outbox_core::{OutboxRecord, outbox_pda}; @@ -45,27 +43,10 @@ fn base_state() -> V03State { ]) } -/// Seeds an inbox config (inbox-owned) allowing `src_zone -> target`. -fn seed_inbox_config( - state: &mut V03State, - self_zone: [u8; 32], - src_zone: [u8; 32], - src_program_id: lee_core::program::ProgramId, - target: lee_core::program::ProgramId, -) { +/// Seeds the inbox config (inbox-owned), which is now just this zone's id. +fn seed_inbox_config(state: &mut V03State, self_zone: [u8; 32]) { let inbox_id = programs::cross_zone_inbox().id(); - let mut allowed_routes = BTreeMap::new(); - allowed_routes.insert( - src_zone, - vec![CrossZoneRoute { - src_program_id, - target_program_id: target, - }], - ); - let config = InboxConfig { - self_zone, - allowed_routes, - }; + let config = InboxConfig { self_zone }; *state = std::mem::replace(state, V03State::new()).with_public_accounts([( inbox_config_account_id(inbox_id), Account { @@ -228,13 +209,7 @@ fn dispatch_mint(amount: u128) -> Result InboxConfig { - let mut allowed_routes = BTreeMap::new(); - for peer in &cross_zone.peers { - allowed_routes.insert(peer.channel_id, peer.allowed_routes.clone()); - } - InboxConfig { - self_zone, - allowed_routes, - } -} - /// The genesis transaction that initializes this zone's inbox config PDA. /// -/// Lets the inbox guest authorize inbound peer messages; replaying it seeds the -/// same account on every node, keeping their state consistent. +/// The operator's per-peer routes no longer live here. They are fanned out into +/// each target program's own config, so all the inbox keeps is its zone id. +/// Replaying this seeds the same account on every node. #[must_use] -pub fn build_inbox_init_config_tx( - self_zone: ZoneId, - cross_zone: &CrossZoneConfig, -) -> lee::PublicTransaction { +pub fn build_inbox_init_config_tx(self_zone: ZoneId) -> lee::PublicTransaction { let inbox_id = programs::cross_zone_inbox().id(); genesis_public_tx( inbox_id, vec![inbox_config_account_id(inbox_id)], - Instruction::InitConfig(inbox_config(self_zone, cross_zone)), + Instruction::InitConfig(InboxConfig { self_zone }), ) } diff --git a/lez/programs/cross_zone_inbox/core/src/lib.rs b/lez/programs/cross_zone_inbox/core/src/lib.rs index d974f7003..71ec5f834 100644 --- a/lez/programs/cross_zone_inbox/core/src/lib.rs +++ b/lez/programs/cross_zone_inbox/core/src/lib.rs @@ -1,4 +1,4 @@ -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeSet; use borsh::{BorshDeserialize, BorshSerialize}; use lee_core::{ @@ -85,32 +85,20 @@ pub struct CrossZoneMessage { pub l1_inclusion_witness: Option>, } -/// Per-peer delivery routes, plus this inbox's own zone id. +/// This inbox's own zone id. +/// +/// It no longer decides who may deliver what. Each target program authorizes its +/// own sources against the marker the inbox passes, so the only thing the inbox +/// still needs to know is which zone it is, to refuse a message addressed to +/// itself. #[derive( Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, BorshSerialize, BorshDeserialize, )] pub struct InboxConfig { pub self_zone: ZoneId, - /// Which deliveries each peer may make. A peer absent from this map may - /// deliver nothing. - pub allowed_routes: BTreeMap>, } impl InboxConfig { - /// Whether `src_zone` may deliver from `src_program_id` to - /// `target_program_id`. A peer with no routes may deliver nothing. - #[must_use] - pub fn permits( - &self, - src_zone: &ZoneId, - src_program_id: ProgramId, - target_program_id: ProgramId, - ) -> bool { - self.allowed_routes - .get(src_zone) - .is_some_and(|routes| routes_permit(routes, src_program_id, target_program_id)) - } - /// Borsh-encoded form stored in the inbox config account. #[must_use] pub fn to_bytes(&self) -> Vec { @@ -205,25 +193,6 @@ pub enum Instruction { InitConfig(InboxConfig), } -/// Whether `routes` authorize a delivery from `src_program_id` to -/// `target_program_id`. -/// -/// The one place the rule lives. The inbox guest decides with it and the -/// sequencer's watcher drops unroutable messages with it, and those two must -/// agree: a watcher stricter than the guest loses messages silently, and one -/// looser records deliveries the guest will refuse, which production then feeds -/// in and gives up on. -#[must_use] -pub fn routes_permit( - routes: &[CrossZoneRoute], - src_program_id: ProgramId, - target_program_id: ProgramId, -) -> bool { - routes.iter().any(|route| { - route.src_program_id == src_program_id && route.target_program_id == target_program_id - }) -} - /// Content-addressed replay key for a delivered message. /// /// Hashes `(src_zone, src_block_id, src_tx_index)` under a domain separator. @@ -336,63 +305,6 @@ mod tests { [b; 32] } - fn program(n: u32) -> ProgramId { - [n; 8] - } - - /// The route is the pair. Two entries that are each reasonable on their own, - /// a lock program that may mint and a ping emitter that may reach a - /// receiver, must not compose into the lock program's target being - /// reachable from the ping emitter: that emitter lets its caller choose the - /// target, so it would mint with nothing locked behind it. - #[test] - fn a_route_authorizes_one_pair_and_does_not_compose() { - let lock = program(1); - let wrapped_token = program(2); - let ping_sender = program(3); - let ping_receiver = program(4); - - let mut allowed_routes = BTreeMap::new(); - allowed_routes.insert( - zone(9), - vec![ - CrossZoneRoute { - src_program_id: lock, - target_program_id: wrapped_token, - }, - CrossZoneRoute { - src_program_id: ping_sender, - target_program_id: ping_receiver, - }, - ], - ); - let config = InboxConfig { - self_zone: zone(1), - allowed_routes, - }; - - assert!(config.permits(&zone(9), lock, wrapped_token)); - assert!(config.permits(&zone(9), ping_sender, ping_receiver)); - - assert!( - !config.permits(&zone(9), ping_sender, wrapped_token), - "an emitter whose caller picks the target must not reach the bridge's target" - ); - assert!( - !config.permits(&zone(9), lock, ping_receiver), - "a route grants its own target, not every target the peer has" - ); - } - - #[test] - fn a_peer_with_no_routes_may_deliver_nothing() { - let config = InboxConfig { - self_zone: zone(1), - allowed_routes: BTreeMap::new(), - }; - assert!(!config.permits(&zone(9), program(1), program(2))); - } - #[test] fn message_key_is_stable_and_content_addressed() { assert_eq!(message_key(&zone(1), 7, 3), message_key(&zone(1), 7, 3)); diff --git a/lez/programs/cross_zone_inbox/src/main.rs b/lez/programs/cross_zone_inbox/src/main.rs index d27e147ab..ff61a8bc9 100644 --- a/lez/programs/cross_zone_inbox/src/main.rs +++ b/lez/programs/cross_zone_inbox/src/main.rs @@ -95,15 +95,6 @@ fn dispatch( msg.src_zone != cfg.self_zone, "Source zone must not be this zone" ); - // Checked as a pair. The emitting program is as much a part of the - // authorization as the target: an emitter whose caller chooses the target - // reaches everything the peer may reach, so a target allowlist on its own - // lets any such emitter stand in for every other one. - assert!( - cfg.permits(&msg.src_zone, msg.src_program_id, msg.target_program_id), - "No route from this source program to this target program for this peer" - ); - let mut shard = SeenShard::from_bytes(&seen.account.data.clone().into_inner()).expect("seen shard decodes"); @@ -177,8 +168,7 @@ fn dispatch( .write(); } -/// Writes the inbox config (peer + target allowlists) into the config PDA exactly -/// once at genesis. +/// Writes the inbox config into the config PDA exactly once at genesis. fn init_config( self_program_id: ProgramId, caller_program_id: Option, @@ -195,9 +185,8 @@ fn init_config( "account must be the inbox config PDA" ); // Init-once, idempotent under genesis replay: a `default` config is a first - // init; an already-owned config must already hold exactly these allowlists (the - // genesis block is replayed onto seeded state during multi-sequencer - // reconstruction), otherwise reject a post-genesis attempt to change them. + // init; an already-owned config must already hold exactly this, since genesis + // is replayed onto seeded state during multi-sequencer reconstruction. // `new_claimed_if_default` alone would not stop the owning program from // rewriting its own config data on a later call. if config_meta.account != Account::default() { @@ -208,7 +197,7 @@ fn init_config( assert_eq!( config_meta.account.data.clone().into_inner(), config.to_bytes(), - "inbox config already initialized with different allowlists" + "inbox config already initialized differently" ); } diff --git a/lez/sequencer/core/src/cross_zone_watcher.rs b/lez/sequencer/core/src/cross_zone_watcher.rs index 26b0e4441..038b7e891 100644 --- a/lez/sequencer/core/src/cross_zone_watcher.rs +++ b/lez/sequencer/core/src/cross_zone_watcher.rs @@ -1,8 +1,10 @@ use std::{sync::Arc, time::Duration}; use common::{HashType, block::Block, transaction::LeeTransaction}; -use cross_zone::{EmissionSource, build_dispatch_from_emission, extract_emission}; -use cross_zone_inbox_core::{CrossZoneRoute, message_key, routes_permit}; +use cross_zone::{ + EmissionSource, build_dispatch_from_emission, extract_emission, is_sequencer_only_program, +}; +use cross_zone_inbox_core::message_key; use futures::{Stream, StreamExt as _}; use lee::{GENESIS_BLOCK_ID, PublicKey}; use log::{debug, error, info, warn}; @@ -36,7 +38,6 @@ const STUCK_SLOT_ALERT_PASSES: u32 = 20; struct PeerContext { peer_zone: [u8; 32], self_zone: [u8; 32], - allowed_routes: Vec, expected_pubkey: Option, } @@ -280,7 +281,6 @@ pub fn spawn_watchers( PeerContext { peer_zone: peer.channel_id, self_zone, - allowed_routes: peer.allowed_routes, expected_pubkey, }, poll_interval, @@ -556,7 +556,6 @@ fn record_block_deliveries( ) -> bool { let peer_zone = peer.peer_zone; let self_zone = peer.self_zone; - let allowed_routes = peer.allowed_routes.as_slice(); // Collected and written once. The pending list is a single value, so a write // per delivery would rewrite the whole list once per message, which is // quadratic in a peer block that carries many of them, on a task holding the @@ -574,16 +573,15 @@ fn record_block_deliveries( if emission.target_zone != self_zone { continue; } - // Mirrors the inbox guest, which is the authority. Dropping here keeps - // an unroutable message from becoming a record that production would - // feed in and give up on three blocks later. - if !routes_permit( - allowed_routes, - message.program_id, - emission.target_program_id, - ) { + // Targets authorize their own sources now, so this is not authorization, + // it is hygiene: a delivery the zone will certainly refuse still costs a + // pending-list slot and three execution attempts before it is dead + // lettered. Kept host-side only, never in `extract_emission` or the + // verifier's re-derivation, where a check that depends on this build would + // make the two disagree and halt ingestion. + if is_sequencer_only_program(emission.target_program_id) { warn!( - "Watcher dropping message from peer {}: no route from that source program to that target", + "Watcher dropping message from peer {}: a peer may not dispatch into a sequencer-only program", hex::encode(peer_zone) ); continue; @@ -680,10 +678,6 @@ mod tests { PeerContext { peer_zone: PEER_ZONE, self_zone: SELF_ZONE, - allowed_routes: vec![CrossZoneRoute { - src_program_id: programs::ping_sender().id(), - target_program_id: programs::ping_receiver().id(), - }], expected_pubkey: None, } } @@ -1082,13 +1076,49 @@ mod tests { } #[tokio::test] - async fn a_delivery_with_no_route_is_never_recorded() { - // The peer is routed to ping_receiver only. A bridging zone would also - // route its lock program to wrapped_token, and `ping_sender` lets its - // caller name wrapped_token as the target, so without the pair check - // this emission would be recorded and delivered, minting with nothing - // locked behind it. The guest rejects it too; dropping here keeps it - // from becoming a record production feeds in and gives up on. + async fn a_delivery_into_a_sequencer_only_program_is_never_recorded() { + // Targets authorize their own sources, so the watcher no longer decides + // who may reach what. It still refuses to queue a delivery the zone will + // certainly refuse: the inbox is injected by this node alone, so a peer + // naming it as a target is junk that would cost a pending slot and three + // execution attempts. + let (_dir, dbio) = store(); + let mut cursor = None; + let mut tip = None; + + let outcome = consume_peer_stream( + stream::iter(vec![peer_block_msg_to( + 1, + 0, + programs::cross_zone_inbox().id(), + )]), + &peer_context(), + &dbio, + &mut cursor, + &mut tip, + ) + .await; + + assert_eq!( + outcome, + PassOutcome::Drained, + "a message the watcher drops is not a failure" + ); + assert!( + recorded_keys(&dbio).is_empty(), + "a message aimed at a sequencer-only program must not be recorded" + ); + assert_eq!( + get_cross_zone_peer_floor(&dbio, PEER_ZONE).unwrap(), + Some(Slot::from(0)), + "the slot was fully read, so the floor still advances" + ); + } + + #[tokio::test] + async fn a_delivery_to_an_unrelated_target_is_still_recorded() { + // The watcher is not the authorization point any more. A target it knows + // nothing about is recorded and delivered, and that target decides. let (_dir, dbio) = store(); let mut cursor = None; let mut tip = None; @@ -1106,19 +1136,11 @@ mod tests { ) .await; + assert_eq!(outcome, PassOutcome::Drained); assert_eq!( - outcome, - PassOutcome::Drained, - "an unroutable message is not a failure" - ); - assert!( - recorded_keys(&dbio).is_empty(), - "a message with no route must not be recorded" - ); - assert_eq!( - get_cross_zone_peer_floor(&dbio, PEER_ZONE).unwrap(), - Some(Slot::from(0)), - "the slot was fully read, so the floor still advances" + recorded_keys(&dbio).len(), + 1, + "the watcher records it and lets the target refuse it" ); } diff --git a/lez/sequencer/core/src/lib.rs b/lez/sequencer/core/src/lib.rs index bd467076a..5eeb88633 100644 --- a/lez/sequencer/core/src/lib.rs +++ b/lez/sequencer/core/src/lib.rs @@ -1537,9 +1537,9 @@ fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec