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.
This commit is contained in:
moudyellaz
2026-08-10 14:15:57 +02:00
parent 6a15eaec93
commit f26193eedc
6 changed files with 92 additions and 230 deletions
@@ -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<ValidatedStateDiff, lee::error::LeeErro
let src_block_id = 5;
let mut state = base_state();
seed_inbox_config(
&mut state,
self_zone,
src_zone,
[9_u32; 8],
wrapped_token_id,
);
seed_inbox_config(&mut state, self_zone);
seed_wrapped_config(&mut state, vec![(src_zone, [9_u32; 8])]);
let msg = CrossZoneMessage {
@@ -302,7 +277,7 @@ fn inbox_dispatch_delivers_payload_to_ping_receiver() {
let src_block_id = 5;
let mut state = base_state();
seed_inbox_config(&mut state, self_zone, src_zone, [9_u32; 8], receiver_id);
seed_inbox_config(&mut state, self_zone);
seed_receiver_config(&mut state, vec![(src_zone, [9_u32; 8])]);
// The payload is the ping_receiver instruction, serialized as risc0 words in
@@ -987,12 +962,11 @@ fn inbox_dispatch_mints_wrapped_token() {
);
}
/// A zone that bridges must allow `wrapped_token` as a target. When that
/// allowance was per peer rather than per source program, it was enough for any
/// emitter on the peer to reach it, and `ping_sender` lets its caller choose the
/// target and payload freely. Any user on the peer could therefore mint wrapped
/// tokens with no lock and no escrow behind them, by routing a `Mint` payload
/// through the ping emitter. The route is the pair, so this must not execute.
/// `ping_sender` lets its caller choose the target and payload freely, so any user
/// on a peer can aim a `Mint` payload at `wrapped_token`. The inbox no longer
/// refuses it; the token does, because the marker names `ping_sender` and the
/// token authorized only the bridge. This is the check that replaced the central
/// route table, so it must be the thing that rejects here.
#[test]
fn a_mint_from_an_unrouted_emitter_is_rejected() {
let inbox_id = programs::cross_zone_inbox().id();
@@ -1004,13 +978,7 @@ fn a_mint_from_an_unrouted_emitter_is_rejected() {
let mut state = base_state();
// The config a bridging zone writes: the lock program may mint, nothing else.
seed_inbox_config(
&mut state,
self_zone,
src_zone,
programs::bridge_lock().id(),
wrapped_token_id,
);
seed_inbox_config(&mut state, self_zone);
seed_wrapped_config(&mut state, vec![(src_zone, programs::bridge_lock().id())]);
let msg = CrossZoneMessage {
@@ -1057,13 +1025,7 @@ fn a_mint_from_the_routed_emitter_is_accepted() {
let src_block_id = 5;
let mut state = base_state();
seed_inbox_config(
&mut state,
self_zone,
src_zone,
bridge_lock_id,
wrapped_token_id,
);
seed_inbox_config(&mut state, self_zone);
seed_wrapped_config(&mut state, vec![(src_zone, programs::bridge_lock().id())]);
let msg = CrossZoneMessage {
@@ -1111,13 +1073,7 @@ fn mint_replay_rejected() {
let src_tx_index = 0;
let mut state = base_state();
seed_inbox_config(
&mut state,
self_zone,
src_zone,
[9_u32; 8],
wrapped_token_id,
);
seed_inbox_config(&mut state, self_zone);
seed_wrapped_config(&mut state, vec![(src_zone, [9_u32; 8])]);
// Seed the seen-shard as already holding this delivery, so the inbox takes
@@ -1197,7 +1153,7 @@ fn a_delivery_from_a_second_block_at_the_same_id_is_refused() {
let other_block_hash = [8_u8; 32];
let mut state = base_state();
seed_inbox_config(&mut state, self_zone, src_zone, [9_u32; 8], receiver_id);
seed_inbox_config(&mut state, self_zone);
seed_receiver_config(&mut state, vec![(src_zone, [9_u32; 8])]);
// The shard as the first delivery left it: bound, holding transaction 0.
+5 -22
View File
@@ -9,8 +9,6 @@
//! own block-reading, emission-extraction, delivery-building, and trust model; a
//! shared trait is best lifted from that first real adapter, not from this one.
use std::collections::BTreeMap;
pub use cross_zone_inbox_core::{CrossZoneConfig, CrossZonePeer};
use cross_zone_inbox_core::{
CrossZoneMessage, InboxConfig, Instruction, ZoneId, inbox_config_account_id,
@@ -172,33 +170,18 @@ pub fn build_dispatch_from_emission(
build_inbox_dispatch_tx(programs::cross_zone_inbox().id(), &msg, target_ids)
}
/// The inbox config a zone derives from its cross-zone config: the per-peer
/// delivery routes plus its own zone id.
fn inbox_config(self_zone: ZoneId, cross_zone: &CrossZoneConfig) -> 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 }),
)
}
+7 -95
View File
@@ -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<Vec<u8>>,
}
/// 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<ZoneId, Vec<CrossZoneRoute>>,
}
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<u8> {
@@ -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));
+4 -15
View File
@@ -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<ProgramId>,
@@ -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"
);
}
+59 -37
View File
@@ -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<CrossZoneRoute>,
expected_pubkey: Option<PublicKey>,
}
@@ -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"
);
}
+2 -2
View File
@@ -1537,9 +1537,9 @@ fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec<LeeTrans
config.cross_zone.as_ref(),
));
let bridge_lock_config_tx = std::iter::once(cross_zone::build_bridge_lock_init_config_tx());
let inbox_config_tx = config.cross_zone.as_ref().map(|cross_zone| {
let inbox_config_tx = config.cross_zone.as_ref().map(|_| {
let self_zone = *config.bedrock_config.channel_id.as_ref();
cross_zone::build_inbox_init_config_tx(self_zone, cross_zone)
cross_zone::build_inbox_init_config_tx(self_zone)
});
let supply_txs = config.genesis.iter().filter_map(|action| match action {
GenesisAction::SupplyAccount {