diff --git a/lez/cross_zone/src/lib.rs b/lez/cross_zone/src/lib.rs index a10b3b0f..0a5993e6 100644 --- a/lez/cross_zone/src/lib.rs +++ b/lez/cross_zone/src/lib.rs @@ -20,6 +20,7 @@ use lee_core::{ account::{Account, AccountId, Balance}, program::ProgramId, }; +use serde::Serialize; /// The cross-zone emission fields a watcher or verifier reads off a source /// transaction, common to every emitter program. @@ -142,38 +143,35 @@ pub fn build_dispatch_from_emission( build_inbox_dispatch_tx(programs::cross_zone_inbox().id(), &msg, target_ids) } -/// Builds the inbox config account a zone seeds into genesis state. -/// -/// Lets the inbox guest authorize inbound peer messages. The sequencer and -/// indexer seed the same account from the same config, keeping their replayed -/// state consistent. -#[must_use] -pub fn build_inbox_config_account( - self_zone: ZoneId, - cross_zone: &CrossZoneConfig, -) -> (AccountId, Account) { - let inbox_id = programs::cross_zone_inbox().id(); - +/// The inbox config a zone derives from its cross-zone config: the per-peer target +/// allowlists plus its own zone id. +fn inbox_config(self_zone: ZoneId, cross_zone: &CrossZoneConfig) -> InboxConfig { let mut allowed_targets = BTreeMap::new(); for peer in &cross_zone.peers { allowed_targets.insert(peer.channel_id, peer.allowed_targets.clone()); } - let config = InboxConfig { + InboxConfig { self_zone, allowed_peers: BTreeMap::new(), allowed_targets, - }; + } +} - let account = Account { - program_owner: inbox_id, - balance: 0, - data: config - .to_bytes() - .try_into() - .expect("inbox config fits in account data"), - nonce: 0_u128.into(), - }; - (inbox_config_account_id(inbox_id), account) +/// 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. +#[must_use] +pub fn build_inbox_init_config_tx( + self_zone: ZoneId, + cross_zone: &CrossZoneConfig, +) -> 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)), + ) } /// Builds the genesis holding account funding a holder's bridgeable balance. @@ -194,34 +192,32 @@ pub fn build_holding_account(holder: AccountId, amount: Balance) -> (AccountId, (holder, account) } -/// The wrapped-token config account, pinning the cross-zone inbox as the -/// authorized minter without importing the inbox id into the guest. Fixed for -/// every zone, part of the cross-zone genesis setup. -fn wrapped_token_config_account() -> (AccountId, Account) { +/// The genesis transaction that pins the cross-zone inbox as the wrapped-token +/// minter, without importing the inbox id into the guest. +#[must_use] +pub fn build_wrapped_token_init_config_tx() -> lee::PublicTransaction { let wrapped_token_id = programs::wrapped_token().id(); - ( - wrapped_token_core::config_account_id(wrapped_token_id), - Account { - program_owner: wrapped_token_id, - data: wrapped_token_core::minter_bytes(programs::cross_zone_inbox().id()) - .to_vec() - .try_into() - .expect("minter id fits in account data"), - ..Default::default() + genesis_public_tx( + wrapped_token_id, + vec![wrapped_token_core::config_account_id(wrapped_token_id)], + wrapped_token_core::Instruction::InitConfig { + minter: programs::cross_zone_inbox().id(), }, ) } -/// The receiving-side cross-zone genesis accounts a zone seeds. -/// -/// The wrapped-token config and this zone's inbox config, for a zone that -/// receives (one with `cross_zone` config). Bridge-lock holdings are seeded -/// separately via [`build_holding_account`], since they belong to the locking -/// (source) side and must not depend on receiving config. -#[must_use] -pub fn genesis_accounts(self_zone: ZoneId, config: &CrossZoneConfig) -> Vec<(AccountId, Account)> { - vec![ - wrapped_token_config_account(), - build_inbox_config_account(self_zone, config), - ] +/// Builds an unsigned, sequencer-origin genesis transaction invoking `instruction` +/// on `program_id` over `account_ids`. +fn genesis_public_tx( + program_id: ProgramId, + account_ids: Vec, + instruction: I, +) -> lee::PublicTransaction { + let message = + lee::public_transaction::Message::try_new(program_id, account_ids, vec![], instruction) + .expect("genesis instruction must serialize"); + lee::PublicTransaction::new( + message, + lee::public_transaction::WitnessSet::from_raw_parts(vec![]), + ) } diff --git a/lez/indexer/core/src/cross_zone_verifier.rs b/lez/indexer/core/src/cross_zone_verifier.rs index f7a76d00..5d95df4e 100644 --- a/lez/indexer/core/src/cross_zone_verifier.rs +++ b/lez/indexer/core/src/cross_zone_verifier.rs @@ -186,7 +186,9 @@ impl CrossZoneVerifier { &public_tx.message().instruction_data, ) { Ok(InboxInstruction::Dispatch(msg)) => Some(msg), - Err(_) => None, + // Only a dispatch carries a cross-zone message to re-derive; a genesis + // `InitConfig` is not verifier-relevant. + Ok(InboxInstruction::InitConfig(_)) | Err(_) => None, } } diff --git a/lez/indexer/core/src/lib.rs b/lez/indexer/core/src/lib.rs index 130d155b..3df23e08 100644 --- a/lez/indexer/core/src/lib.rs +++ b/lez/indexer/core/src/lib.rs @@ -93,18 +93,15 @@ impl IndexerCore { ); let zone_indexer = ZoneIndexer::new(config.channel_id, node.clone()); - // Cross-zone programs are base builtins, so none are seeded here. - // Bridge-lock holdings (source side) always; inbox + wrapped-token config only when - // cross_zone is set. - let mut genesis_accounts: Vec<_> = config + // Cross-zone programs are base builtins, and their config accounts are + // reconstructed by replaying the genesis block's InitConfig transactions; + // neither is seeded here. Only bridge-lock holdings (source side), not + // produced by any transaction, are still seeded directly. + let genesis_accounts: Vec<_> = config .bridge_lock_holdings .iter() .map(|holding| cross_zone::build_holding_account(holding.holder, holding.amount)) .collect(); - if let Some(cross_zone) = config.cross_zone.as_ref() { - let self_zone: [u8; 32] = *config.channel_id.as_ref(); - genesis_accounts.extend(cross_zone::genesis_accounts(self_zone, cross_zone)); - } // Option B verifier: re-derives each cross-zone dispatch from the peer's // finalized blocks. `None` when cross-zone messaging is disabled. diff --git a/lez/programs/bridge_lock/src/main.rs b/lez/programs/bridge_lock/src/main.rs index aa8b5f93..53dd1077 100644 --- a/lez/programs/bridge_lock/src/main.rs +++ b/lez/programs/bridge_lock/src/main.rs @@ -36,7 +36,10 @@ fn main() { let WrappedInstruction::Mint { amount: mint_amount, .. - } = decode_mint(&payload); + } = decode_mint(&payload) + else { + panic!("bridge_lock payload must be a wrapped-token mint"); + }; assert_eq!( mint_amount, amount, "locked amount must equal the wrapped mint amount" diff --git a/lez/programs/cross_zone_inbox/core/src/lib.rs b/lez/programs/cross_zone_inbox/core/src/lib.rs index 761a8a1c..4323db6b 100644 --- a/lez/programs/cross_zone_inbox/core/src/lib.rs +++ b/lez/programs/cross_zone_inbox/core/src/lib.rs @@ -116,6 +116,10 @@ impl SeenShard { pub enum Instruction { /// Delivers a finalized peer message to its target program. Dispatch(CrossZoneMessage), + /// Initializes the inbox config account at genesis. Written once, into a + /// default (unclaimed) config PDA; the guest refuses a non-default pre-state, + /// so it cannot be re-run to overwrite the allowlists. + InitConfig(InboxConfig), } /// Content-addressed replay key for a delivered message. @@ -142,7 +146,14 @@ pub fn message_key(src_zone: &ZoneId, src_block_id: u64, src_tx_index: u32) -> M /// 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)) + AccountId::for_public_pda(&inbox_id, &inbox_config_seed()) +} + +/// Seed of the config PDA, exposed so the guest can claim the account when it +/// initializes the config at genesis. +#[must_use] +pub const fn inbox_config_seed() -> PdaSeed { + PdaSeed::new(INBOX_CONFIG_SEED) } /// The seen-set shard for the `(src_zone, epoch)` the message falls in. diff --git a/lez/programs/cross_zone_inbox/src/main.rs b/lez/programs/cross_zone_inbox/src/main.rs index 8bb06ed8..480a7274 100644 --- a/lez/programs/cross_zone_inbox/src/main.rs +++ b/lez/programs/cross_zone_inbox/src/main.rs @@ -1,10 +1,13 @@ use cross_zone_inbox_core::{ - InboxConfig, Instruction, SeenShard, inbox_config_account_id, inbox_seen_shard_account_id, - inbox_seen_shard_seed, message_key, + CrossZoneMessage, InboxConfig, Instruction, SeenShard, inbox_config_account_id, + inbox_config_seed, 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}, + account::{Account, AccountWithMetadata}, + program::{ + AccountPostState, ChainedCall, Claim, ProgramId, ProgramInput, ProgramOutput, + read_lee_inputs, + }, }; fn unchanged(pre: &AccountWithMetadata) -> AccountPostState { @@ -27,8 +30,32 @@ fn main() { "Inbox is only invoked as a top-level sequencer-origin transaction" ); - let Instruction::Dispatch(msg) = instruction; + match instruction { + Instruction::Dispatch(msg) => dispatch( + self_program_id, + caller_program_id, + pre_states, + instruction_words, + &msg, + ), + Instruction::InitConfig(config) => init_config( + self_program_id, + caller_program_id, + pre_states, + instruction_words, + &config, + ), + } +} +/// Delivers a finalized peer message to its target program, no-op on replay. +fn dispatch( + self_program_id: ProgramId, + caller_program_id: Option, + pre_states: Vec, + instruction_words: Vec, + msg: &CrossZoneMessage, +) { assert!( msg.l1_inclusion_witness.is_none(), "l1_inclusion_witness must be None in v1" @@ -123,3 +150,47 @@ fn main() { .with_chained_calls(chained_calls) .write(); } + +/// Writes the inbox config (peer + target allowlists) 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, + config: &InboxConfig, +) { + // pre_states: [config PDA]. + let [config_meta] = <[AccountWithMetadata; 1]>::try_from(pre_states) + .expect("InitConfig requires the config account"); + assert_eq!( + config_meta.account_id, + inbox_config_account_id(self_program_id), + "account must be the inbox config PDA" + ); + // Init-once: a default pre-state cannot be re-run to overwrite the allowlists, + // and `new_claimed_if_default` alone would not stop the owning program from + // rewriting its own config data on a later call. + assert_eq!( + config_meta.account, + Account::default(), + "inbox config already initialized" + ); + + let mut config_account = config_meta.account.clone(); + config_account.data = config + .to_bytes() + .try_into() + .expect("inbox config fits in account data"); + let config_post = + AccountPostState::new_claimed_if_default(config_account, Claim::Pda(inbox_config_seed())); + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + vec![config_meta], + vec![config_post], + ) + .write(); +} diff --git a/lez/programs/wrapped_token/core/src/lib.rs b/lez/programs/wrapped_token/core/src/lib.rs index 8e13567a..a95a5ca0 100644 --- a/lez/programs/wrapped_token/core/src/lib.rs +++ b/lez/programs/wrapped_token/core/src/lib.rs @@ -19,6 +19,12 @@ pub enum Instruction { /// Required accounts (2): the wrapped-token config PDA, then the recipient's /// holding PDA. Mint { recipient: [u8; 32], amount: u128 }, + /// Pins `minter` (the cross-zone inbox) as the authorized minter, written once + /// into a default config PDA at genesis. The guest refuses a non-default + /// pre-state, so it cannot be re-run to hijack the minter. + /// + /// Required accounts (1): the wrapped-token config PDA. + InitConfig { minter: ProgramId }, } /// PDA holding the authorized minter program id (the cross-zone inbox), seeded at diff --git a/lez/programs/wrapped_token/src/main.rs b/lez/programs/wrapped_token/src/main.rs index d0e7595f..12525756 100644 --- a/lez/programs/wrapped_token/src/main.rs +++ b/lez/programs/wrapped_token/src/main.rs @@ -1,10 +1,10 @@ use lee_core::{ - account::AccountWithMetadata, + account::{Account, AccountWithMetadata}, program::{AccountPostState, Claim, ProgramInput, ProgramOutput, read_lee_inputs}, }; use wrapped_token_core::{ - Instruction, balance_bytes, config_account_id, holding_account_id, holding_seed, read_balance, - read_minter, + Instruction, balance_bytes, config_account_id, config_seed, holding_account_id, holding_seed, + minter_bytes, read_balance, read_minter, }; fn main() { @@ -18,8 +18,33 @@ fn main() { instruction_words, ) = read_lee_inputs::(); - let Instruction::Mint { recipient, amount } = instruction; + match instruction { + Instruction::Mint { recipient, amount } => mint( + self_program_id, + caller_program_id, + pre_states, + instruction_words, + recipient, + amount, + ), + Instruction::InitConfig { minter } => init_config( + self_program_id, + caller_program_id, + pre_states, + instruction_words, + minter, + ), + } +} +fn mint( + self_program_id: lee_core::program::ProgramId, + caller_program_id: Option, + pre_states: Vec, + instruction_words: Vec, + recipient: [u8; 32], + amount: u128, +) { // pre_states: [config PDA, recipient holding PDA]. let [config, holding] = <[AccountWithMetadata; 2]>::try_from(pre_states) .expect("Mint requires the config and recipient holding accounts"); @@ -68,3 +93,51 @@ fn main() { ) .write(); } + +/// Writes the authorized minter into the config PDA exactly once at genesis. +fn init_config( + self_program_id: lee_core::program::ProgramId, + caller_program_id: Option, + pre_states: Vec, + instruction_words: Vec, + minter: lee_core::program::ProgramId, +) { + assert!( + caller_program_id.is_none(), + "InitConfig is a top-level genesis transaction" + ); + + // pre_states: [config PDA]. + let [config] = <[AccountWithMetadata; 1]>::try_from(pre_states) + .expect("InitConfig requires the config account"); + assert_eq!( + config.account_id, + config_account_id(self_program_id), + "account must be the wrapped-token config PDA" + ); + // Init-once: a default pre-state cannot be re-run to overwrite the minter, and + // `new_claimed_if_default` alone would not stop the owning program from + // rewriting its own config data on a later call. + assert_eq!( + config.account, + Account::default(), + "wrapped-token config already initialized" + ); + + let mut config_account = config.account.clone(); + config_account.data = minter_bytes(minter) + .to_vec() + .try_into() + .expect("minter id fits in account data"); + let config_post = + AccountPostState::new_claimed_if_default(config_account, Claim::Pda(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/lib.rs b/lez/sequencer/core/src/lib.rs index b12ce8c0..43667528 100644 --- a/lez/sequencer/core/src/lib.rs +++ b/lez/sequencer/core/src/lib.rs @@ -637,19 +637,12 @@ fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec = bridge_lock_holdings(&config.genesis) .into_iter() .map(|(holder, amount)| cross_zone::build_holding_account(holder, amount)) .collect(); - state = state.with_public_accounts(holdings); - // Receiving-side accounts (inbox + wrapped-token config), only when cross_zone is set. - if let Some(cross_zone) = &config.cross_zone { - let self_zone = *config.bedrock_config.channel_id.as_ref(); - state = state.with_public_accounts(cross_zone::genesis_accounts(self_zone, cross_zone)); - } + let mut state = base.with_public_accounts(holdings); // Fingerprint the directly-seeded state, before genesis txs, so it matches the indexer's. info!( @@ -657,22 +650,37 @@ fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec Some(build_supply_account_genesis_transaction( - account_id, *balance, - )), - GenesisAction::SupplyBridgeAccount { balance } => { - Some(build_supply_bridge_account_genesis_transaction(*balance)) - } - // Seeded directly (accounts via `cross_zone::genesis_accounts`), not by a genesis tx. - GenesisAction::SupplyBridgeLockHolding { .. } => None, - }) + // Config txs seed the config accounts by transaction, so every node + // reconstructs them by replaying the genesis block. The wrapped-token minter is + // initialized on every zone (wrapped_token is a builtin), since its InitConfig + // is user-callable and a config PDA left default would be claimable by anyone as + // the first initializer (a minter hijack). The inbox allowlist is initialized + // only on receiving zones; the inbox is sequencer-only, so its default config + // PDA is not user-claimable, merely unused until the zone receives. + let wrapped_token_config_tx = std::iter::once(cross_zone::build_wrapped_token_init_config_tx()); + let inbox_config_tx = config.cross_zone.as_ref().map(|cross_zone| { + let self_zone = *config.bedrock_config.channel_id.as_ref(); + cross_zone::build_inbox_init_config_tx(self_zone, cross_zone) + }); + let supply_txs = config.genesis.iter().filter_map(|action| match action { + GenesisAction::SupplyAccount { + account_id, + balance, + } => Some(build_supply_account_genesis_transaction( + account_id, *balance, + )), + GenesisAction::SupplyBridgeAccount { balance } => { + Some(build_supply_bridge_account_genesis_transaction(*balance)) + } + // Seeded directly (holdings via `build_holding_account`), not by a genesis tx. + GenesisAction::SupplyBridgeLockHolding { .. } => None, + }); + + // Applied in order with the mandatory clock last, then committed to the genesis + // block so external observers can replay them. Every genesis tx is public. + let genesis_txs = wrapped_token_config_tx + .chain(inbox_config_tx) + .chain(supply_txs) .chain(std::iter::once(clock_invocation(0))) .inspect(|tx| { state diff --git a/tools/cross_zone_chat/src/main.rs b/tools/cross_zone_chat/src/main.rs index 4347cc93..26ce7b3a 100644 --- a/tools/cross_zone_chat/src/main.rs +++ b/tools/cross_zone_chat/src/main.rs @@ -289,24 +289,14 @@ async fn main() -> Result<()> { let cross_zone_b = watch_peer(zone_a, receiver_id); let partial = SequencerPartialConfig::default(); - let (seq_a, _home_a) = setup_sequencer( - partial, - bedrock_addr, - vec![], - channel_a, - Some(cross_zone_a), - ) - .await - .context("Failed to set up zone A sequencer")?; - let (seq_b, _home_b) = setup_sequencer( - partial, - bedrock_addr, - vec![], - channel_b, - Some(cross_zone_b), - ) - .await - .context("Failed to set up zone B sequencer")?; + let (seq_a, _home_a) = + setup_sequencer(partial, bedrock_addr, vec![], channel_a, Some(cross_zone_a)) + .await + .context("Failed to set up zone A sequencer")?; + let (seq_b, _home_b) = + setup_sequencer(partial, bedrock_addr, vec![], channel_b, Some(cross_zone_b)) + .await + .context("Failed to set up zone B sequencer")?; let state = Arc::new(AppState { zone_a: ZoneRuntime { @@ -446,7 +436,9 @@ async fn poll_finality(state: Arc) { fn decode_inbox_text(instruction_data: &[u32]) -> Option { let instruction: Instruction = risc0_zkvm::serde::from_slice::(instruction_data).ok()?; - let Instruction::Dispatch(message) = instruction; + let Instruction::Dispatch(message) = instruction else { + return None; + }; decode_payload(&message.payload) }