From 09f4bfdd0319069993b576e24aa98ae37c75d6ac Mon Sep 17 00:00:00 2001 From: moudyellaz Date: Mon, 3 Aug 2026 04:28:13 +0200 Subject: [PATCH] feat(cross-zone)!: authorize deliveries by source program and target BREAKING CHANGE: `CrossZonePeer.allowed_targets` is replaced by `allowed_routes`, a list of `{src_program_id, target_program_id}` pairs. `InboxConfig.allowed_peers` is removed. --- integration_tests/tests/cross_zone_bridge.rs | 7 +- integration_tests/tests/cross_zone_ping.rs | 7 +- .../tests/cross_zone_state_machine.rs | 154 +++++++++++++++++- .../tests/cross_zone_verified.rs | 7 +- .../tests/cross_zone_watcher_restart.rs | 7 +- lez/cross_zone/src/lib.rs | 11 +- lez/programs/cross_zone_inbox/core/src/lib.rs | 118 +++++++++++++- lez/programs/cross_zone_inbox/src/main.rs | 12 +- lez/sequencer/core/src/config.rs | 2 +- lez/sequencer/core/src/cross_zone_watcher.rs | 85 +++++++++- lez/sequencer/core/src/tests.rs | 10 +- tools/cross_zone_chat/src/main.rs | 7 +- 12 files changed, 380 insertions(+), 47 deletions(-) diff --git a/integration_tests/tests/cross_zone_bridge.rs b/integration_tests/tests/cross_zone_bridge.rs index 919b3dbc..0c334088 100644 --- a/integration_tests/tests/cross_zone_bridge.rs +++ b/integration_tests/tests/cross_zone_bridge.rs @@ -30,7 +30,7 @@ use lee::{ AccountId, PrivateKey, PublicKey, PublicTransaction, public_transaction::{Message, WitnessSet}, }; -use sequencer_core::config::{CrossZoneConfig, CrossZonePeer, GenesisAction}; +use sequencer_core::config::{CrossZoneConfig, CrossZonePeer, CrossZoneRoute, GenesisAction}; use sequencer_service_rpc::RpcClient as _; use tokio::test; @@ -58,7 +58,10 @@ async fn lock_on_zone_a_mints_wrapped_token_on_zone_b() -> Result<()> { let cross_zone = CrossZoneConfig { peers: vec![CrossZonePeer { channel_id: *channel_a.as_ref(), - allowed_targets: vec![wrapped_token_id], + allowed_routes: vec![CrossZoneRoute { + src_program_id: programs::bridge_lock().id(), + target_program_id: wrapped_token_id, + }], expected_block_signing_pubkey: None, }], }; diff --git a/integration_tests/tests/cross_zone_ping.rs b/integration_tests/tests/cross_zone_ping.rs index f106bda1..c4724b13 100644 --- a/integration_tests/tests/cross_zone_ping.rs +++ b/integration_tests/tests/cross_zone_ping.rs @@ -23,7 +23,7 @@ use integration_tests::{ use lee::{AccountId, PublicTransaction, public_transaction::Message}; use lee_core::program::ProgramId; use ping_core::{ReceiverInstruction, SenderInstruction, ping_record_pda}; -use sequencer_core::config::{CrossZoneConfig, CrossZonePeer}; +use sequencer_core::config::{CrossZoneConfig, CrossZonePeer, CrossZoneRoute}; use sequencer_service_rpc::{RpcClient as _, SequencerClient}; use tokio::test; @@ -49,7 +49,10 @@ async fn ping_crosses_from_zone_a_to_zone_b() -> Result<()> { let cross_zone = CrossZoneConfig { peers: vec![CrossZonePeer { channel_id: zone_a, - allowed_targets: vec![receiver_id], + allowed_routes: vec![CrossZoneRoute { + src_program_id: programs::ping_sender().id(), + target_program_id: receiver_id, + }], expected_block_signing_pubkey: None, }], }; diff --git a/integration_tests/tests/cross_zone_state_machine.rs b/integration_tests/tests/cross_zone_state_machine.rs index f1080c8c..f9d92a31 100644 --- a/integration_tests/tests/cross_zone_state_machine.rs +++ b/integration_tests/tests/cross_zone_state_machine.rs @@ -13,7 +13,7 @@ use std::collections::BTreeMap; use cross_zone_inbox_core::{ - CrossZoneMessage, InboxConfig, Instruction as InboxInstruction, SeenShard, + CrossZoneMessage, CrossZoneRoute, InboxConfig, Instruction as InboxInstruction, SeenShard, inbox_config_account_id, inbox_seen_shard_account_id, message_key, }; use cross_zone_outbox_core::{OutboxRecord, outbox_pda}; @@ -44,15 +44,21 @@ 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, ) { let inbox_id = programs::cross_zone_inbox().id(); - let mut allowed_targets = BTreeMap::new(); - allowed_targets.insert(src_zone, vec![target]); + 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_peers: BTreeMap::new(), - allowed_targets, + allowed_routes, }; *state = std::mem::replace(state, V03State::new()).with_public_accounts([( inbox_config_account_id(inbox_id), @@ -109,7 +115,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, receiver_id); + seed_inbox_config(&mut state, self_zone, src_zone, [9_u32; 8], receiver_id); // The payload is the ping_receiver instruction, serialized as risc0 words in // little-endian bytes (the contract the inbox reverses when forwarding). @@ -243,7 +249,13 @@ fn inbox_dispatch_mints_wrapped_token() { let src_block_id = 5; let mut state = base_state(); - seed_inbox_config(&mut state, self_zone, src_zone, wrapped_token_id); + seed_inbox_config( + &mut state, + self_zone, + src_zone, + [9_u32; 8], + wrapped_token_id, + ); seed_wrapped_config(&mut state); let msg = CrossZoneMessage { @@ -285,6 +297,126 @@ 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. +#[test] +fn a_mint_from_an_unrouted_emitter_is_rejected() { + let inbox_id = programs::cross_zone_inbox().id(); + let wrapped_token_id = programs::wrapped_token().id(); + + let self_zone = [1_u8; 32]; + let src_zone = [2_u8; 32]; + let src_block_id = 5; + + 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_wrapped_config(&mut state); + + let msg = CrossZoneMessage { + src_zone, + src_block_id, + src_tx_index: 0, + // The emitter a user can drive directly, aimed at the bridge's target. + src_program_id: programs::ping_sender().id(), + target_program_id: wrapped_token_id, + payload: mint_payload(), + l1_inclusion_witness: None, + }; + + let seen_id = inbox_seen_shard_account_id(inbox_id, &src_zone, src_block_id); + let wrapped_config_id = wrapped_token_core::config_account_id(wrapped_token_id); + let holding_id = wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT); + + let message = Message::try_new( + inbox_id, + vec![ + inbox_config_account_id(inbox_id), + seen_id, + wrapped_config_id, + holding_id, + ], + vec![], + InboxInstruction::Dispatch(msg), + ) + .expect("build dispatch message"); + let tx = PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![])); + + assert!( + ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0).is_err(), + "a delivery from an emitter with no route to wrapped_token must not mint" + ); +} + +/// The same target reached by the emitter the route names still works. Without +/// this, the test above would pass equally against an inbox that rejected every +/// delivery. +#[test] +fn a_mint_from_the_routed_emitter_is_accepted() { + let inbox_id = programs::cross_zone_inbox().id(); + let wrapped_token_id = programs::wrapped_token().id(); + let bridge_lock_id = programs::bridge_lock().id(); + + let self_zone = [1_u8; 32]; + let src_zone = [2_u8; 32]; + 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_wrapped_config(&mut state); + + let msg = CrossZoneMessage { + src_zone, + src_block_id, + src_tx_index: 0, + src_program_id: bridge_lock_id, + target_program_id: wrapped_token_id, + payload: mint_payload(), + l1_inclusion_witness: None, + }; + + let seen_id = inbox_seen_shard_account_id(inbox_id, &src_zone, src_block_id); + let wrapped_config_id = wrapped_token_core::config_account_id(wrapped_token_id); + let holding_id = wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT); + + let message = Message::try_new( + inbox_id, + vec![ + inbox_config_account_id(inbox_id), + seen_id, + wrapped_config_id, + holding_id, + ], + vec![], + InboxInstruction::Dispatch(msg), + ) + .expect("build dispatch message"); + let tx = PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![])); + + let diff = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0) + .expect("the routed emitter must still deliver"); + let minted = wrapped_token_core::read_balance( + &diff.public_diff()[&holding_id].data.clone().into_inner(), + ); + assert_eq!(minted, LOCK_AMOUNT); +} + /// A dispatch whose message key is already in the seen-shard is an idempotent /// no-op: the inbox makes no chained call, so the wrapped token is not minted a /// second time. This is the bridge's replay defense. @@ -299,7 +431,13 @@ fn mint_replay_rejected() { let src_tx_index = 0; let mut state = base_state(); - seed_inbox_config(&mut state, self_zone, src_zone, wrapped_token_id); + seed_inbox_config( + &mut state, + self_zone, + src_zone, + [9_u32; 8], + wrapped_token_id, + ); seed_wrapped_config(&mut state); // Seed the seen-shard as already containing this message's key, so the inbox diff --git a/integration_tests/tests/cross_zone_verified.rs b/integration_tests/tests/cross_zone_verified.rs index cc21c42a..92ccdacb 100644 --- a/integration_tests/tests/cross_zone_verified.rs +++ b/integration_tests/tests/cross_zone_verified.rs @@ -22,7 +22,7 @@ use integration_tests::{ use lee::{AccountId, PublicTransaction, public_transaction::Message}; use lee_core::program::ProgramId; use ping_core::{ReceiverInstruction, SenderInstruction, ping_record_pda}; -use sequencer_core::config::{CrossZoneConfig, CrossZonePeer}; +use sequencer_core::config::{CrossZoneConfig, CrossZonePeer, CrossZoneRoute}; use sequencer_service_rpc::RpcClient as _; use tokio::test; @@ -46,7 +46,10 @@ async fn indexer_verifies_and_delivers_cross_zone_ping() -> Result<()> { let cross_zone = CrossZoneConfig { peers: vec![CrossZonePeer { channel_id: zone_a, - allowed_targets: vec![receiver_id], + allowed_routes: vec![CrossZoneRoute { + src_program_id: programs::ping_sender().id(), + target_program_id: receiver_id, + }], expected_block_signing_pubkey: None, }], }; diff --git a/integration_tests/tests/cross_zone_watcher_restart.rs b/integration_tests/tests/cross_zone_watcher_restart.rs index 155a29fc..86dfc705 100644 --- a/integration_tests/tests/cross_zone_watcher_restart.rs +++ b/integration_tests/tests/cross_zone_watcher_restart.rs @@ -26,7 +26,7 @@ use integration_tests::{ use lee::{AccountId, PublicTransaction, public_transaction::Message}; use lee_core::program::ProgramId; use ping_core::{ReceiverInstruction, SenderInstruction, ping_record_pda}; -use sequencer_core::config::{CrossZoneConfig, CrossZonePeer}; +use sequencer_core::config::{CrossZoneConfig, CrossZonePeer, CrossZoneRoute}; use sequencer_service_rpc::{RpcClient as _, SequencerClient}; use tokio::test; @@ -55,7 +55,10 @@ async fn restarted_watcher_resumes_instead_of_replaying_the_peer_channel() -> Re let cross_zone = CrossZoneConfig { peers: vec![CrossZonePeer { channel_id: zone_a, - allowed_targets: vec![receiver_id], + allowed_routes: vec![CrossZoneRoute { + src_program_id: programs::ping_sender().id(), + target_program_id: receiver_id, + }], expected_block_signing_pubkey: None, }], }; diff --git a/lez/cross_zone/src/lib.rs b/lez/cross_zone/src/lib.rs index 06ebd8a0..38f35d24 100644 --- a/lez/cross_zone/src/lib.rs +++ b/lez/cross_zone/src/lib.rs @@ -143,17 +143,16 @@ 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 target -/// allowlists plus its own zone id. +/// 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_targets = BTreeMap::new(); + let mut allowed_routes = BTreeMap::new(); for peer in &cross_zone.peers { - allowed_targets.insert(peer.channel_id, peer.allowed_targets.clone()); + allowed_routes.insert(peer.channel_id, peer.allowed_routes.clone()); } InboxConfig { self_zone, - allowed_peers: BTreeMap::new(), - allowed_targets, + allowed_routes, } } diff --git a/lez/programs/cross_zone_inbox/core/src/lib.rs b/lez/programs/cross_zone_inbox/core/src/lib.rs index 4323db6b..b3ea8d59 100644 --- a/lez/programs/cross_zone_inbox/core/src/lib.rs +++ b/lez/programs/cross_zone_inbox/core/src/lib.rs @@ -23,13 +23,30 @@ pub type ExpectedPubkey = [u8; 32]; /// Content-addressed replay key for a delivered message. pub type MessageKey = [u8; 32]; +/// One delivery a peer is allowed to make: a program on the peer that may emit, +/// paired with the program here it may reach. +/// +/// The pair is the unit rather than two independent lists. A bridging peer needs +/// `wrapped_token` reachable, and any emitter that lets its caller choose the +/// target (`ping_sender` does) would otherwise reach it too, minting tokens with +/// no lock behind them. Naming the pair is what stops two separately reasonable +/// entries composing into a route nobody wrote down. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +pub struct CrossZoneRoute { + /// The program on the peer zone that emitted the message. + pub src_program_id: ProgramId, + /// The program on this zone it may be delivered to. + pub target_program_id: ProgramId, +} + /// A peer zone whose outbox a zone watches for inbound cross-zone messages. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct CrossZonePeer { /// The peer's Bedrock channel; its 32 bytes double as the peer's zone id. pub channel_id: ZoneId, - /// Programs on the local zone a message from this peer is allowed to target. - pub allowed_targets: Vec, + /// The deliveries this peer may make: which of its programs may emit, and + /// what each of them may reach here. + pub allowed_routes: Vec, /// The peer's block-signing public key, pinned to reject blocks inscribed by /// anyone other than that zone's sequencer. `None` skips the check (the /// channel signer is still authenticated by the zone-sdk). @@ -60,17 +77,32 @@ pub struct CrossZoneMessage { pub l1_inclusion_witness: Option>, } -/// Peer and per-peer target allowlists, plus this inbox's own zone id. +/// Per-peer delivery routes, plus this inbox's own zone id. #[derive( Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, BorshSerialize, BorshDeserialize, )] pub struct InboxConfig { pub self_zone: ZoneId, - pub allowed_peers: BTreeMap, - pub allowed_targets: BTreeMap>, + /// 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 { @@ -122,6 +154,25 @@ 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. @@ -191,6 +242,63 @@ 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 bea71c69..4dec4478 100644 --- a/lez/programs/cross_zone_inbox/src/main.rs +++ b/lez/programs/cross_zone_inbox/src/main.rs @@ -85,13 +85,13 @@ fn dispatch( msg.src_zone != cfg.self_zone, "Source zone must not be this zone" ); - let allowed_targets = cfg - .allowed_targets - .get(&msg.src_zone) - .expect("Source zone is not an allowed peer"); + // 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!( - allowed_targets.contains(&msg.target_program_id), - "Target program is not allowed for this peer" + 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 key = message_key(&msg.src_zone, msg.src_block_id, msg.src_tx_index); diff --git a/lez/sequencer/core/src/config.rs b/lez/sequencer/core/src/config.rs index aff0ae48..60bd8502 100644 --- a/lez/sequencer/core/src/config.rs +++ b/lez/sequencer/core/src/config.rs @@ -8,7 +8,7 @@ use std::{ use anyhow::Result; use bytesize::ByteSize; use common::config::BasicAuth; -pub use cross_zone_inbox_core::{CrossZoneConfig, CrossZonePeer}; +pub use cross_zone_inbox_core::{CrossZoneConfig, CrossZonePeer, CrossZoneRoute}; use humantime_serde; use lee::{AccountId, Balance}; use logos_blockchain_core::mantle::ops::channel::ChannelId; diff --git a/lez/sequencer/core/src/cross_zone_watcher.rs b/lez/sequencer/core/src/cross_zone_watcher.rs index e3ca8ba3..3b2961f0 100644 --- a/lez/sequencer/core/src/cross_zone_watcher.rs +++ b/lez/sequencer/core/src/cross_zone_watcher.rs @@ -2,10 +2,9 @@ use std::{sync::Arc, time::Duration}; use common::{block::Block, transaction::LeeTransaction}; use cross_zone::{build_dispatch_from_emission, extract_emission}; -use cross_zone_inbox_core::message_key; +use cross_zone_inbox_core::{CrossZoneRoute, message_key, routes_permit}; use futures::{Stream, StreamExt as _}; use lee::PublicKey; -use lee_core::program::ProgramId; use log::{debug, error, info, warn}; use logos_blockchain_core::mantle::ops::channel::ChannelId; use logos_blockchain_zone_sdk::{ @@ -33,7 +32,7 @@ const DECODE_RETRY_LIMIT: u32 = 20; struct PeerContext { peer_zone: [u8; 32], self_zone: [u8; 32], - allowed_targets: Vec, + allowed_routes: Vec, expected_pubkey: Option, } @@ -216,7 +215,7 @@ pub fn spawn_watchers( PeerContext { peer_zone: peer.channel_id, self_zone, - allowed_targets: peer.allowed_targets, + allowed_routes: peer.allowed_routes, expected_pubkey, }, poll_interval, @@ -432,7 +431,7 @@ fn advance_cursor( fn record_block_deliveries(block: &Block, peer: &PeerContext, dbio: &RocksDBIO) -> bool { let peer_zone = peer.peer_zone; let self_zone = peer.self_zone; - let allowed_targets = peer.allowed_targets.as_slice(); + 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 @@ -450,9 +449,16 @@ fn record_block_deliveries(block: &Block, peer: &PeerContext, dbio: &RocksDBIO) if emission.target_zone != self_zone { continue; } - if !allowed_targets.contains(&emission.target_program_id) { + // 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, + ) { warn!( - "Watcher dropping message to disallowed target from peer {}", + "Watcher dropping message from peer {}: no route from that source program to that target", hex::encode(peer_zone) ); continue; @@ -546,7 +552,10 @@ mod tests { PeerContext { peer_zone: PEER_ZONE, self_zone: SELF_ZONE, - allowed_targets: vec![programs::ping_receiver().id()], + allowed_routes: vec![CrossZoneRoute { + src_program_id: programs::ping_sender().id(), + target_program_id: programs::ping_receiver().id(), + }], expected_pubkey: None, } } @@ -561,11 +570,18 @@ mod tests { /// A `ping_sender` emission addressed to `SELF_ZONE`. fn emission() -> LeeTransaction { + emission_to(programs::ping_receiver().id()) + } + + /// A `ping_sender` emission aimed at `target_program_id`. The sender lets its + /// caller name any target, which is exactly why the route has to pin the + /// pair rather than the target alone. + fn emission_to(target_program_id: lee_core::program::ProgramId) -> LeeTransaction { let receiver_id = programs::ping_receiver().id(); let send = SenderInstruction::Send { outbox_program_id: programs::cross_zone_outbox().id(), target_zone: SELF_ZONE, - target_program_id: receiver_id, + target_program_id, target_accounts: vec![ping_record_pda(receiver_id).into_value()], payload: b"hi".to_vec(), ordinal: 0, @@ -594,6 +610,17 @@ mod tests { peer_msg(borsh::to_vec(&block).expect("block serializes"), slot) } + /// A stream item carrying a block whose one emission targets + /// `target_program_id`. + fn peer_block_msg_to( + block_id: u64, + slot: u64, + target_program_id: lee_core::program::ProgramId, + ) -> (ZoneMessage, Slot) { + let block = produce_dummy_block(block_id, None, vec![emission_to(target_program_id)]); + peer_msg(borsh::to_vec(&block).expect("block serializes"), slot) + } + fn undecodable_msg(slot: u64) -> (ZoneMessage, Slot) { peer_msg(b"not a block".to_vec(), slot) } @@ -781,6 +808,46 @@ 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. + let (_dir, dbio) = store(); + let mut cursor = None; + + let outcome = consume_peer_stream( + stream::iter(vec![peer_block_msg_to( + 1, + 0, + programs::wrapped_token().id(), + )]), + &peer_context(), + &dbio, + &mut cursor, + SkipPolicy::DeliverAll, + ) + .await; + + 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" + ); + } + #[tokio::test] async fn watcher_records_every_delivery_it_reads() { let (_dir, dbio) = store(); diff --git a/lez/sequencer/core/src/tests.rs b/lez/sequencer/core/src/tests.rs index 44778a22..863bb99e 100644 --- a/lez/sequencer/core/src/tests.rs +++ b/lez/sequencer/core/src/tests.rs @@ -36,7 +36,10 @@ use crate::{ block_publisher::FollowUpdate, block_store::SequencerStore, build_bridge_deposit_tx_from_event, build_genesis_state, classify_settled_deliveries, - config::{BedrockConfig, CrossZoneConfig, CrossZonePeer, GenesisAction, SequencerConfig}, + config::{ + BedrockConfig, CrossZoneConfig, CrossZonePeer, CrossZoneRoute, GenesisAction, + SequencerConfig, + }, deposit_already_minted, dispatch_already_delivered, extract_cross_zone_dispatch, extract_cross_zone_dispatch_key, is_sequencer_only_program, mock::{SequencerCoreWithMockClients, mock_checkpoint}, @@ -174,7 +177,10 @@ fn cross_zone_test_config() -> SequencerConfig { cross_zone: Some(CrossZoneConfig { peers: vec![CrossZonePeer { channel_id: PEER_ZONE, - allowed_targets: vec![programs::ping_receiver().id()], + allowed_routes: vec![CrossZoneRoute { + src_program_id: programs::ping_sender().id(), + target_program_id: programs::ping_receiver().id(), + }], expected_block_signing_pubkey: None, }], }), diff --git a/tools/cross_zone_chat/src/main.rs b/tools/cross_zone_chat/src/main.rs index e9205d97..88f6d385 100644 --- a/tools/cross_zone_chat/src/main.rs +++ b/tools/cross_zone_chat/src/main.rs @@ -54,7 +54,7 @@ use axum::{ routing::{get, post}, }; use common::{block::BedrockStatus, transaction::LeeTransaction}; -use cross_zone_inbox_core::{CrossZoneConfig, CrossZonePeer, Instruction, ZoneId}; +use cross_zone_inbox_core::{CrossZoneConfig, CrossZonePeer, CrossZoneRoute, Instruction, ZoneId}; use cross_zone_outbox_core::outbox_pda; use lee::{ ProgramId, PublicTransaction, @@ -348,7 +348,10 @@ fn watch_peer(peer: ZoneId, receiver_id: ProgramId) -> CrossZoneConfig { CrossZoneConfig { peers: vec![CrossZonePeer { channel_id: peer, - allowed_targets: vec![receiver_id], + allowed_routes: vec![CrossZoneRoute { + src_program_id: programs::ping_sender().id(), + target_program_id: receiver_id, + }], expected_block_signing_pubkey: None, }], }