diff --git a/Cargo.lock b/Cargo.lock index 0d9eae93..6dd5b588 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1800,6 +1800,7 @@ checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" name = "cross_zone_inbox_core" version = "0.1.0" dependencies = [ + "borsh", "lee", "lee_core", "risc0-zkvm", @@ -1810,6 +1811,7 @@ dependencies = [ name = "cross_zone_outbox_core" version = "0.1.0" dependencies = [ + "borsh", "lee_core", "risc0-zkvm", "serde", @@ -7403,6 +7405,8 @@ dependencies = [ "authenticated_transfer_core", "bridge_core", "clock_core", + "cross_zone_inbox_core", + "cross_zone_outbox_core", "faucet_core", "lee_core", "risc0-zkvm", diff --git a/artifacts/program_methods/cross_zone_inbox.bin b/artifacts/program_methods/cross_zone_inbox.bin new file mode 100644 index 00000000..3595d60d Binary files /dev/null and b/artifacts/program_methods/cross_zone_inbox.bin differ diff --git a/artifacts/program_methods/cross_zone_outbox.bin b/artifacts/program_methods/cross_zone_outbox.bin new file mode 100644 index 00000000..b89f8d0f Binary files /dev/null and b/artifacts/program_methods/cross_zone_outbox.bin differ diff --git a/lee/state_machine/src/program.rs b/lee/state_machine/src/program.rs index c4223810..bf48afd8 100644 --- a/lee/state_machine/src/program.rs +++ b/lee/state_machine/src/program.rs @@ -11,8 +11,9 @@ use crate::{ program_methods::{ AMM_ELF, AMM_ID, ASSOCIATED_TOKEN_ACCOUNT_ELF, ASSOCIATED_TOKEN_ACCOUNT_ID, AUTHENTICATED_TRANSFER_ELF, AUTHENTICATED_TRANSFER_ID, BRIDGE_ELF, BRIDGE_ID, CLOCK_ELF, - CLOCK_ID, FAUCET_ELF, FAUCET_ID, PINATA_ELF, PINATA_ID, TOKEN_ELF, TOKEN_ID, VAULT_ELF, - VAULT_ID, + CLOCK_ID, CROSS_ZONE_INBOX_ELF, CROSS_ZONE_INBOX_ID, CROSS_ZONE_OUTBOX_ELF, + CROSS_ZONE_OUTBOX_ID, FAUCET_ELF, FAUCET_ID, PINATA_ELF, PINATA_ID, TOKEN_ELF, TOKEN_ID, + VAULT_ELF, VAULT_ID, }, }; @@ -173,6 +174,22 @@ impl Program { elf: BRIDGE_ELF.to_vec(), } } + + #[must_use] + pub fn cross_zone_outbox() -> Self { + Self { + id: CROSS_ZONE_OUTBOX_ID, + elf: CROSS_ZONE_OUTBOX_ELF.to_vec(), + } + } + + #[must_use] + pub fn cross_zone_inbox() -> Self { + Self { + id: CROSS_ZONE_INBOX_ID, + elf: CROSS_ZONE_INBOX_ELF.to_vec(), + } + } } // TODO: Testnet only. Refactor to prevent compilation on mainnet. diff --git a/lee/state_machine/src/state.rs b/lee/state_machine/src/state.rs index c7152917..110f3113 100644 --- a/lee/state_machine/src/state.rs +++ b/lee/state_machine/src/state.rs @@ -197,6 +197,8 @@ impl V03State { this.insert_program(Program::vault()); this.insert_program(Program::faucet()); this.insert_program(Program::bridge()); + this.insert_program(Program::cross_zone_outbox()); + this.insert_program(Program::cross_zone_inbox()); this } diff --git a/program_methods/guest/Cargo.toml b/program_methods/guest/Cargo.toml index b7b37961..d7572235 100644 --- a/program_methods/guest/Cargo.toml +++ b/program_methods/guest/Cargo.toml @@ -20,5 +20,7 @@ ata_program.workspace = true faucet_core.workspace = true bridge_core.workspace = true vault_core.workspace = true +cross_zone_outbox_core.workspace = true +cross_zone_inbox_core.workspace = true risc0-zkvm.workspace = true serde = { workspace = true, default-features = false } diff --git a/program_methods/guest/src/bin/cross_zone_inbox.rs b/program_methods/guest/src/bin/cross_zone_inbox.rs new file mode 100644 index 00000000..41a1015b --- /dev/null +++ b/program_methods/guest/src/bin/cross_zone_inbox.rs @@ -0,0 +1,127 @@ +use cross_zone_inbox_core::{ + InboxConfig, Instruction, SeenShard, inbox_config_account_id, inbox_seen_shard_account_id, + inbox_seen_shard_seed, message_key, +}; +use lee_core::{ + account::AccountWithMetadata, + program::{AccountPostState, ChainedCall, Claim, ProgramInput, ProgramOutput, read_lee_inputs}, +}; + +fn unchanged(pre: &AccountWithMetadata) -> AccountPostState { + AccountPostState::new(pre.account.clone()) +} + +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + instruction, + }, + instruction_words, + ) = read_lee_inputs::(); + + assert!( + caller_program_id.is_none(), + "Inbox is only invoked as a top-level sequencer-origin transaction" + ); + + let msg = match instruction { + Instruction::Dispatch(msg) => msg, + }; + + assert!( + msg.l1_inclusion_witness.is_none(), + "l1_inclusion_witness must be None in v1" + ); + + // pre_states layout: [config, seen_shard, then the target accounts]. + let mut accounts = pre_states.into_iter(); + let config = accounts.next().expect("config account required"); + let seen = accounts.next().expect("seen shard account required"); + let target_accounts: Vec = accounts.collect(); + + assert_eq!( + config.account_id, + inbox_config_account_id(self_program_id), + "First account must be the inbox config PDA" + ); + assert_eq!( + seen.account_id, + inbox_seen_shard_account_id(self_program_id, &msg.src_zone, msg.src_block_id), + "Second account must be the seen-shard PDA" + ); + + let cfg = InboxConfig::from_bytes(&config.account.data.clone().into_inner()) + .expect("inbox config decodes"); + + assert!( + 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"); + assert!( + allowed_targets.contains(&msg.target_program_id), + "Target program is not allowed for this peer" + ); + + let key = message_key(&msg.src_zone, msg.src_block_id, msg.src_tx_index); + let mut shard = + SeenShard::from_bytes(&seen.account.data.clone().into_inner()).expect("seen shard decodes"); + let already_seen = shard.contains(&key); + + // On replay this is a no-op: the seen shard is untouched and no call is made. + let (seen_post, chained_calls) = if already_seen { + (unchanged(&seen), vec![]) + } else { + shard.insert(key); + let mut seen_account = seen.account.clone(); + seen_account.data = shard + .to_bytes() + .try_into() + .expect("seen shard fits in account data"); + let seen_post = AccountPostState::new_claimed_if_default( + seen_account, + Claim::Pda(inbox_seen_shard_seed(&msg.src_zone, msg.src_block_id)), + ); + + // The payload carries the target instruction as risc0 words, little-endian. + assert!( + msg.payload.len() % 4 == 0, + "payload must be u32-aligned instruction words" + ); + let instruction_data = msg + .payload + .chunks_exact(4) + .map(|c| u32::from_le_bytes([c[0], c[1], c[2], c[3]])) + .collect(); + + let call = ChainedCall { + program_id: msg.target_program_id, + pre_states: target_accounts.clone(), + instruction_data, + pda_seeds: vec![], + }; + (seen_post, vec![call]) + }; + + let mut post_states = vec![unchanged(&config), seen_post]; + post_states.extend(target_accounts.iter().map(unchanged)); + + let mut pre_states = vec![config, seen]; + pre_states.extend(target_accounts); + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + pre_states, + post_states, + ) + .with_chained_calls(chained_calls) + .write(); +} diff --git a/program_methods/guest/src/bin/cross_zone_outbox.rs b/program_methods/guest/src/bin/cross_zone_outbox.rs new file mode 100644 index 00000000..84805807 --- /dev/null +++ b/program_methods/guest/src/bin/cross_zone_outbox.rs @@ -0,0 +1,64 @@ +use cross_zone_outbox_core::{Instruction, OutboxRecord, outbox_pda, outbox_pda_seed}; +use lee_core::{ + account::AccountWithMetadata, + program::{AccountPostState, Claim, ProgramInput, ProgramOutput, read_lee_inputs}, +}; + +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + instruction, + }, + instruction_words, + ) = read_lee_inputs::(); + + assert!( + caller_program_id.is_some(), + "Outbox is only callable through a chain call from a user program" + ); + + let (target_zone, target_program_id, payload, ordinal) = match instruction { + Instruction::Emit { + target_zone, + target_program_id, + payload, + ordinal, + } => (target_zone, target_program_id, payload, ordinal), + }; + + let [outbox] = + <[AccountWithMetadata; 1]>::try_from(pre_states).expect("Emit requires exactly 1 account"); + + assert_eq!( + outbox.account_id, + outbox_pda(self_program_id, &target_zone, ordinal), + "Account must be the outbox PDA for (target_zone, ordinal)" + ); + + let mut post_account = outbox.account.clone(); + post_account.data = OutboxRecord { + target_zone, + target_program_id, + payload, + } + .to_bytes() + .try_into() + .expect("OutboxRecord fits in account data"); + + let post = AccountPostState::new_claimed_if_default( + post_account, + Claim::Pda(outbox_pda_seed(&target_zone, ordinal)), + ); + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + vec![outbox], + vec![post], + ) + .write(); +} diff --git a/programs/cross_zone_inbox/core/Cargo.toml b/programs/cross_zone_inbox/core/Cargo.toml index 63760c53..0afc78c4 100644 --- a/programs/cross_zone_inbox/core/Cargo.toml +++ b/programs/cross_zone_inbox/core/Cargo.toml @@ -11,6 +11,7 @@ workspace = true lee_core.workspace = true serde = { workspace = true, features = ["alloc"] } risc0-zkvm.workspace = true +borsh.workspace = true lee = { workspace = true, optional = true } [features] diff --git a/programs/cross_zone_inbox/core/src/lib.rs b/programs/cross_zone_inbox/core/src/lib.rs index 9af223f5..d3db5f78 100644 --- a/programs/cross_zone_inbox/core/src/lib.rs +++ b/programs/cross_zone_inbox/core/src/lib.rs @@ -1,5 +1,6 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; +use borsh::{BorshDeserialize, BorshSerialize}; use lee_core::{ account::AccountId, program::{PdaSeed, ProgramId}, @@ -37,13 +38,58 @@ pub struct CrossZoneMessage { pub l1_inclusion_witness: Option>, } -/// Peer and per-peer target allowlists. -#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +/// Peer and per-peer target allowlists, 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>, } +impl InboxConfig { + /// Borsh-encoded form stored in the inbox config account. + #[must_use] + pub fn to_bytes(&self) -> Vec { + borsh::to_vec(self).expect("InboxConfig serializes") + } + + /// Decodes an [`InboxConfig`] from account data. + pub fn from_bytes(bytes: &[u8]) -> borsh::io::Result { + borsh::from_slice(bytes) + } +} + +/// The replay keys seen for one `(src_zone, epoch)` shard. +#[derive(Clone, Debug, Default, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +pub struct SeenShard(pub BTreeSet); + +impl SeenShard { + /// Decodes a shard from account data; empty data is an empty shard. + pub fn from_bytes(bytes: &[u8]) -> borsh::io::Result { + if bytes.is_empty() { + return Ok(Self::default()); + } + borsh::from_slice(bytes) + } + + #[must_use] + pub fn to_bytes(&self) -> Vec { + borsh::to_vec(self).expect("SeenShard serializes") + } + + #[must_use] + pub fn contains(&self, key: &MessageKey) -> bool { + self.0.contains(key) + } + + /// Inserts a key; returns true if it was newly inserted. + pub fn insert(&mut self, key: MessageKey) -> bool { + self.0.insert(key) + } +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum Instruction { /// Delivers a finalized peer message to its target program. @@ -82,10 +128,12 @@ pub fn inbox_seen_shard_account_id( src_zone: &ZoneId, src_block_id: u64, ) -> AccountId { - AccountId::for_public_pda(&inbox_id, &seen_shard_seed(src_zone, src_block_id)) + AccountId::for_public_pda(&inbox_id, &inbox_seen_shard_seed(src_zone, src_block_id)) } -fn seen_shard_seed(src_zone: &ZoneId, src_block_id: u64) -> PdaSeed { +/// Seed of the seen-shard PDA, exposed so the guest can claim the account. +#[must_use] +pub fn inbox_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; diff --git a/programs/cross_zone_outbox/core/Cargo.toml b/programs/cross_zone_outbox/core/Cargo.toml index 8e26009c..c7876286 100644 --- a/programs/cross_zone_outbox/core/Cargo.toml +++ b/programs/cross_zone_outbox/core/Cargo.toml @@ -11,3 +11,4 @@ workspace = true lee_core.workspace = true serde = { workspace = true, features = ["alloc"] } risc0-zkvm.workspace = true +borsh.workspace = true diff --git a/programs/cross_zone_outbox/core/src/lib.rs b/programs/cross_zone_outbox/core/src/lib.rs index 256aae69..528cf769 100644 --- a/programs/cross_zone_outbox/core/src/lib.rs +++ b/programs/cross_zone_outbox/core/src/lib.rs @@ -1,3 +1,4 @@ +use borsh::{BorshDeserialize, BorshSerialize}; use lee_core::{ account::AccountId, program::{PdaSeed, ProgramId}, @@ -19,17 +20,43 @@ pub enum Instruction { target_zone: ZoneId, target_program_id: ProgramId, payload: Vec, + ordinal: u32, }, } +/// The message as stored in an outbox PDA. The destination zone's watcher reads +/// this from the inscribed block; the source coordinates are filled by the +/// watcher, not stored here. +#[derive(Clone, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +pub struct OutboxRecord { + pub target_zone: ZoneId, + pub target_program_id: ProgramId, + pub payload: Vec, +} + +impl OutboxRecord { + /// Borsh-encoded form stored in the outbox PDA's account data. + #[must_use] + pub fn to_bytes(&self) -> Vec { + borsh::to_vec(self).expect("OutboxRecord serializes") + } + + /// Decodes an [`OutboxRecord`] from account data. + pub fn from_bytes(bytes: &[u8]) -> borsh::io::Result { + borsh::from_slice(bytes) + } +} + /// 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)) + AccountId::for_public_pda(&outbox_id, &outbox_pda_seed(target_zone, ordinal)) } -fn outbox_seed(target_zone: &ZoneId, ordinal: u32) -> PdaSeed { +/// Seed of an outbox message PDA, exposed so the guest can claim the account. +#[must_use] +pub fn outbox_pda_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);