diff --git a/Cargo.lock b/Cargo.lock index 67c4b696c..f3063ba77 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7704,6 +7704,7 @@ name = "ping_core" version = "0.1.0" dependencies = [ "lee_core", + "risc0-zkvm", "serde", ] diff --git a/integration_tests/tests/cross_zone_ping.rs b/integration_tests/tests/cross_zone_ping.rs index 527a0135b..35aa213ee 100644 --- a/integration_tests/tests/cross_zone_ping.rs +++ b/integration_tests/tests/cross_zone_ping.rs @@ -22,7 +22,9 @@ use integration_tests::{ }; use lee::{AccountId, PublicTransaction, public_transaction::Message}; use lee_core::program::ProgramId; -use ping_core::{ReceiverInstruction, SenderInstruction, ping_record_pda}; +use ping_core::{ + ReceiverInstruction, SenderInstruction, ping_record_pda, sender_config_account_id, +}; use sequencer_core::config::{CrossZoneConfig, CrossZonePeer, CrossZoneRoute}; use sequencer_service_rpc::{RpcClient as _, SequencerClient}; use tokio::test; @@ -104,7 +106,6 @@ fn build_ping_tx(target_zone: [u8; 32], receiver_id: ProgramId) -> LeeTransactio let payload: Vec = words.iter().flat_map(|word| word.to_le_bytes()).collect(); let send = SenderInstruction::Send { - outbox_program_id: outbox_id, target_zone, target_program_id: receiver_id, target_accounts: vec![ping_record_pda(receiver_id).into_value()], @@ -112,15 +113,11 @@ fn build_ping_tx(target_zone: [u8; 32], receiver_id: ProgramId) -> LeeTransactio ordinal, }; - let outbox_account = outbox_pda( - outbox_id, - programs::ping_sender().id(), - &target_zone, - ordinal, - ); + let sender_id = programs::ping_sender().id(); + let outbox_account = outbox_pda(outbox_id, sender_id, &target_zone, ordinal); let message = Message::try_new( - programs::ping_sender().id(), - vec![outbox_account], + sender_id, + vec![sender_config_account_id(sender_id), outbox_account], vec![], send, ) diff --git a/integration_tests/tests/cross_zone_state_machine.rs b/integration_tests/tests/cross_zone_state_machine.rs index b484f12f0..968fc38a9 100644 --- a/integration_tests/tests/cross_zone_state_machine.rs +++ b/integration_tests/tests/cross_zone_state_machine.rs @@ -22,7 +22,9 @@ use lee::{ public_transaction::{Message, WitnessSet}, }; use lee_core::account::Account; -use ping_core::{ReceiverInstruction, ping_record_pda}; +use ping_core::{ + ReceiverInstruction, outbox_bytes, ping_record_pda, read_outbox, sender_config_account_id, +}; const INITIAL_BALANCE: u128 = 100; const LOCK_AMOUNT: u128 = 30; @@ -94,6 +96,43 @@ fn seed_wrapped_config(state: &mut V03State) { )]); } +/// Seeds the ping-sender config account pinning the real outbox, matching what +/// genesis seeds for a real zone. +fn seed_ping_sender_config(state: &mut V03State) { + let sender_id = programs::ping_sender().id(); + *state = std::mem::replace(state, V03State::new()).with_public_accounts([( + sender_config_account_id(sender_id), + Account { + program_owner: sender_id, + data: outbox_bytes(programs::cross_zone_outbox().id()) + .to_vec() + .try_into() + .expect("outbox id fits in account data"), + ..Default::default() + }, + )]); +} + +/// A `ping_sender::Send` carrying `payload` to `target_zone`, over the accounts +/// given rather than the correct ones, so tests can vary them. +fn send_tx(accounts: Vec, target_zone: [u8; 32], ordinal: u32) -> PublicTransaction { + let receiver_id = programs::ping_receiver().id(); + let words = risc0_zkvm::serde::to_vec(&ReceiverInstruction::Record { + payload: b"ping".to_vec(), + }) + .expect("serialize ping instruction"); + let send = ping_core::SenderInstruction::Send { + target_zone, + target_program_id: receiver_id, + target_accounts: vec![ping_record_pda(receiver_id).into_value()], + payload: words.iter().flat_map(|word| word.to_le_bytes()).collect(), + ordinal, + }; + let message = Message::try_new(programs::ping_sender().id(), accounts, vec![], send) + .expect("build ping_sender message"); + PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![])) +} + /// The wrapped-token `Mint` the bridge forwards, serialized as the cross-zone /// payload (risc0 words, little-endian bytes). fn mint_payload() -> Vec { @@ -423,6 +462,7 @@ fn two_emitters_share_an_ordinal_without_colliding() { ..Default::default() }, )]); + seed_ping_sender_config(&mut state); let lock_slot = outbox_pda(outbox_id, bridge_lock_id, &zone_b, ordinal); let send_slot = outbox_pda(outbox_id, sender_id, &zone_b, ordinal); @@ -436,23 +476,12 @@ fn two_emitters_share_an_ordinal_without_colliding() { .expect("the lock executes"); state.apply_state_diff(diff); - let words = risc0_zkvm::serde::to_vec(&ReceiverInstruction::Record { - payload: b"ping".to_vec(), - }) - .expect("serialize ping instruction"); - let send = ping_core::SenderInstruction::Send { - outbox_program_id: outbox_id, - target_zone: zone_b, - target_program_id: receiver_id, - target_accounts: vec![ping_record_pda(receiver_id).into_value()], - payload: words.iter().flat_map(|word| word.to_le_bytes()).collect(), + let send = send_tx( + vec![sender_config_account_id(sender_id), send_slot], + zone_b, ordinal, - }; - let message = Message::try_new(sender_id, vec![send_slot], vec![], send) - .expect("build ping_sender message"); - let send_tx = PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![])); - - let send_diff = ValidatedStateDiff::from_public_transaction(&send_tx, &state, 2, 0) + ); + let send_diff = ValidatedStateDiff::from_public_transaction(&send, &state, 2, 0) .expect("the send executes into its own slot, not the lock's"); let record = OutboxRecord::from_bytes( @@ -472,6 +501,135 @@ fn two_emitters_share_an_ordinal_without_colliding() { assert_eq!(lock_record.emitter, bridge_lock_id); } +/// A caller can no longer aim an emission at a program of their own and still +/// succeed, leaving no record of it. With the program no longer an instruction +/// field, the account is the only way left to try. +#[test] +fn a_send_into_a_foreign_outbox_slot_is_rejected() { + let sender_id = programs::ping_sender().id(); + let zone_b = [2_u8; 32]; + let ordinal = 0; + + let mut state = base_state(); + seed_ping_sender_config(&mut state); + + // A slot under some other program, which is what the caller would have to + // pass to reach it. + let foreign_slot = outbox_pda([3; 8], sender_id, &zone_b, ordinal); + let send = send_tx( + vec![sender_config_account_id(sender_id), foreign_slot], + zone_b, + ordinal, + ); + + // Refused inside the pinned outbox, not by the sender: the chained call goes + // there whatever account the caller passes, which is the point. + let Err(err) = ValidatedStateDiff::from_public_transaction(&send, &state, 1, 0) else { + panic!("a send into a slot outside the pinned outbox must not execute"); + }; + assert!( + format!("{err:?}").contains("Account must be the outbox PDA"), + "rejected for the wrong reason: {err:?}" + ); +} + +/// An emitter with no pin cannot fall back to a caller-named outbox: it stops +/// emitting. The state a zone reaches by skipping the genesis init. +#[test] +fn a_send_before_the_pin_is_set_is_rejected() { + let sender_id = programs::ping_sender().id(); + let outbox_id = programs::cross_zone_outbox().id(); + let zone_b = [2_u8; 32]; + let ordinal = 0; + + let state = base_state(); + let slot = outbox_pda(outbox_id, sender_id, &zone_b, ordinal); + let send = send_tx( + vec![sender_config_account_id(sender_id), slot], + zone_b, + ordinal, + ); + + let Err(err) = ValidatedStateDiff::from_public_transaction(&send, &state, 1, 0) else { + panic!("a send with no outbox pinned must not execute"); + }; + assert!( + format!("{err:?}").contains("config account holds an outbox program id"), + "rejected for the wrong reason: {err:?}" + ); +} + +/// The config is read by address, so substituting another account for it fails +/// rather than pinning the outbox to whatever that account happens to hold. +#[test] +fn a_send_with_a_substituted_config_account_is_rejected() { + let sender_id = programs::ping_sender().id(); + let outbox_id = programs::cross_zone_outbox().id(); + let zone_b = [2_u8; 32]; + let ordinal = 0; + + let mut state = base_state(); + seed_ping_sender_config(&mut state); + + let slot = outbox_pda(outbox_id, sender_id, &zone_b, ordinal); + let send = send_tx(vec![ping_record_pda(sender_id), slot], zone_b, ordinal); + + let Err(err) = ValidatedStateDiff::from_public_transaction(&send, &state, 1, 0) else { + panic!("a send over a substituted config account must not execute"); + }; + assert!( + format!("{err:?}").contains("must be the ping-sender config PDA"), + "rejected for the wrong reason: {err:?}" + ); +} + +/// Written once: an identical re-init has to succeed, since genesis is replayed +/// during multi-sequencer reconstruction, while one naming a different outbox has +/// to fail, or anyone could redirect every emission on the zone after genesis. +#[test] +fn the_outbox_pin_is_written_once_and_replayable() { + let sender_id = programs::ping_sender().id(); + let config_id = sender_config_account_id(sender_id); + + // Unsigned and nonce-free, as genesis builds it: the config PDA has no signer. + let init = |outbox: lee_core::program::ProgramId| { + let message = Message::try_new( + sender_id, + vec![config_id], + vec![], + ping_core::SenderInstruction::InitConfig { + outbox_program_id: outbox, + }, + ) + .expect("build InitConfig message"); + PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![])) + }; + + let mut state = base_state(); + let outbox_id = programs::cross_zone_outbox().id(); + + let first = init(outbox_id); + let diff = ValidatedStateDiff::from_public_transaction(&first, &state, 1, 0) + .expect("the first init claims the config PDA"); + state.apply_state_diff(diff); + assert_eq!( + read_outbox(&state.get_account_by_id(config_id).data.into_inner()), + Some(outbox_id), + "the config pins the outbox after genesis" + ); + + ValidatedStateDiff::from_public_transaction(&init(outbox_id), &state, 2, 0) + .expect("replaying the identical init is a no-op, not a failure"); + + let Err(err) = ValidatedStateDiff::from_public_transaction(&init([3; 8]), &state, 3, 0) else { + panic!("a re-init naming a different outbox must not execute"); + }; + assert!( + format!("{err:?}").contains("already pins a different outbox"), + "rejected for the wrong reason: {err:?}" + ); +} + /// Drives a hand-built `cross_zone_inbox::Dispatch` (as the watcher would inject) /// and asserts it chains into `wrapped_token::Mint`, crediting the recipient. #[test] diff --git a/integration_tests/tests/cross_zone_verified.rs b/integration_tests/tests/cross_zone_verified.rs index 59e3cce50..577239d05 100644 --- a/integration_tests/tests/cross_zone_verified.rs +++ b/integration_tests/tests/cross_zone_verified.rs @@ -21,7 +21,9 @@ use integration_tests::{ }; use lee::{AccountId, PublicTransaction, public_transaction::Message}; use lee_core::program::ProgramId; -use ping_core::{ReceiverInstruction, SenderInstruction, ping_record_pda}; +use ping_core::{ + ReceiverInstruction, SenderInstruction, ping_record_pda, sender_config_account_id, +}; use sequencer_core::config::{CrossZoneConfig, CrossZonePeer, CrossZoneRoute}; use sequencer_service_rpc::RpcClient as _; use tokio::test; @@ -109,7 +111,6 @@ fn build_ping_tx(target_zone: [u8; 32], receiver_id: ProgramId) -> LeeTransactio let payload: Vec = words.iter().flat_map(|word| word.to_le_bytes()).collect(); let send = SenderInstruction::Send { - outbox_program_id: outbox_id, target_zone, target_program_id: receiver_id, target_accounts: vec![ping_record_pda(receiver_id).into_value()], @@ -117,15 +118,11 @@ fn build_ping_tx(target_zone: [u8; 32], receiver_id: ProgramId) -> LeeTransactio ordinal, }; - let outbox_account = outbox_pda( - outbox_id, - programs::ping_sender().id(), - &target_zone, - ordinal, - ); + let sender_id = programs::ping_sender().id(); + let outbox_account = outbox_pda(outbox_id, sender_id, &target_zone, ordinal); let message = Message::try_new( - programs::ping_sender().id(), - vec![outbox_account], + sender_id, + vec![sender_config_account_id(sender_id), outbox_account], vec![], send, ) diff --git a/integration_tests/tests/cross_zone_watcher_restart.rs b/integration_tests/tests/cross_zone_watcher_restart.rs index 73028f56f..f91d59c97 100644 --- a/integration_tests/tests/cross_zone_watcher_restart.rs +++ b/integration_tests/tests/cross_zone_watcher_restart.rs @@ -25,7 +25,9 @@ use integration_tests::{ }; use lee::{AccountId, PublicTransaction, public_transaction::Message}; use lee_core::program::ProgramId; -use ping_core::{ReceiverInstruction, SenderInstruction, ping_record_pda}; +use ping_core::{ + ReceiverInstruction, SenderInstruction, ping_record_pda, sender_config_account_id, +}; use sequencer_core::config::{CrossZoneConfig, CrossZonePeer, CrossZoneRoute}; use sequencer_service_rpc::{RpcClient as _, SequencerClient}; use tokio::test; @@ -190,7 +192,6 @@ fn build_ping_tx(target_zone: [u8; 32], receiver_id: ProgramId) -> LeeTransactio let payload: Vec = words.iter().flat_map(|word| word.to_le_bytes()).collect(); let send = SenderInstruction::Send { - outbox_program_id: outbox_id, target_zone, target_program_id: receiver_id, target_accounts: vec![ping_record_pda(receiver_id).into_value()], @@ -198,15 +199,11 @@ fn build_ping_tx(target_zone: [u8; 32], receiver_id: ProgramId) -> LeeTransactio ordinal, }; - let outbox_account = outbox_pda( - outbox_id, - programs::ping_sender().id(), - &target_zone, - ordinal, - ); + let sender_id = programs::ping_sender().id(); + let outbox_account = outbox_pda(outbox_id, sender_id, &target_zone, ordinal); let message = Message::try_new( - programs::ping_sender().id(), - vec![outbox_account], + sender_id, + vec![sender_config_account_id(sender_id), outbox_account], vec![], send, ) diff --git a/lez/cross_zone/src/lib.rs b/lez/cross_zone/src/lib.rs index 7ea3fdf0f..d0eafd6fd 100644 --- a/lez/cross_zone/src/lib.rs +++ b/lez/cross_zone/src/lib.rs @@ -64,13 +64,18 @@ pub fn is_sequencer_only_program(program_id: ProgramId) -> bool { #[must_use] pub fn extract_emission(program_id: ProgramId, instruction_data: &[u32]) -> Option { if program_id == programs::ping_sender().id() { - let ping_core::SenderInstruction::Send { + // Not every transaction to an emitter emits: `InitConfig` is one of its + // instructions, so a non-`Send` decode is an ordinary non-emitting tx. + let Ok(ping_core::SenderInstruction::Send { target_zone, target_program_id, target_accounts, payload, .. - } = risc0_zkvm::serde::from_slice(instruction_data).ok()?; + }) = risc0_zkvm::serde::from_slice(instruction_data) + else { + return None; + }; Some(Emission { target_zone, target_program_id, @@ -215,6 +220,20 @@ pub fn build_wrapped_token_init_config_tx() -> lee::PublicTransaction { ) } +/// The genesis transaction that pins the outbox `ping_sender` chains into, +/// without importing the outbox id into the guest. +#[must_use] +pub fn build_ping_sender_init_config_tx() -> lee::PublicTransaction { + let ping_sender_id = programs::ping_sender().id(); + genesis_public_tx( + ping_sender_id, + vec![ping_core::sender_config_account_id(ping_sender_id)], + ping_core::SenderInstruction::InitConfig { + outbox_program_id: programs::cross_zone_outbox().id(), + }, + ) +} + /// Builds an unsigned, sequencer-origin genesis transaction invoking `instruction` /// on `program_id` over `account_ids`. fn genesis_public_tx( diff --git a/lez/indexer/core/src/cross_zone_verifier.rs b/lez/indexer/core/src/cross_zone_verifier.rs index 1cc48ee17..03d742a55 100644 --- a/lez/indexer/core/src/cross_zone_verifier.rs +++ b/lez/indexer/core/src/cross_zone_verifier.rs @@ -746,7 +746,6 @@ mod tests { fn emission(payload: &[u8]) -> 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_accounts: vec![ping_record_pda(receiver_id).into_value()], diff --git a/lez/programs/ping_core/Cargo.toml b/lez/programs/ping_core/Cargo.toml index 29870630f..93b0e430d 100644 --- a/lez/programs/ping_core/Cargo.toml +++ b/lez/programs/ping_core/Cargo.toml @@ -10,3 +10,6 @@ workspace = true [dependencies] lee_core.workspace = true serde = { workspace = true, features = ["alloc"] } + +[dev-dependencies] +risc0-zkvm.workspace = true diff --git a/lez/programs/ping_core/src/lib.rs b/lez/programs/ping_core/src/lib.rs index 80b272479..cc2214bbb 100644 --- a/lez/programs/ping_core/src/lib.rs +++ b/lez/programs/ping_core/src/lib.rs @@ -5,6 +5,7 @@ use lee_core::{ use serde::{Deserialize, Serialize}; const PING_RECORD_SEED: [u8; 32] = *b"/LEZ/v0.3/PingRecord/0000000000/"; +const SENDER_CONFIG_SEED: [u8; 32] = *b"/LEZ/v0.3/PingSenderCfg/0000000/"; /// Instruction delivered to `ping_receiver` by the inbox: record the payload. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -12,17 +13,29 @@ pub enum ReceiverInstruction { Record { payload: Vec }, } -/// Instruction to `ping_sender`: forwarded verbatim into `cross_zone_outbox::Instruction::Emit`. +/// Instruction to `ping_sender`. `Send`'s emission fields are forwarded verbatim +/// into `cross_zone_outbox::Instruction::Emit`. +/// +/// Variants are append-only. risc0 serde encodes the variant as a bare leading +/// tag word, so inserting one ahead of `Send` shifts every existing encoding. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum SenderInstruction { + /// Emit a cross-zone message through the pinned outbox. + /// + /// Required accounts (2): the sender config PDA, then the outbox PDA. Send { - outbox_program_id: ProgramId, target_zone: [u8; 32], target_program_id: ProgramId, target_accounts: Vec<[u8; 32]>, payload: Vec, ordinal: u32, }, + /// Pins the outbox program, written once into a default config PDA at + /// genesis. A re-run naming a different outbox is refused; an identical one + /// is a no-op, which is what genesis replay does. + /// + /// Required accounts (1): the sender config PDA. + InitConfig { outbox_program_id: ProgramId }, } /// The account a `ping_receiver` records the latest delivered payload into. @@ -36,3 +49,65 @@ pub fn ping_record_pda(receiver_id: ProgramId) -> AccountId { pub const fn ping_record_seed() -> PdaSeed { PdaSeed::new(PING_RECORD_SEED) } + +/// PDA holding the outbox program id, seeded at genesis so the guest can pin the +/// program it chains into without importing the outbox image id. +#[must_use] +pub fn sender_config_account_id(sender_id: ProgramId) -> AccountId { + AccountId::for_public_pda(&sender_id, &sender_config_seed()) +} + +#[must_use] +pub const fn sender_config_seed() -> PdaSeed { + PdaSeed::new(SENDER_CONFIG_SEED) +} + +/// Encodes the pinned outbox program id for the config account's data. +#[must_use] +pub fn outbox_bytes(outbox_program_id: ProgramId) -> [u8; 32] { + let mut bytes = [0_u8; 32]; + for (word, chunk) in outbox_program_id.iter().zip(bytes.chunks_exact_mut(4)) { + chunk.copy_from_slice(&word.to_le_bytes()); + } + bytes +} + +/// Decodes the pinned outbox program id from the config account's data. +#[must_use] +pub fn read_outbox(data: &[u8]) -> Option { + if data.len() < 32 { + return None; + } + let mut outbox_program_id = [0_u32; 8]; + for (word, chunk) in outbox_program_id.iter_mut().zip(data[..32].chunks_exact(4)) { + *word = u32::from_le_bytes(chunk.try_into().unwrap_or_else(|_| unreachable!())); + } + Some(outbox_program_id) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// `extract_emission` decodes `Send` off peer transactions, so its tag word is + /// wire format: a variant inserted ahead of it would silently shift every + /// existing encoding. + #[test] + fn send_is_the_first_variant() { + let send = SenderInstruction::Send { + target_zone: [7; 32], + target_program_id: [1; 8], + target_accounts: vec![], + payload: vec![], + ordinal: 0, + }; + let words = risc0_zkvm::serde::to_vec(&send).expect("Send serializes"); + assert_eq!(words[0], 0); + } + + #[test] + fn outbox_id_round_trips() { + let outbox: ProgramId = [9; 8]; + assert_eq!(read_outbox(&outbox_bytes(outbox)), Some(outbox)); + } +} diff --git a/lez/programs/ping_sender/src/main.rs b/lez/programs/ping_sender/src/main.rs index d0ad04f56..7ae0ea038 100644 --- a/lez/programs/ping_sender/src/main.rs +++ b/lez/programs/ping_sender/src/main.rs @@ -1,9 +1,14 @@ use cross_zone_outbox_core::Instruction as OutboxInstruction; use lee_core::{ - account::AccountWithMetadata, - program::{AccountPostState, ChainedCall, ProgramInput, ProgramOutput, read_lee_inputs}, + account::{Account, AccountWithMetadata}, + program::{ + AccountPostState, ChainedCall, Claim, ProgramId, ProgramInput, ProgramOutput, + read_lee_inputs, + }, +}; +use ping_core::{ + SenderInstruction, outbox_bytes, read_outbox, sender_config_account_id, sender_config_seed, }; -use ping_core::SenderInstruction; fn main() { let ( @@ -21,19 +26,63 @@ fn main() { "ping_sender is only invoked as a top-level user transaction" ); - let SenderInstruction::Send { - outbox_program_id, - target_zone, - target_program_id, - target_accounts, - payload, - ordinal, - } = instruction; + match instruction { + SenderInstruction::Send { + target_zone, + target_program_id, + target_accounts, + payload, + ordinal, + } => send( + self_program_id, + caller_program_id, + pre_states, + instruction_words, + target_zone, + target_program_id, + target_accounts, + payload, + ordinal, + ), + SenderInstruction::InitConfig { outbox_program_id } => init_config( + self_program_id, + caller_program_id, + pre_states, + instruction_words, + outbox_program_id, + ), + } +} - // The single account is the outbox PDA the chained call writes into; the - // outbox claims it, so ping_sender forwards it unchanged. - let [outbox] = - <[AccountWithMetadata; 1]>::try_from(pre_states).expect("Send requires exactly 1 account"); +#[expect( + clippy::too_many_arguments, + reason = "the emission fields are passed through verbatim" +)] +fn send( + self_program_id: ProgramId, + caller_program_id: Option, + pre_states: Vec, + instruction_words: Vec, + target_zone: [u8; 32], + target_program_id: ProgramId, + target_accounts: Vec<[u8; 32]>, + payload: Vec, + ordinal: u32, +) { + // pre_states: [config PDA, outbox PDA]. The outbox claims its own slot, so + // ping_sender forwards it unchanged. + let [config, outbox] = <[AccountWithMetadata; 2]>::try_from(pre_states) + .expect("Send requires the config and outbox accounts"); + + // Pinned rather than caller-named: chaining elsewhere would let an emission + // skip the real outbox and leave no record of itself. + assert_eq!( + config.account_id, + sender_config_account_id(self_program_id), + "first account must be the ping-sender config PDA" + ); + let outbox_program_id = read_outbox(&config.account.data.clone().into_inner()) + .expect("config account holds an outbox program id"); let call = ChainedCall::new( outbox_program_id, @@ -47,13 +96,65 @@ fn main() { }, ); + let config_post = AccountPostState::new(config.account.clone()); + ProgramOutput::new( self_program_id, caller_program_id, instruction_words, - vec![outbox.clone()], - vec![AccountPostState::new(outbox.account)], + vec![config, outbox.clone()], + vec![config_post, AccountPostState::new(outbox.account)], ) .with_chained_calls(vec![call]) .write(); } + +/// Writes the outbox program id into the config PDA exactly once at genesis. +fn init_config( + self_program_id: ProgramId, + caller_program_id: Option, + pre_states: Vec, + instruction_words: Vec, + outbox_program_id: ProgramId, +) { + // pre_states: [config PDA]. + let [config] = <[AccountWithMetadata; 1]>::try_from(pre_states) + .expect("InitConfig requires the config account"); + assert_eq!( + config.account_id, + sender_config_account_id(self_program_id), + "account must be the ping-sender config PDA" + ); + // Init-once, idempotent under genesis replay: a `default` config is a first + // init; an already-owned one must already pin exactly this outbox, since + // genesis is replayed onto seeded state during multi-sequencer reconstruction. + // `new_claimed_if_default` alone would not stop a later self-owned rewrite. + if config.account != Account::default() { + assert_eq!( + config.account.program_owner, self_program_id, + "ping-sender config PDA is owned by another program" + ); + assert_eq!( + config.account.data.clone().into_inner(), + outbox_bytes(outbox_program_id).to_vec(), + "ping-sender config already pins a different outbox" + ); + } + + let mut config_account = config.account.clone(); + config_account.data = outbox_bytes(outbox_program_id) + .to_vec() + .try_into() + .expect("outbox id fits in account data"); + let config_post = + AccountPostState::new_claimed_if_default(config_account, Claim::Pda(sender_config_seed())); + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + vec![config], + vec![config_post], + ) + .write(); +} diff --git a/lez/sequencer/core/src/cross_zone_watcher.rs b/lez/sequencer/core/src/cross_zone_watcher.rs index 0ddc4b6d3..96c254fc2 100644 --- a/lez/sequencer/core/src/cross_zone_watcher.rs +++ b/lez/sequencer/core/src/cross_zone_watcher.rs @@ -707,7 +707,6 @@ mod tests { 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, target_accounts: vec![ping_record_pda(receiver_id).into_value()], diff --git a/lez/sequencer/core/src/lib.rs b/lez/sequencer/core/src/lib.rs index b9269e980..7cf5b260c 100644 --- a/lez/sequencer/core/src/lib.rs +++ b/lez/sequencer/core/src/lib.rs @@ -1522,13 +1522,15 @@ fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec (lee::V03State, Vec Option { fn decode_send_ordinal(instruction_data: &[u32]) -> Option { let instruction: SenderInstruction = risc0_zkvm::serde::from_slice::(instruction_data).ok()?; - let SenderInstruction::Send { ordinal, .. } = instruction; + let SenderInstruction::Send { ordinal, .. } = instruction else { + return None; + }; Some(ordinal) } @@ -554,7 +558,6 @@ fn build_send_tx(other_zone: ZoneId, ordinal: u32, text: &str) -> LeeTransaction let payload: Vec = words.iter().flat_map(|word| word.to_le_bytes()).collect(); let send = SenderInstruction::Send { - outbox_program_id: outbox_id, target_zone: other_zone, target_program_id: receiver_id, target_accounts: vec![ping_record_pda(receiver_id).into_value()], @@ -562,15 +565,11 @@ fn build_send_tx(other_zone: ZoneId, ordinal: u32, text: &str) -> LeeTransaction ordinal, }; - let outbox_account = outbox_pda( - outbox_id, - programs::ping_sender().id(), - &other_zone, - ordinal, - ); + let sender_id = programs::ping_sender().id(); + let outbox_account = outbox_pda(outbox_id, sender_id, &other_zone, ordinal); let message = Message::try_new( - programs::ping_sender().id(), - vec![outbox_account], + sender_id, + vec![sender_config_account_id(sender_id), outbox_account], vec![], send, )