From 43ce9b59326c4fa097497c9dec9c78afdd61f09d Mon Sep 17 00:00:00 2001 From: moudyellaz Date: Thu, 18 Jun 2026 13:54:01 +0200 Subject: [PATCH] feat(cross-zone): add inbox and outbox core crates (envelope, replay key, dispatch builder) --- Cargo.lock | 19 +++ Cargo.toml | 4 + programs/cross_zone_inbox/core/Cargo.toml | 18 ++ programs/cross_zone_inbox/core/src/lib.rs | 186 +++++++++++++++++++++ programs/cross_zone_outbox/core/Cargo.toml | 13 ++ programs/cross_zone_outbox/core/src/lib.rs | 61 +++++++ 6 files changed, 301 insertions(+) create mode 100644 programs/cross_zone_inbox/core/Cargo.toml create mode 100644 programs/cross_zone_inbox/core/src/lib.rs create mode 100644 programs/cross_zone_outbox/core/Cargo.toml create mode 100644 programs/cross_zone_outbox/core/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index d3898e49..0d9eae93 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1796,6 +1796,25 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" +[[package]] +name = "cross_zone_inbox_core" +version = "0.1.0" +dependencies = [ + "lee", + "lee_core", + "risc0-zkvm", + "serde", +] + +[[package]] +name = "cross_zone_outbox_core" +version = "0.1.0" +dependencies = [ + "lee_core", + "risc0-zkvm", + "serde", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" diff --git a/Cargo.toml b/Cargo.toml index 25f03774..1b15f129 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,8 @@ members = [ "programs/faucet/core", "programs/bridge/core", "programs/vault/core", + "programs/cross_zone_outbox/core", + "programs/cross_zone_inbox/core", "lez/sequencer/core", "lez/sequencer/service", "lez/sequencer/service/protocol", @@ -80,6 +82,8 @@ authenticated_transfer_core = { path = "programs/authenticated_transfer/core" } faucet_core = { path = "programs/faucet/core" } bridge_core = { path = "programs/bridge/core" } vault_core = { path = "programs/vault/core" } +cross_zone_outbox_core = { path = "programs/cross_zone_outbox/core" } +cross_zone_inbox_core = { path = "programs/cross_zone_inbox/core" } test_program_methods = { path = "test_program_methods" } testnet_initial_state = { path = "lez/testnet_initial_state" } keycard_wallet = { path = "lez/keycard_wallet" } diff --git a/programs/cross_zone_inbox/core/Cargo.toml b/programs/cross_zone_inbox/core/Cargo.toml new file mode 100644 index 00000000..63760c53 --- /dev/null +++ b/programs/cross_zone_inbox/core/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "cross_zone_inbox_core" +version = "0.1.0" +edition = "2024" +license = { workspace = true } + +[lints] +workspace = true + +[dependencies] +lee_core.workspace = true +serde = { workspace = true, features = ["alloc"] } +risc0-zkvm.workspace = true +lee = { workspace = true, optional = true } + +[features] +# Host-only transaction builder; pulls `lee`, so the risc0 guest builds without it. +host = ["dep:lee"] diff --git a/programs/cross_zone_inbox/core/src/lib.rs b/programs/cross_zone_inbox/core/src/lib.rs new file mode 100644 index 00000000..9af223f5 --- /dev/null +++ b/programs/cross_zone_inbox/core/src/lib.rs @@ -0,0 +1,186 @@ +use std::collections::BTreeMap; + +use lee_core::{ + account::AccountId, + program::{PdaSeed, ProgramId}, +}; +use serde::{Deserialize, Serialize}; + +/// Raw 32-byte zone (channel) id; the host maps it to the zone-sdk `ChannelId`. +pub type ZoneId = [u8; 32]; + +/// Block-signing public key pinned per peer zone. +pub type ExpectedPubkey = [u8; 32]; + +/// Content-addressed replay key for a delivered message. +pub type MessageKey = [u8; 32]; + +/// Source blocks per seen-set shard, so no single seen account grows without bound. +pub const EPOCH_BLOCKS: u64 = 10_000; + +const MESSAGE_KEY_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneMsgKey/00000/"; +const INBOX_CONFIG_SEED: [u8; 32] = *b"/LEZ/v0.3/CrossZoneInboxCfg/000/"; +const INBOX_SEEN_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneInboxSeen/00/"; + +/// A finalized outbound message observed on a peer zone, addressed to a program +/// on this zone. The watcher fills it from the peer's block; it is never +/// self-reported by a user. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CrossZoneMessage { + pub src_zone: ZoneId, + pub src_block_id: u64, + pub src_tx_index: u32, + pub src_program_id: ProgramId, + pub target_program_id: ProgramId, + pub payload: Vec, + /// Reserved for a future source-state proof; MUST be `None` in v1. + pub l1_inclusion_witness: Option>, +} + +/// Peer and per-peer target allowlists. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct InboxConfig { + pub allowed_peers: BTreeMap, + pub allowed_targets: BTreeMap>, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Instruction { + /// Delivers a finalized peer message to its target program. + Dispatch(CrossZoneMessage), +} + +/// Content-addressed replay key: `(src_zone, src_block_id, src_tx_index)` hashed +/// under a domain separator. Watcher-independent and immune to proof +/// malleability, since it keys on block id plus index rather than a tx hash. +#[must_use] +pub fn message_key(src_zone: &ZoneId, src_block_id: u64, src_tx_index: u32) -> MessageKey { + use risc0_zkvm::sha::{Impl, Sha256 as _}; + + let mut bytes = Vec::with_capacity(MESSAGE_KEY_DOMAIN.len() + 32 + 8 + 4); + bytes.extend_from_slice(&MESSAGE_KEY_DOMAIN); + bytes.extend_from_slice(src_zone); + bytes.extend_from_slice(&src_block_id.to_le_bytes()); + bytes.extend_from_slice(&src_tx_index.to_le_bytes()); + + Impl::hash_bytes(&bytes) + .as_bytes() + .try_into() + .unwrap_or_else(|_| unreachable!()) +} + +/// The config account holding the allowlists. +#[must_use] +pub fn inbox_config_account_id(inbox_id: ProgramId) -> AccountId { + AccountId::for_public_pda(&inbox_id, &PdaSeed::new(INBOX_CONFIG_SEED)) +} + +/// The seen-set shard for the `(src_zone, epoch)` the message falls in. +#[must_use] +pub fn inbox_seen_shard_account_id( + inbox_id: ProgramId, + src_zone: &ZoneId, + src_block_id: u64, +) -> AccountId { + AccountId::for_public_pda(&inbox_id, &seen_shard_seed(src_zone, src_block_id)) +} + +fn seen_shard_seed(src_zone: &ZoneId, src_block_id: u64) -> PdaSeed { + use risc0_zkvm::sha::{Impl, Sha256 as _}; + + let src_epoch = src_block_id / EPOCH_BLOCKS; + let mut bytes = Vec::with_capacity(INBOX_SEEN_SEED_DOMAIN.len() + 32 + 8); + bytes.extend_from_slice(&INBOX_SEEN_SEED_DOMAIN); + bytes.extend_from_slice(src_zone); + bytes.extend_from_slice(&src_epoch.to_le_bytes()); + + let seed: [u8; 32] = Impl::hash_bytes(&bytes) + .as_bytes() + .try_into() + .unwrap_or_else(|_| unreachable!()); + PdaSeed::new(seed) +} + +/// Builds the sequencer-origin dispatch transaction. Pure, so the watcher's +/// injected tx and the indexer's re-derived tx are byte-identical for the same +/// inputs (the basis of the Option B check). `target_account_ids` are the +/// inbox's chained-call targets; deriving them is target-specific. +#[cfg(feature = "host")] +#[must_use] +pub fn build_inbox_dispatch_tx( + inbox_id: ProgramId, + msg: &CrossZoneMessage, + target_account_ids: Vec, +) -> lee::PublicTransaction { + let mut account_ids = Vec::with_capacity(2 + target_account_ids.len()); + account_ids.push(inbox_config_account_id(inbox_id)); + account_ids.push(inbox_seen_shard_account_id( + inbox_id, + &msg.src_zone, + msg.src_block_id, + )); + account_ids.extend(target_account_ids); + + let message = lee::public_transaction::Message::try_new( + inbox_id, + account_ids, + vec![], + Instruction::Dispatch(msg.clone()), + ) + .expect("inbox dispatch instruction must serialize"); + + lee::PublicTransaction::new( + message, + lee::public_transaction::WitnessSet::from_raw_parts(vec![]), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn zone(b: u8) -> ZoneId { + [b; 32] + } + + #[test] + fn message_key_is_stable_and_content_addressed() { + assert_eq!(message_key(&zone(1), 7, 3), message_key(&zone(1), 7, 3)); + assert_ne!(message_key(&zone(1), 7, 3), message_key(&zone(2), 7, 3)); + assert_ne!(message_key(&zone(1), 7, 3), message_key(&zone(1), 8, 3)); + assert_ne!(message_key(&zone(1), 7, 3), message_key(&zone(1), 7, 4)); + } + + #[test] + fn seen_shards_split_on_epoch_boundary() { + let id: ProgramId = [9; 8]; + assert_eq!( + inbox_seen_shard_account_id(id, &zone(1), 0), + inbox_seen_shard_account_id(id, &zone(1), EPOCH_BLOCKS - 1), + ); + assert_ne!( + inbox_seen_shard_account_id(id, &zone(1), EPOCH_BLOCKS - 1), + inbox_seen_shard_account_id(id, &zone(1), EPOCH_BLOCKS), + ); + } + + #[cfg(feature = "host")] + #[test] + fn build_inbox_dispatch_tx_is_deterministic() { + let inbox: ProgramId = [5; 8]; + let msg = CrossZoneMessage { + src_zone: zone(1), + src_block_id: 42, + src_tx_index: 2, + src_program_id: [6; 8], + target_program_id: [7; 8], + payload: vec![1, 2, 3, 4], + l1_inclusion_witness: None, + }; + let targets = vec![AccountId::new([8; 32]), AccountId::new([9; 32])]; + + let tx1 = build_inbox_dispatch_tx(inbox, &msg, targets.clone()); + let tx2 = build_inbox_dispatch_tx(inbox, &msg, targets); + assert_eq!(tx1, tx2); + } +} diff --git a/programs/cross_zone_outbox/core/Cargo.toml b/programs/cross_zone_outbox/core/Cargo.toml new file mode 100644 index 00000000..8e26009c --- /dev/null +++ b/programs/cross_zone_outbox/core/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "cross_zone_outbox_core" +version = "0.1.0" +edition = "2024" +license = { workspace = true } + +[lints] +workspace = true + +[dependencies] +lee_core.workspace = true +serde = { workspace = true, features = ["alloc"] } +risc0-zkvm.workspace = true diff --git a/programs/cross_zone_outbox/core/src/lib.rs b/programs/cross_zone_outbox/core/src/lib.rs new file mode 100644 index 00000000..256aae69 --- /dev/null +++ b/programs/cross_zone_outbox/core/src/lib.rs @@ -0,0 +1,61 @@ +use lee_core::{ + account::AccountId, + program::{PdaSeed, ProgramId}, +}; +use serde::{Deserialize, Serialize}; + +/// Raw 32-byte zone (channel) id; the host maps it to the zone-sdk `ChannelId`. +pub type ZoneId = [u8; 32]; + +const OUTBOX_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneOutbox/00000/"; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Instruction { + /// Records an outbound cross-zone message as a write to a self-owned PDA. + /// + /// Required accounts (1): + /// - Outbox PDA account + Emit { + target_zone: ZoneId, + target_program_id: ProgramId, + payload: Vec, + }, +} + +/// PDA holding one emitted message, keyed by destination zone and a per-zone +/// ordinal. +#[must_use] +pub fn outbox_pda(outbox_id: ProgramId, target_zone: &ZoneId, ordinal: u32) -> AccountId { + AccountId::for_public_pda(&outbox_id, &outbox_seed(target_zone, ordinal)) +} + +fn outbox_seed(target_zone: &ZoneId, ordinal: u32) -> PdaSeed { + use risc0_zkvm::sha::{Impl, Sha256 as _}; + + let mut bytes = Vec::with_capacity(OUTBOX_SEED_DOMAIN.len() + target_zone.len() + 4); + bytes.extend_from_slice(&OUTBOX_SEED_DOMAIN); + bytes.extend_from_slice(target_zone); + bytes.extend_from_slice(&ordinal.to_le_bytes()); + + let seed: [u8; 32] = Impl::hash_bytes(&bytes) + .as_bytes() + .try_into() + .unwrap_or_else(|_| unreachable!()); + PdaSeed::new(seed) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn outbox_pda_is_unique_per_zone_and_ordinal() { + let id: ProgramId = [3; 8]; + let zone_a = [1; 32]; + let zone_b = [2; 32]; + + assert_eq!(outbox_pda(id, &zone_a, 0), outbox_pda(id, &zone_a, 0)); + assert_ne!(outbox_pda(id, &zone_a, 0), outbox_pda(id, &zone_a, 1)); + assert_ne!(outbox_pda(id, &zone_a, 0), outbox_pda(id, &zone_b, 0)); + } +}