diff --git a/Cargo.lock b/Cargo.lock index 7f7605515..06d7be1ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1225,6 +1225,7 @@ name = "bridge_lock_core" version = "0.1.0" dependencies = [ "lee_core", + "risc0-zkvm", "serde", ] @@ -2002,6 +2003,7 @@ dependencies = [ "log", "ping_core", "programs", + "rand 0.8.6", "risc0-zkvm", "sequencer_service_rpc", "serde", @@ -7746,7 +7748,9 @@ dependencies = [ name = "ping_core" version = "0.1.0" dependencies = [ + "borsh", "lee_core", + "risc0-zkvm", "serde", ] @@ -7754,6 +7758,7 @@ dependencies = [ name = "ping_receiver_program" version = "0.1.0" dependencies = [ + "cross_zone_inbox_core", "lee_core", "ping_core", ] @@ -12340,6 +12345,7 @@ dependencies = [ name = "wrapped_token_core" version = "0.1.0" dependencies = [ + "borsh", "lee_core", "risc0-zkvm", "serde", @@ -12349,6 +12355,7 @@ dependencies = [ name = "wrapped_token_program" version = "0.1.0" dependencies = [ + "cross_zone_inbox_core", "lee_core", "wrapped_token_core", ] diff --git a/artifacts/lez/programs/amm.bin b/artifacts/lez/programs/amm.bin index 412f58b80..2fdf142e7 100644 Binary files a/artifacts/lez/programs/amm.bin and b/artifacts/lez/programs/amm.bin differ diff --git a/artifacts/lez/programs/associated_token_account.bin b/artifacts/lez/programs/associated_token_account.bin index 98969806c..60edcbbdc 100644 Binary files a/artifacts/lez/programs/associated_token_account.bin and b/artifacts/lez/programs/associated_token_account.bin differ diff --git a/artifacts/lez/programs/authenticated_transfer.bin b/artifacts/lez/programs/authenticated_transfer.bin index b6ed736d7..216282765 100644 Binary files a/artifacts/lez/programs/authenticated_transfer.bin and b/artifacts/lez/programs/authenticated_transfer.bin differ diff --git a/artifacts/lez/programs/bridge.bin b/artifacts/lez/programs/bridge.bin index 9c2c3b91f..c1b8d9884 100644 Binary files a/artifacts/lez/programs/bridge.bin and b/artifacts/lez/programs/bridge.bin differ diff --git a/artifacts/lez/programs/bridge_lock.bin b/artifacts/lez/programs/bridge_lock.bin index 04626a0dd..ddadd4ed5 100644 Binary files a/artifacts/lez/programs/bridge_lock.bin and b/artifacts/lez/programs/bridge_lock.bin differ diff --git a/artifacts/lez/programs/clock.bin b/artifacts/lez/programs/clock.bin index 1e3bc48a4..c47419be3 100644 Binary files a/artifacts/lez/programs/clock.bin and b/artifacts/lez/programs/clock.bin differ diff --git a/artifacts/lez/programs/cross_zone_inbox.bin b/artifacts/lez/programs/cross_zone_inbox.bin index 2d69904d3..ec1da83fa 100644 Binary files a/artifacts/lez/programs/cross_zone_inbox.bin and b/artifacts/lez/programs/cross_zone_inbox.bin differ diff --git a/artifacts/lez/programs/cross_zone_outbox.bin b/artifacts/lez/programs/cross_zone_outbox.bin index e9ead7b36..ba3649f6b 100644 Binary files a/artifacts/lez/programs/cross_zone_outbox.bin and b/artifacts/lez/programs/cross_zone_outbox.bin differ diff --git a/artifacts/lez/programs/faucet.bin b/artifacts/lez/programs/faucet.bin index b87cd0818..399826ed6 100644 Binary files a/artifacts/lez/programs/faucet.bin and b/artifacts/lez/programs/faucet.bin differ diff --git a/artifacts/lez/programs/pinata.bin b/artifacts/lez/programs/pinata.bin index 4b5f9b6c8..febc6cd86 100644 Binary files a/artifacts/lez/programs/pinata.bin and b/artifacts/lez/programs/pinata.bin differ diff --git a/artifacts/lez/programs/pinata_token.bin b/artifacts/lez/programs/pinata_token.bin index 16bb21bef..d7a6844eb 100644 Binary files a/artifacts/lez/programs/pinata_token.bin and b/artifacts/lez/programs/pinata_token.bin differ diff --git a/artifacts/lez/programs/ping_receiver.bin b/artifacts/lez/programs/ping_receiver.bin index c5f5e3f38..dc2c06472 100644 Binary files a/artifacts/lez/programs/ping_receiver.bin and b/artifacts/lez/programs/ping_receiver.bin differ diff --git a/artifacts/lez/programs/ping_sender.bin b/artifacts/lez/programs/ping_sender.bin index d9b1fb66a..0ae3175bd 100644 Binary files a/artifacts/lez/programs/ping_sender.bin and b/artifacts/lez/programs/ping_sender.bin differ diff --git a/artifacts/lez/programs/token.bin b/artifacts/lez/programs/token.bin index baab01244..d91927d3e 100644 Binary files a/artifacts/lez/programs/token.bin and b/artifacts/lez/programs/token.bin differ diff --git a/artifacts/lez/programs/vault.bin b/artifacts/lez/programs/vault.bin index cf2e5d54e..5ce29680c 100644 Binary files a/artifacts/lez/programs/vault.bin and b/artifacts/lez/programs/vault.bin differ diff --git a/artifacts/lez/programs/wrapped_token.bin b/artifacts/lez/programs/wrapped_token.bin index 416cb2b5b..6633ec468 100644 Binary files a/artifacts/lez/programs/wrapped_token.bin and b/artifacts/lez/programs/wrapped_token.bin differ diff --git a/integration_tests/tests/cross_zone_bridge.rs b/integration_tests/tests/cross_zone_bridge.rs index 6e300672d..b9c8d41ae 100644 --- a/integration_tests/tests/cross_zone_bridge.rs +++ b/integration_tests/tests/cross_zone_bridge.rs @@ -9,12 +9,10 @@ //! wrapped token is minted to the recipient. Reuses the M3/M4 spine unchanged; //! only the source caller (`bridge_lock`) and target (`wrapped_token`) are new. //! -//! Not production-safe. The inbox allowlist gates the target program, not the -//! source emitter, and `extract_emission` recognizes any known emitter, so in a -//! zone that allows `wrapped_token` as a target a permissionless `ping_sender` -//! send can carry a `wrapped_token::Mint` and mint with no lock. Making this safe -//! needs source verification, where a value-bearing target checks the message -//! originated from `bridge_lock`; that is out of scope for the demo. +//! A `ping_sender` send carrying a `wrapped_token::Mint` is refused as long as no +//! operator writes a `(ping_sender, wrapped_token)` route: the allowlist is a +//! source-and-target pair. Nothing forbids writing that route, and the token +//! still trusts the table rather than checking its own sources, which is #673. use std::time::Duration; @@ -163,14 +161,14 @@ fn build_lock_tx( target_program_id: wrapped_token_id, target_accounts, payload, - outbox_program_id: outbox_id, ordinal, }; let accounts = vec![ + bridge_lock_core::config_account_id(bridge_lock_id), holder_id, bridge_lock_core::escrow_account_id(bridge_lock_id), - outbox_pda(outbox_id, &target_zone, ordinal), + outbox_pda(outbox_id, bridge_lock_id, &target_zone, ordinal), ]; // One nonce per signature: the holder signs, at its genesis nonce 0. let message = Message::try_new(bridge_lock_id, accounts, vec![0_u128.into()], lock) diff --git a/integration_tests/tests/cross_zone_ping.rs b/integration_tests/tests/cross_zone_ping.rs index 5fa32bae1..fb00410d2 100644 --- a/integration_tests/tests/cross_zone_ping.rs +++ b/integration_tests/tests/cross_zone_ping.rs @@ -19,7 +19,10 @@ use cross_zone_outbox_core::outbox_pda; use integration_tests::config::{self, SequencerPartialConfig}; 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, receiver_config_account_id, + sender_config_account_id, +}; use sequencer_core::config::{CrossZoneConfig, CrossZonePeer, CrossZoneRoute}; use sequencer_service_rpc::{RpcClient as _, SequencerClient}; use test_fixtures::{ @@ -119,18 +122,21 @@ 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()], + target_accounts: vec![ + receiver_config_account_id(receiver_id).into_value(), + ping_record_pda(receiver_id).into_value(), + ], payload, ordinal, }; - let outbox_account = outbox_pda(outbox_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 119d09cb2..47027eb2d 100644 --- a/integration_tests/tests/cross_zone_state_machine.rs +++ b/integration_tests/tests/cross_zone_state_machine.rs @@ -10,11 +10,9 @@ //! `outbox::Emit`). Fast, so they pin guest logic before the e2e exercises the //! plumbing. Run with `RISC0_DEV_MODE=1`. -use std::collections::BTreeMap; - use cross_zone_inbox_core::{ - CrossZoneMessage, CrossZoneRoute, InboxConfig, Instruction as InboxInstruction, SeenShard, - inbox_config_account_id, inbox_seen_shard_account_id, + CrossZoneMessage, InboxConfig, Instruction as InboxInstruction, SeenShard, + inbox_config_account_id, inbox_seen_shard_account_id, inbox_source_marker_account_id, }; use cross_zone_outbox_core::{OutboxRecord, outbox_pda}; use lee::{ @@ -22,7 +20,10 @@ 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, receiver_config_account_id, + sender_config_account_id, +}; const INITIAL_BALANCE: u128 = 100; const LOCK_AMOUNT: u128 = 30; @@ -35,33 +36,17 @@ fn base_state() -> V03State { V03State::new().with_programs([ programs::cross_zone_inbox(), programs::cross_zone_outbox(), + programs::ping_sender(), programs::ping_receiver(), programs::bridge_lock(), programs::wrapped_token(), ]) } -/// Seeds an inbox config (inbox-owned) allowing `src_zone -> target`. -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, -) { +/// Seeds the inbox config (inbox-owned), which is now just this zone's id. +fn seed_inbox_config(state: &mut V03State, self_zone: [u8; 32]) { let inbox_id = programs::cross_zone_inbox().id(); - 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_routes, - }; + let config = InboxConfig { self_zone }; *state = std::mem::replace(state, V03State::new()).with_public_accounts([( inbox_config_account_id(inbox_id), Account { @@ -76,23 +61,130 @@ fn seed_inbox_config( )]); } -/// Seeds the wrapped-token config account pinning the inbox as authorized minter, -/// matching what genesis seeds for a real zone. -fn seed_wrapped_config(state: &mut V03State) { +/// Seeds the wrapped-token config pinning the inbox as minter and `sources` as the +/// peer pairs it will mint for, matching what genesis seeds for a real zone. +fn seed_wrapped_config( + state: &mut V03State, + sources: Vec<([u8; 32], lee_core::program::ProgramId)>, +) { let wrapped_token_id = programs::wrapped_token().id(); + let config = wrapped_token_core::WrappedTokenConfig { + minter: programs::cross_zone_inbox().id(), + sources, + }; *state = std::mem::replace(state, V03State::new()).with_public_accounts([( 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() + data: config + .to_bytes() .try_into() - .expect("minter id fits in account data"), + .expect("wrapped-token config fits in account data"), ..Default::default() }, )]); } +/// Seeds the ping-receiver config pinning the inbox as deliverer and `sources` as +/// the peer pairs it accepts a delivery from. +fn seed_receiver_config( + state: &mut V03State, + sources: Vec<([u8; 32], lee_core::program::ProgramId)>, +) { + let receiver_id = programs::ping_receiver().id(); + let config = ping_core::ReceiverConfig { + deliverer: programs::cross_zone_inbox().id(), + sources, + }; + *state = std::mem::replace(state, V03State::new()).with_public_accounts([( + receiver_config_account_id(receiver_id), + Account { + program_owner: receiver_id, + data: config + .to_bytes() + .try_into() + .expect("receiver config fits in account data"), + ..Default::default() + }, + )]); +} + +/// 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() + }, + )]); +} + +/// Seeds the bridge-lock config account pinning the real outbox and the wrapped +/// token, matching what genesis seeds for a real zone. +fn seed_bridge_lock_config(state: &mut V03State) { + let bridge_lock_id = programs::bridge_lock().id(); + *state = std::mem::replace(state, V03State::new()).with_public_accounts([( + bridge_lock_core::config_account_id(bridge_lock_id), + Account { + program_owner: bridge_lock_id, + data: bridge_lock_core::config_bytes( + programs::cross_zone_outbox().id(), + programs::wrapped_token().id(), + ) + .to_vec() + .try_into() + .expect("pinned ids fit in account data"), + ..Default::default() + }, + )]); +} + +/// The account list a dispatch declares, mirroring `cross_zone::build_inbox_dispatch_tx`: +/// config, seen shard, source marker, then the target's own accounts. +fn dispatch_accounts( + inbox_id: lee_core::program::ProgramId, + msg: &CrossZoneMessage, + targets: Vec, +) -> Vec { + let mut ids = vec![ + inbox_config_account_id(inbox_id), + inbox_seen_shard_account_id(inbox_id, &msg.src_zone, msg.src_block_id), + inbox_source_marker_account_id(inbox_id, &msg.src_zone, msg.src_program_id), + ]; + ids.extend(targets); + ids +} + +/// 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![ + receiver_config_account_id(receiver_id).into_value(), + 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 { @@ -117,14 +209,8 @@ fn dispatch_mint(amount: u128) -> Result Result PublicTransaction { + let wrapped_token_id = programs::wrapped_token().id(); + lock_tx_to( + holder_key, + holder_id, + zone_b, + ordinal, + nonce, + wrapped_token_id, + mint_target_accounts(wrapped_token_id), + ) +} + +/// The mint's own account list: the wrapped-token config, then the recipient's +/// holding. What `wrapped_token::Mint` requires on the destination zone. +fn mint_target_accounts(wrapped_token_id: lee_core::program::ProgramId) -> Vec<[u8; 32]> { + vec![ + wrapped_token_core::config_account_id(wrapped_token_id).into_value(), + wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT).into_value(), + ] +} + +/// The same lock aimed at `target_program_id` over `target_accounts`, so a test +/// can vary what the destination would be asked to do. +fn lock_tx_to( + holder_key: &PrivateKey, + holder_id: AccountId, + zone_b: [u8; 32], + ordinal: u32, + nonce: u128, + target_program_id: lee_core::program::ProgramId, + target_accounts: Vec<[u8; 32]>, +) -> PublicTransaction { + let bridge_lock_id = programs::bridge_lock().id(); + let outbox_id = programs::cross_zone_outbox().id(); + + let lock = bridge_lock_core::Instruction::Lock { + amount: LOCK_AMOUNT, + target_zone: zone_b, + target_program_id, + target_accounts, + payload: mint_payload(), + ordinal, + }; + let message = Message::try_new( + bridge_lock_id, + vec![ + bridge_lock_core::config_account_id(bridge_lock_id), + holder_id, + bridge_lock_core::escrow_account_id(bridge_lock_id), + outbox_pda(outbox_id, bridge_lock_id, &zone_b, ordinal), + ], + vec![nonce.into()], + lock, + ) + .expect("build lock message"); + let witness = WitnessSet::for_message(&message, &[holder_key]); + PublicTransaction::new(message, witness) +} + +/// A slot holds one message for ever, so a second emission into it fails rather +/// than replacing the record. Without this a later emitter silently destroys an +/// earlier one, and for a bridge that means an escrow with no record of what it +/// was for. +#[test] +fn a_second_emit_at_the_same_slot_is_rejected() { + let zone_b = [2_u8; 32]; + let ordinal = 0; + + let holder_key = PrivateKey::try_new([7; 32]).expect("valid key"); + let holder_id = AccountId::from(&PublicKey::new_from_private_key(&holder_key)); + let mut state = base_state().with_public_accounts([( + holder_id, + Account { + program_owner: programs::bridge_lock().id(), + balance: INITIAL_BALANCE, + ..Default::default() + }, + )]); + seed_bridge_lock_config(&mut state); + + let first = lock_tx(&holder_key, holder_id, zone_b, ordinal, 0); + let diff = ValidatedStateDiff::from_public_transaction(&first, &state, 1, 0) + .expect("the first lock executes"); + state.apply_state_diff(diff); + + // Same slot, fresh nonce, so the only thing that can reject it is the slot + // already holding a record. Matched on the guest's own message rather than + // any error, or a future change that rejected it earlier for an unrelated + // reason would keep this passing. + let second = lock_tx(&holder_key, holder_id, zone_b, ordinal, 1); + let Err(err) = ValidatedStateDiff::from_public_transaction(&second, &state, 2, 0) else { + panic!("a second emission into a written slot must not execute"); + }; + assert!( + format!("{err:?}").contains("Outbox slot already written"), + "rejected for the wrong reason: {err:?}" + ); + + // Control: the same second lock into a fresh ordinal executes, so the + // refusal above is the slot and not the transaction's shape. + let elsewhere = lock_tx(&holder_key, holder_id, zone_b, ordinal + 1, 1); + ValidatedStateDiff::from_public_transaction(&elsewhere, &state, 2, 0) + .expect("a lock into an unwritten slot executes"); +} + +/// Two programs emitting to one zone and ordinal address two different slots, +/// so neither can overwrite or block the other. +#[test] +fn two_emitters_share_an_ordinal_without_colliding() { + let outbox_id = programs::cross_zone_outbox().id(); + let sender_id = programs::ping_sender().id(); + let bridge_lock_id = programs::bridge_lock().id(); + let receiver_id = programs::ping_receiver().id(); + let zone_b = [2_u8; 32]; + let ordinal = 0; + + let holder_key = PrivateKey::try_new([7; 32]).expect("valid key"); + let holder_id = AccountId::from(&PublicKey::new_from_private_key(&holder_key)); + let mut state = base_state().with_public_accounts([( + holder_id, + Account { + program_owner: bridge_lock_id, + balance: INITIAL_BALANCE, + ..Default::default() + }, + )]); + seed_ping_sender_config(&mut state); + seed_bridge_lock_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); + assert_ne!( + lock_slot, send_slot, + "the same zone and ordinal under two emitters are two slots" + ); + + let lock = lock_tx(&holder_key, holder_id, zone_b, ordinal, 0); + let diff = ValidatedStateDiff::from_public_transaction(&lock, &state, 1, 0) + .expect("the lock executes"); + state.apply_state_diff(diff); + + let send = send_tx( + vec![sender_config_account_id(sender_id), send_slot], + zone_b, + ordinal, + ); + 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( + &send_diff.public_diff()[&send_slot] + .data + .clone() + .into_inner(), + ) + .expect("outbox PDA holds an OutboxRecord"); + assert_eq!(record.emitter, sender_id); + assert_eq!(record.target_program_id, receiver_id); + + // And the lock's own slot is untouched by it. + let lock_record = + OutboxRecord::from_bytes(&state.get_account_by_id(lock_slot).data.into_inner()) + .expect("the lock's record survives"); + 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:?}" + ); +} + +/// Nothing releases an escrow, so a message the destination will refuse is a +/// burn: debited here, never minted there. The refusal has to come before the +/// debit. +#[test] +fn a_lock_naming_another_target_program_is_rejected() { + let bridge_lock_id = programs::bridge_lock().id(); + let zone_b = [2_u8; 32]; + + let holder_key = PrivateKey::try_new([7; 32]).expect("valid key"); + let holder_id = AccountId::from(&PublicKey::new_from_private_key(&holder_key)); + let mut state = base_state().with_public_accounts([( + holder_id, + Account { + program_owner: bridge_lock_id, + balance: INITIAL_BALANCE, + ..Default::default() + }, + )]); + seed_bridge_lock_config(&mut state); + + let elsewhere = programs::ping_receiver().id(); + let lock = lock_tx_to( + &holder_key, + holder_id, + zone_b, + 0, + 0, + elsewhere, + mint_target_accounts(elsewhere), + ); + + let Err(err) = ValidatedStateDiff::from_public_transaction(&lock, &state, 1, 0) else { + panic!("a lock aimed at another program must not execute"); + }; + assert!( + format!("{err:?}").contains("only mints through the wrapped token it is pinned to"), + "rejected for the wrong reason: {err:?}" + ); + assert_eq!( + state.get_account_by_id(holder_id).balance, + INITIAL_BALANCE, + "a refused lock leaves the holder's balance alone" + ); +} + +/// The same burn by a different route: the right target program, the wrong +/// accounts for it. `wrapped_token::Mint` fails its own address asserts on the +/// destination, so the escrow has to be refused here instead. +#[test] +fn a_lock_naming_other_mint_accounts_is_rejected() { + let bridge_lock_id = programs::bridge_lock().id(); + let wrapped_token_id = programs::wrapped_token().id(); + let zone_b = [2_u8; 32]; + + let holder_key = PrivateKey::try_new([7; 32]).expect("valid key"); + let holder_id = AccountId::from(&PublicKey::new_from_private_key(&holder_key)); + let mut state = base_state().with_public_accounts([( + holder_id, + Account { + program_owner: bridge_lock_id, + balance: INITIAL_BALANCE, + ..Default::default() + }, + )]); + seed_bridge_lock_config(&mut state); + + // A holding under someone other than the payload's recipient: a mint the + // destination would credit to the wrong account if it credited it at all. + let other_holding = + wrapped_token_core::holding_account_id(wrapped_token_id, &[4; 32]).into_value(); + let lock = lock_tx_to( + &holder_key, + holder_id, + zone_b, + 0, + 0, + wrapped_token_id, + vec![ + wrapped_token_core::config_account_id(wrapped_token_id).into_value(), + other_holding, + ], + ); + + let Err(err) = ValidatedStateDiff::from_public_transaction(&lock, &state, 1, 0) else { + panic!("a lock over the wrong mint accounts must not execute"); + }; + assert!( + format!("{err:?}").contains("target accounts must be the mint's config"), + "rejected for the wrong reason: {err:?}" + ); + assert_eq!( + state.get_account_by_id(holder_id).balance, + INITIAL_BALANCE, + "a refused lock leaves the holder's balance alone" + ); +} + +/// The config is read by address, so substituting another account for it fails +/// rather than reading the pins out of whatever that account holds. Without the +/// address check, 64 bytes a caller controls would re-pin both for one lock. +#[test] +fn a_lock_with_a_substituted_config_account_is_rejected() { + let bridge_lock_id = programs::bridge_lock().id(); + let wrapped_token_id = programs::wrapped_token().id(); + let outbox_id = programs::cross_zone_outbox().id(); + let zone_b = [2_u8; 32]; + let ordinal = 0; + + let holder_key = PrivateKey::try_new([7; 32]).expect("valid key"); + let holder_id = AccountId::from(&PublicKey::new_from_private_key(&holder_key)); + // A bridge-lock-owned account holding pins of the caller's choosing, so only + // the address check stands between it and being read as the config. + let decoy_key = PrivateKey::try_new([8; 32]).expect("valid key"); + let decoy_id = AccountId::from(&PublicKey::new_from_private_key(&decoy_key)); + let mut state = base_state().with_public_accounts([ + ( + holder_id, + Account { + program_owner: bridge_lock_id, + balance: INITIAL_BALANCE, + ..Default::default() + }, + ), + ( + decoy_id, + Account { + program_owner: bridge_lock_id, + data: bridge_lock_core::config_bytes([3; 8], [4; 8]) + .to_vec() + .try_into() + .expect("pinned ids fit in account data"), + ..Default::default() + }, + ), + ]); + seed_bridge_lock_config(&mut state); + + let lock = bridge_lock_core::Instruction::Lock { + amount: LOCK_AMOUNT, + target_zone: zone_b, + target_program_id: wrapped_token_id, + target_accounts: mint_target_accounts(wrapped_token_id), + payload: mint_payload(), + ordinal, + }; + let message = Message::try_new( + bridge_lock_id, + vec![ + decoy_id, + holder_id, + bridge_lock_core::escrow_account_id(bridge_lock_id), + outbox_pda(outbox_id, bridge_lock_id, &zone_b, ordinal), + ], + vec![0_u128.into()], + lock, + ) + .expect("build lock message"); + let tx = PublicTransaction::new( + message.clone(), + WitnessSet::for_message(&message, &[&holder_key]), + ); + + let Err(err) = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0) else { + panic!("a lock over a substituted config account must not execute"); + }; + assert!( + format!("{err:?}").contains("must be the bridge-lock config PDA"), + "rejected for the wrong reason: {err:?}" + ); +} + +/// A bridge with no pin cannot fall back to caller-named programs: it stops +/// locking. The state a zone reaches by skipping the genesis init. +#[test] +fn a_lock_before_the_pins_are_set_is_rejected() { + let bridge_lock_id = programs::bridge_lock().id(); + let zone_b = [2_u8; 32]; + + let holder_key = PrivateKey::try_new([7; 32]).expect("valid key"); + let holder_id = AccountId::from(&PublicKey::new_from_private_key(&holder_key)); + let state = base_state().with_public_accounts([( + holder_id, + Account { + program_owner: bridge_lock_id, + balance: INITIAL_BALANCE, + ..Default::default() + }, + )]); + + let lock = lock_tx(&holder_key, holder_id, zone_b, 0, 0); + let Err(err) = ValidatedStateDiff::from_public_transaction(&lock, &state, 1, 0) else { + panic!("a lock with nothing pinned must not execute"); + }; + assert!( + format!("{err:?}").contains("config account holds an outbox and a mint target"), + "rejected for the wrong reason: {err:?}" + ); +} + +/// Written once, on the same terms as the sender's: an identical re-init is the +/// genesis replay, a different one would redirect every lock on the zone. +#[test] +fn the_bridge_pins_are_written_once_and_replayable() { + let bridge_lock_id = programs::bridge_lock().id(); + let config_id = bridge_lock_core::config_account_id(bridge_lock_id); + let outbox_id = programs::cross_zone_outbox().id(); + let wrapped_token_id = programs::wrapped_token().id(); + + let init = |outbox: lee_core::program::ProgramId, target: lee_core::program::ProgramId| { + let message = Message::try_new( + bridge_lock_id, + vec![config_id], + vec![], + bridge_lock_core::Instruction::InitConfig { + outbox_program_id: outbox, + target_program_id: target, + }, + ) + .expect("build InitConfig message"); + PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![])) + }; + + let mut state = base_state(); + + let diff = ValidatedStateDiff::from_public_transaction( + &init(outbox_id, wrapped_token_id), + &state, + 1, + 0, + ) + .expect("the first init claims the config PDA"); + state.apply_state_diff(diff); + assert_eq!( + bridge_lock_core::read_config(&state.get_account_by_id(config_id).data.into_inner()), + Some((outbox_id, wrapped_token_id)), + "the config pins both programs after genesis" + ); + + ValidatedStateDiff::from_public_transaction(&init(outbox_id, wrapped_token_id), &state, 2, 0) + .expect("replaying the identical init is a no-op, not a failure"); + + // Either half moving is a redirect: the outbox decides whether the emission is + // recorded, the target where the value lands. + for (outbox, target, what) in [ + ([3; 8], wrapped_token_id, "outbox"), + (outbox_id, [3; 8], "mint target"), + ] { + let Err(err) = + ValidatedStateDiff::from_public_transaction(&init(outbox, target), &state, 3, 0) + else { + panic!("a re-init naming a different {what} must not execute"); + }; + assert!( + format!("{err:?}").contains("already pins a different outbox or mint target"), + "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:?}" + ); +} + +/// A token that authorizes nothing mints for nobody. The state a zone reaches with +/// no peers configured, where the config is still seeded so its PDA cannot be +/// claimed by a first initializer. +#[test] +fn a_mint_is_refused_when_the_token_authorizes_no_source() { + 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 mut state = base_state(); + seed_inbox_config(&mut state, self_zone); + seed_wrapped_config(&mut state, vec![]); + + let msg = CrossZoneMessage { + src_zone, + src_block_id: 5, + src_block_hash: SRC_BLOCK_HASH, + src_tx_index: 0, + src_program_id: programs::bridge_lock().id(), + target_program_id: wrapped_token_id, + payload: mint_payload(), + l1_inclusion_witness: None, + }; + let message = Message::try_new( + inbox_id, + dispatch_accounts( + inbox_id, + &msg, + vec![ + wrapped_token_core::config_account_id(wrapped_token_id), + wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT), + ], + ), + vec![], + InboxInstruction::Dispatch(msg), + ) + .expect("build dispatch message"); + let tx = PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![])); + + let Err(err) = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0) else { + panic!("a token authorizing nothing must not mint"); + }; + assert!( + format!("{err:?}").contains("peer source this token authorizes"), + "rejected for the wrong reason: {err:?}" + ); +} + +/// The marker only means something because the caller is pinned to the inbox. +/// Invoked directly, with the caller handing in the marker themselves, the mint +/// must refuse before it ever looks at it. +#[test] +fn a_top_level_mint_is_refused() { + let inbox_id = programs::cross_zone_inbox().id(); + let wrapped_token_id = programs::wrapped_token().id(); + let src_zone = [2_u8; 32]; + let src_program_id = programs::bridge_lock().id(); + + let mut state = base_state(); + seed_wrapped_config(&mut state, vec![(src_zone, src_program_id)]); + + let marker_id = inbox_source_marker_account_id(inbox_id, &src_zone, src_program_id); + let message = Message::try_new( + wrapped_token_id, + vec![ + marker_id, + wrapped_token_core::config_account_id(wrapped_token_id), + wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT), + ], + vec![], + wrapped_token_core::Instruction::Mint { + recipient: RECIPIENT, + amount: LOCK_AMOUNT, + }, + ) + .expect("build mint message"); + let tx = PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![])); + + let Err(err) = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0) else { + panic!("a directly invoked mint must not execute"); + }; + assert!( + format!("{err:?}").contains("only callable by the authorized minter"), + "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] @@ -328,12 +1054,11 @@ 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. +/// `ping_sender` lets its caller choose the target and payload freely, so any user +/// on a peer can aim a `Mint` payload at `wrapped_token`. The inbox no longer +/// refuses it; the token does, because the marker names `ping_sender` and the +/// token authorized only the bridge. This is the check that replaced the central +/// route table, so it must be the thing that rejects here. #[test] fn a_mint_from_an_unrouted_emitter_is_rejected() { let inbox_id = programs::cross_zone_inbox().id(); @@ -345,14 +1070,8 @@ fn a_mint_from_an_unrouted_emitter_is_rejected() { 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); + seed_inbox_config(&mut state, self_zone); + seed_wrapped_config(&mut state, vec![(src_zone, programs::bridge_lock().id())]); let msg = CrossZoneMessage { src_zone, @@ -366,27 +1085,24 @@ fn a_mint_from_an_unrouted_emitter_is_rejected() { 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, - ], + dispatch_accounts(inbox_id, &msg, vec![wrapped_config_id, holding_id]), vec![], InboxInstruction::Dispatch(msg), ) .expect("build dispatch message"); let tx = PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![])); + let Err(err) = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0) else { + panic!("a delivery from a source the token did not authorize must not mint"); + }; 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" + format!("{err:?}").contains("peer source this token authorizes"), + "rejected for the wrong reason: {err:?}" ); } @@ -404,14 +1120,8 @@ fn a_mint_from_the_routed_emitter_is_accepted() { 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); + seed_inbox_config(&mut state, self_zone); + seed_wrapped_config(&mut state, vec![(src_zone, programs::bridge_lock().id())]); let msg = CrossZoneMessage { src_zone, @@ -424,18 +1134,12 @@ fn a_mint_from_the_routed_emitter_is_accepted() { 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, - ], + dispatch_accounts(inbox_id, &msg, vec![wrapped_config_id, holding_id]), vec![], InboxInstruction::Dispatch(msg), ) @@ -464,14 +1168,8 @@ fn mint_replay_rejected() { let src_tx_index = 0; let mut state = base_state(); - seed_inbox_config( - &mut state, - self_zone, - src_zone, - [9_u32; 8], - wrapped_token_id, - ); - seed_wrapped_config(&mut state); + seed_inbox_config(&mut state, self_zone); + seed_wrapped_config(&mut state, vec![(src_zone, [9_u32; 8])]); // Seed the seen-shard as already holding this delivery, so the inbox takes // the replay no-op branch. The shard is inbox-owned (claimed on a prior @@ -509,12 +1207,7 @@ fn mint_replay_rejected() { let message = Message::try_new( inbox_id, - vec![ - inbox_config_account_id(inbox_id), - seen_id, - wrapped_config_id, - holding_id, - ], + dispatch_accounts(inbox_id, &msg, vec![wrapped_config_id, holding_id]), vec![], InboxInstruction::Dispatch(msg), ) @@ -555,7 +1248,8 @@ fn a_delivery_from_a_second_block_at_the_same_id_is_refused() { let other_block_hash = [8_u8; 32]; let mut state = base_state(); - seed_inbox_config(&mut state, self_zone, src_zone, [9_u32; 8], receiver_id); + seed_inbox_config(&mut state, self_zone); + seed_receiver_config(&mut state, vec![(src_zone, [9_u32; 8])]); // The shard as the first delivery left it: bound, holding transaction 0. let seen_id = inbox_seen_shard_account_id(inbox_id, &src_zone, src_block_id); @@ -596,7 +1290,11 @@ fn a_delivery_from_a_second_block_at_the_same_id_is_refused() { let record_id = ping_record_pda(receiver_id); let message = Message::try_new( inbox_id, - vec![inbox_config_account_id(inbox_id), seen_id, record_id], + dispatch_accounts( + inbox_id, + &msg, + vec![receiver_config_account_id(receiver_id), record_id], + ), vec![], InboxInstruction::Dispatch(msg), ) @@ -629,7 +1327,11 @@ fn a_delivery_from_a_second_block_at_the_same_id_is_refused() { }; let control_message = Message::try_new( inbox_id, - vec![inbox_config_account_id(inbox_id), seen_id, record_id], + dispatch_accounts( + inbox_id, + &control_msg, + vec![receiver_config_account_id(receiver_id), record_id], + ), vec![], InboxInstruction::Dispatch(control_msg), ) diff --git a/integration_tests/tests/cross_zone_verified.rs b/integration_tests/tests/cross_zone_verified.rs index b1dd28cad..21cb4e102 100644 --- a/integration_tests/tests/cross_zone_verified.rs +++ b/integration_tests/tests/cross_zone_verified.rs @@ -20,7 +20,10 @@ 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, receiver_config_account_id, + sender_config_account_id, +}; use sequencer_core::config::{CrossZoneConfig, CrossZonePeer, CrossZoneRoute}; use sequencer_service_rpc::RpcClient as _; use test_fixtures::{ @@ -112,18 +115,21 @@ 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()], + target_accounts: vec![ + receiver_config_account_id(receiver_id).into_value(), + ping_record_pda(receiver_id).into_value(), + ], payload, ordinal, }; - let outbox_account = outbox_pda(outbox_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 86dfc7059..e30875201 100644 --- a/integration_tests/tests/cross_zone_watcher_restart.rs +++ b/integration_tests/tests/cross_zone_watcher_restart.rs @@ -25,7 +25,10 @@ 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, receiver_config_account_id, + sender_config_account_id, +}; use sequencer_core::config::{CrossZoneConfig, CrossZonePeer, CrossZoneRoute}; use sequencer_service_rpc::{RpcClient as _, SequencerClient}; use tokio::test; @@ -190,18 +193,21 @@ 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()], + target_accounts: vec![ + receiver_config_account_id(receiver_id).into_value(), + ping_record_pda(receiver_id).into_value(), + ], payload, ordinal, }; - let outbox_account = outbox_pda(outbox_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..58d9344dc 100644 --- a/lez/cross_zone/src/lib.rs +++ b/lez/cross_zone/src/lib.rs @@ -9,12 +9,10 @@ //! own block-reading, emission-extraction, delivery-building, and trust model; a //! shared trait is best lifted from that first real adapter, not from this one. -use std::collections::BTreeMap; - pub use cross_zone_inbox_core::{CrossZoneConfig, CrossZonePeer}; use cross_zone_inbox_core::{ CrossZoneMessage, InboxConfig, Instruction, ZoneId, inbox_config_account_id, - inbox_seen_shard_account_id, + inbox_seen_shard_account_id, inbox_source_marker_account_id, }; use lee_core::{ account::{Account, AccountId, Balance}, @@ -64,13 +62,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, @@ -78,13 +81,16 @@ pub fn extract_emission(program_id: ProgramId, instruction_data: &[u32]) -> Opti payload, }) } else if program_id == programs::bridge_lock().id() { - let bridge_lock_core::Instruction::Lock { + let Ok(bridge_lock_core::Instruction::Lock { 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, @@ -103,13 +109,21 @@ fn build_inbox_dispatch_tx( msg: &CrossZoneMessage, target_account_ids: Vec, ) -> lee::PublicTransaction { - let mut account_ids = Vec::with_capacity(target_account_ids.len().saturating_add(2)); + let mut account_ids = Vec::with_capacity(target_account_ids.len().saturating_add(3)); 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, )); + // Declared here rather than derived by the guest, since a guest cannot + // conjure an account. Both the watcher and the verifier build it through this + // one function, so they cannot disagree about the source a target will see. + account_ids.push(inbox_source_marker_account_id( + inbox_id, + &msg.src_zone, + msg.src_program_id, + )); account_ids.extend(target_account_ids); let message = lee::public_transaction::Message::try_new( @@ -156,33 +170,18 @@ 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 -/// delivery routes plus its own zone id. -fn inbox_config(self_zone: ZoneId, cross_zone: &CrossZoneConfig) -> InboxConfig { - let mut allowed_routes = BTreeMap::new(); - for peer in &cross_zone.peers { - allowed_routes.insert(peer.channel_id, peer.allowed_routes.clone()); - } - InboxConfig { - self_zone, - allowed_routes, - } -} - /// 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. +/// The operator's per-peer routes no longer live here. They are fanned out into +/// each target program's own config, so all the inbox keeps is its zone id. +/// Replaying this seeds the same account on every node. #[must_use] -pub fn build_inbox_init_config_tx( - self_zone: ZoneId, - cross_zone: &CrossZoneConfig, -) -> lee::PublicTransaction { +pub fn build_inbox_init_config_tx(self_zone: ZoneId) -> 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)), + Instruction::InitConfig(InboxConfig { self_zone }), ) } @@ -202,19 +201,104 @@ pub fn build_holding_account(holder: AccountId, amount: Balance) -> (AccountId, } /// The genesis transaction that pins the cross-zone inbox as the wrapped-token -/// minter, without importing the inbox id into the guest. +/// minter and names the peer sources it may mint for, without importing either id +/// into the guest. +/// +/// The sources are the operator's own peer routes aimed at this token, moved from +/// the inbox's allowlist to the token's own config: the same information, enforced +/// by the program that owns the value. A zone with no peers gets an empty list, +/// which authorizes nothing, and the config is still seeded so its PDA cannot be +/// claimed by a first initializer. #[must_use] -pub fn build_wrapped_token_init_config_tx() -> lee::PublicTransaction { +pub fn build_wrapped_token_init_config_tx( + cross_zone: Option<&CrossZoneConfig>, +) -> lee::PublicTransaction { let wrapped_token_id = programs::wrapped_token().id(); + let sources = cross_zone + .map(|cross_zone| { + cross_zone + .peers + .iter() + .flat_map(|peer| { + peer.allowed_routes + .iter() + .filter(|route| route.target_program_id == wrapped_token_id) + .map(|route| (peer.channel_id, route.src_program_id)) + }) + .collect() + }) + .unwrap_or_default(); genesis_public_tx( wrapped_token_id, vec![wrapped_token_core::config_account_id(wrapped_token_id)], - wrapped_token_core::Instruction::InitConfig { + wrapped_token_core::Instruction::InitConfig(wrapped_token_core::WrappedTokenConfig { minter: programs::cross_zone_inbox().id(), + sources, + }), + ) +} + +/// 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(), }, ) } +/// The genesis transaction that pins the outbox `bridge_lock` chains into and the +/// wrapped token it mints, without importing either id into the guest. +#[must_use] +pub fn build_bridge_lock_init_config_tx() -> lee::PublicTransaction { + let bridge_lock_id = programs::bridge_lock().id(); + genesis_public_tx( + bridge_lock_id, + vec![bridge_lock_core::config_account_id(bridge_lock_id)], + bridge_lock_core::Instruction::InitConfig { + outbox_program_id: programs::cross_zone_outbox().id(), + target_program_id: programs::wrapped_token().id(), + }, + ) +} + +/// The genesis transaction naming the peer sources `ping_receiver` accepts a +/// delivery from, fanned out of the operator's routes exactly as the wrapped +/// token's is. +#[must_use] +pub fn build_ping_receiver_init_config_tx( + cross_zone: Option<&CrossZoneConfig>, +) -> lee::PublicTransaction { + let receiver_id = programs::ping_receiver().id(); + let sources = cross_zone + .map(|cross_zone| { + cross_zone + .peers + .iter() + .flat_map(|peer| { + peer.allowed_routes + .iter() + .filter(|route| route.target_program_id == receiver_id) + .map(|route| (peer.channel_id, route.src_program_id)) + }) + .collect() + }) + .unwrap_or_default(); + genesis_public_tx( + receiver_id, + vec![ping_core::receiver_config_account_id(receiver_id)], + ping_core::ReceiverInstruction::InitConfig(ping_core::ReceiverConfig { + deliverer: programs::cross_zone_inbox().id(), + sources, + }), + ) +} + /// 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 16233df12..be17e6f3c 100644 --- a/lez/indexer/core/src/cross_zone_verifier.rs +++ b/lez/indexer/core/src/cross_zone_verifier.rs @@ -718,7 +718,7 @@ mod tests { }; use logos_blockchain_core::mantle::ops::channel::{MsgId, inscribe::Inscription}; use logos_blockchain_zone_sdk::ZoneBlock; - use ping_core::{SenderInstruction, ping_record_pda}; + use ping_core::{SenderInstruction, ping_record_pda, receiver_config_account_id}; use super::*; @@ -746,10 +746,12 @@ 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()], + target_accounts: vec![ + receiver_config_account_id(receiver_id).into_value(), + ping_record_pda(receiver_id).into_value(), + ], payload: payload.to_vec(), ordinal: 0, }; @@ -846,7 +848,10 @@ mod tests { src_program_id: programs::ping_sender().id(), }, receiver_id, - &[ping_record_pda(receiver_id).into_value()], + &[ + receiver_config_account_id(receiver_id).into_value(), + ping_record_pda(receiver_id).into_value(), + ], payload.to_vec(), )) } diff --git a/lez/programs/bridge_lock/core/Cargo.toml b/lez/programs/bridge_lock/core/Cargo.toml index 190fc4f26..1251d459e 100644 --- a/lez/programs/bridge_lock/core/Cargo.toml +++ b/lez/programs/bridge_lock/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/bridge_lock/core/src/lib.rs b/lez/programs/bridge_lock/core/src/lib.rs index 6d2aaf495..77836140b 100644 --- a/lez/programs/bridge_lock/core/src/lib.rs +++ b/lez/programs/bridge_lock/core/src/lib.rs @@ -9,23 +9,41 @@ use lee_core::{ use serde::{Deserialize, Serialize}; const ESCROW_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/BridgeLockEscrow/0000/"; +const CONFIG_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/BridgeLockCfg/0000000/"; +/// Variants are append-only. risc0 serde encodes the variant as a bare leading +/// tag word, so inserting one ahead of `Lock` shifts every existing encoding. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum Instruction { /// Lock `amount` of the holder's balance and emit a cross-zone message - /// minting the wrapped token on `target_zone`. The emission fields mirror - /// `cross_zone_outbox::Instruction::Emit` so the watcher reads them directly. + /// minting the wrapped token on `target_zone`. /// - /// Required accounts (3): holder holding (authorized), escrow PDA, outbox PDA. + /// `target_program_id` and `target_accounts` are supplied though the guest + /// accepts one value for each: `cross_zone::extract_emission` reads them off + /// the transaction, decoding every emitter through one shape. + /// + /// `target_zone` is the caller's, so a lock to a zone that will not route it + /// escrows and never mints. TODO: bound it source-side. + /// + /// Required accounts (4): config PDA, holder holding (authorized), escrow + /// PDA, outbox PDA. Lock { amount: u128, target_zone: [u8; 32], target_program_id: ProgramId, target_accounts: Vec<[u8; 32]>, payload: Vec, - outbox_program_id: ProgramId, ordinal: u32, }, + /// Pins the outbox program and the mint target, written once into a default + /// config PDA at genesis. A re-run naming different programs is refused; an + /// identical one is a no-op, which is what genesis replay does. + /// + /// Required accounts (1): the config PDA. + InitConfig { + outbox_program_id: ProgramId, + target_program_id: ProgramId, + }, } /// PDA accumulating all locked balance on this zone. @@ -39,6 +57,49 @@ pub const fn escrow_seed() -> PdaSeed { PdaSeed::new(ESCROW_SEED_DOMAIN) } +/// PDA holding the outbox program id and the mint target, seeded at genesis so +/// the guest can pin both without importing their image ids. +#[must_use] +pub fn config_account_id(bridge_lock_id: ProgramId) -> AccountId { + AccountId::for_public_pda(&bridge_lock_id, &config_seed()) +} + +#[must_use] +pub const fn config_seed() -> PdaSeed { + PdaSeed::new(CONFIG_SEED_DOMAIN) +} + +/// Encodes the pinned outbox and mint target for the config account's data. +#[must_use] +pub fn config_bytes(outbox_program_id: ProgramId, target_program_id: ProgramId) -> [u8; 64] { + let mut bytes = [0_u8; 64]; + for (word, chunk) in outbox_program_id + .iter() + .chain(target_program_id.iter()) + .zip(bytes.chunks_exact_mut(4)) + { + chunk.copy_from_slice(&word.to_le_bytes()); + } + bytes +} + +/// Decodes the pinned outbox and mint target from the config account's data. +#[must_use] +pub fn read_config(data: &[u8]) -> Option<(ProgramId, ProgramId)> { + if data.len() < 64 { + return None; + } + let mut ids = [0_u32; 16]; + for (word, chunk) in ids.iter_mut().zip(data[..64].chunks_exact(4)) { + *word = u32::from_le_bytes(chunk.try_into().unwrap_or_else(|_| unreachable!())); + } + let (outbox, target) = ids.split_at(8); + Some(( + outbox.try_into().unwrap_or_else(|_| unreachable!()), + target.try_into().unwrap_or_else(|_| unreachable!()), + )) +} + #[cfg(test)] mod tests { use super::*; @@ -48,4 +109,31 @@ mod tests { let id: ProgramId = [4; 8]; assert_eq!(escrow_account_id(id), escrow_account_id(id)); } + + #[test] + fn config_ids_round_trip() { + let outbox: ProgramId = [3; 8]; + let target: ProgramId = [5; 8]; + assert_eq!( + read_config(&config_bytes(outbox, target)), + Some((outbox, target)) + ); + } + + /// `extract_emission` decodes `Lock` off peer transactions, so its tag word is + /// wire format: a variant inserted ahead of it would silently shift every + /// existing encoding. + #[test] + fn lock_is_the_first_variant() { + let lock = Instruction::Lock { + amount: 1, + target_zone: [7; 32], + target_program_id: [1; 8], + target_accounts: vec![], + payload: vec![], + ordinal: 0, + }; + let words = risc0_zkvm::serde::to_vec(&lock).expect("Lock serializes"); + assert_eq!(words[0], 0); + } } diff --git a/lez/programs/bridge_lock/src/main.rs b/lez/programs/bridge_lock/src/main.rs index ec4ac7b4a..b7c5997e5 100644 --- a/lez/programs/bridge_lock/src/main.rs +++ b/lez/programs/bridge_lock/src/main.rs @@ -1,8 +1,14 @@ -use bridge_lock_core::{Instruction, escrow_account_id, escrow_seed}; +use bridge_lock_core::{ + Instruction, config_account_id, config_bytes, config_seed, escrow_account_id, escrow_seed, + read_config, +}; use cross_zone_outbox_core::Instruction as OutboxInstruction; 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, + }, }; use wrapped_token_core::{Instruction as WrappedInstruction, MAX_MINT_AMOUNT}; @@ -22,20 +28,74 @@ fn main() { "bridge_lock is only invoked as a top-level user transaction" ); - let Instruction::Lock { - amount, - target_zone, - target_program_id, - target_accounts, - payload, - outbox_program_id, - ordinal, - } = instruction; + match instruction { + Instruction::Lock { + amount, + target_zone, + target_program_id, + target_accounts, + payload, + ordinal, + } => lock( + self_program_id, + caller_program_id, + pre_states, + instruction_words, + amount, + target_zone, + target_program_id, + target_accounts, + payload, + ordinal, + ), + Instruction::InitConfig { + outbox_program_id, + target_program_id, + } => init_config( + self_program_id, + caller_program_id, + pre_states, + instruction_words, + outbox_program_id, + target_program_id, + ), + } +} + +#[expect( + clippy::too_many_arguments, + reason = "the emission fields are passed through verbatim" +)] +fn lock( + self_program_id: ProgramId, + caller_program_id: Option, + pre_states: Vec, + instruction_words: Vec, + amount: u128, + target_zone: [u8; 32], + target_program_id: ProgramId, + target_accounts: Vec<[u8; 32]>, + payload: Vec, + ordinal: u32, +) { + // pre_states: [config PDA, holder holding (authorized), escrow PDA, outbox PDA]. + let [config, holder, escrow, outbox] = <[AccountWithMetadata; 4]>::try_from(pre_states) + .expect("Lock requires config, holder, escrow, and outbox accounts"); + + // Pinned rather than caller-named: chaining elsewhere would debit the escrow + // and leave no record of what it was for. + assert_eq!( + config.account_id, + config_account_id(self_program_id), + "first account must be the bridge-lock config PDA" + ); + let (outbox_program_id, pinned_target) = read_config(&config.account.data.clone().into_inner()) + .expect("config account holds an outbox and a mint target"); // Value conservation: the forwarded payload must mint exactly what is locked. let WrappedInstruction::Mint { + recipient, amount: mint_amount, - .. } = decode_mint(&payload) else { panic!("bridge_lock payload must be a wrapped-token mint"); @@ -44,18 +104,27 @@ fn main() { mint_amount, amount, "locked amount must equal the wrapped mint amount" ); - // Before the debit, not on the destination: nothing releases an escrow, so - // an amount the destination will not mint has to fail in the submitter's own - // transaction. + + // All before the debit: nothing releases an escrow, so a message the + // destination refuses is a burn. `target_zone` is not checkable here, so a + // lock aimed at a zone that will not route it still burns. + assert_eq!( + target_program_id, pinned_target, + "bridge_lock only mints through the wrapped token it is pinned to" + ); + assert_eq!( + target_accounts, + vec![ + wrapped_token_core::config_account_id(pinned_target).into_value(), + wrapped_token_core::holding_account_id(pinned_target, &recipient).into_value(), + ], + "target accounts must be the mint's config and the recipient's holding" + ); assert!( amount <= MAX_MINT_AMOUNT, "locked amount exceeds what the wrapped token will mint" ); - // pre_states: [holder holding (authorized), escrow PDA, outbox PDA]. - let [holder, escrow, outbox] = <[AccountWithMetadata; 3]>::try_from(pre_states) - .expect("Lock requires holder, escrow, and outbox accounts"); - assert!(holder.is_authorized, "holder must authorize the lock"); // The holder holding is bridge_lock-owned, so bridge_lock may debit its native // balance directly (state-machine rule 5). This also pins the transfer to a @@ -68,7 +137,7 @@ fn main() { assert_eq!( escrow.account_id, escrow_account_id(self_program_id), - "second account must be the escrow PDA" + "third account must be the escrow PDA" ); // Move the real native balance holder -> escrow. bridge_lock owns both accounts, @@ -106,12 +175,15 @@ fn main() { }, ); + let config_post = AccountPostState::new(config.account.clone()); + ProgramOutput::new( self_program_id, caller_program_id, instruction_words, - vec![holder, escrow, outbox.clone()], + vec![config, holder, escrow, outbox.clone()], vec![ + config_post, holder_post, escrow_post, AccountPostState::new(outbox.account), @@ -121,6 +193,58 @@ fn main() { .write(); } +/// Writes the outbox program and the mint target 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, + target_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, + config_account_id(self_program_id), + "account must be the bridge-lock config PDA" + ); + // Init-once, idempotent under genesis replay: a `default` config is a first + // init; an already-owned one must already pin exactly these programs, 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, + "bridge-lock config PDA is owned by another program" + ); + assert_eq!( + config.account.data.clone().into_inner(), + config_bytes(outbox_program_id, target_program_id).to_vec(), + "bridge-lock config already pins a different outbox or mint target" + ); + } + + let mut config_account = config.account.clone(); + config_account.data = config_bytes(outbox_program_id, target_program_id) + .to_vec() + .try_into() + .expect("pinned ids fit 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(); +} + /// Decodes the cross-zone payload (risc0 words, little-endian bytes) into the /// wrapped-token instruction it carries. fn decode_mint(payload: &[u8]) -> WrappedInstruction { diff --git a/lez/programs/cross_zone_inbox/core/src/lib.rs b/lez/programs/cross_zone_inbox/core/src/lib.rs index c0dcf64b2..9184e0342 100644 --- a/lez/programs/cross_zone_inbox/core/src/lib.rs +++ b/lez/programs/cross_zone_inbox/core/src/lib.rs @@ -1,4 +1,4 @@ -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeSet; use borsh::{BorshDeserialize, BorshSerialize}; use lee_core::{ @@ -13,6 +13,7 @@ const INBOX_CONFIG_SEED: [u8; 32] = *b"/LEZ/v0.3/CrossZoneInboxCfg/000/"; /// indistinguishable under one domain. Belt and braces, since the image id /// already relocates every PDA in this crate whenever the crate changes. const INBOX_SEEN_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneInboxSeen/01/"; +const SOURCE_MARKER_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneSource/00000/"; /// Raw 32-byte zone (channel) id; the host maps it to the zone-sdk `ChannelId`. pub type ZoneId = [u8; 32]; @@ -84,32 +85,20 @@ pub struct CrossZoneMessage { pub l1_inclusion_witness: Option>, } -/// Per-peer delivery routes, plus this inbox's own zone id. +/// This inbox's own zone id. +/// +/// It no longer decides who may deliver what. Each target program authorizes its +/// own sources against the marker the inbox passes, so the only thing the inbox +/// still needs to know is which zone it is, to refuse a message addressed to +/// itself. #[derive( Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, BorshSerialize, BorshDeserialize, )] pub struct InboxConfig { pub self_zone: ZoneId, - /// 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 { @@ -204,25 +193,6 @@ 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. @@ -286,6 +256,48 @@ pub fn inbox_seen_shard_seed(src_zone: &ZoneId, src_block_id: u64) -> PdaSeed { .unwrap_or_else(|_| unreachable!()); PdaSeed::new(seed) } + +/// The account naming who sent a delivery, which the inbox passes at position 0 +/// of the chained call so the target can authenticate its own sources. +/// +/// Nothing writes or claims it, so the state machine's uninitialized-account rule +/// skips it for being unchanged rather than for being default: anyone may send it +/// balance, and the inbox and the targets all round-trip it untouched. +/// +/// The address is derivable by anyone, so it is not a secret and not a +/// capability. What makes it mean something is that a target checks it only after +/// pinning its caller to the inbox, and only the inbox can be that caller. +#[must_use] +pub fn inbox_source_marker_account_id( + inbox_id: ProgramId, + src_zone: &ZoneId, + src_program_id: ProgramId, +) -> AccountId { + AccountId::for_public_pda( + &inbox_id, + &inbox_source_marker_seed(src_zone, src_program_id), + ) +} + +/// Seed of the source marker, exposed so a target can re-derive the address of +/// the one source it accepts and compare. +#[must_use] +pub fn inbox_source_marker_seed(src_zone: &ZoneId, src_program_id: ProgramId) -> PdaSeed { + use risc0_zkvm::sha::{Impl, Sha256 as _}; + + let mut bytes = [0_u8; 96]; + bytes[..32].copy_from_slice(&SOURCE_MARKER_SEED_DOMAIN); + bytes[32..64].copy_from_slice(src_zone); + for (word, chunk) in src_program_id.iter().zip(bytes[64..].chunks_exact_mut(4)) { + chunk.copy_from_slice(&word.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 lee_core::account::data::DATA_MAX_LENGTH; @@ -296,63 +308,6 @@ 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 4d2181c23..ff61a8bc9 100644 --- a/lez/programs/cross_zone_inbox/src/main.rs +++ b/lez/programs/cross_zone_inbox/src/main.rs @@ -1,6 +1,7 @@ use cross_zone_inbox_core::{ CrossZoneMessage, InboxConfig, Instruction, SeenShard, inbox_config_account_id, inbox_config_seed, inbox_seen_shard_account_id, inbox_seen_shard_seed, + inbox_source_marker_account_id, }; use lee_core::{ account::{Account, AccountWithMetadata}, @@ -61,10 +62,11 @@ fn dispatch( "l1_inclusion_witness must be None in v1" ); - // pre_states layout: [config, seen_shard, then the target accounts]. + // pre_states layout: [config, seen_shard, source marker, 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 marker = accounts.next().expect("source marker account required"); let target_accounts: Vec = accounts.collect(); assert_eq!( @@ -77,6 +79,14 @@ fn dispatch( inbox_seen_shard_account_id(self_program_id, &msg.src_zone, msg.src_block_id), "Second account must be the seen-shard PDA" ); + // The one value the chained call carries about where the message came from. + // The target re-derives this address from the source it accepts, so binding it + // here is what makes a target's own check meaningful. + assert_eq!( + marker.account_id, + inbox_source_marker_account_id(self_program_id, &msg.src_zone, msg.src_program_id), + "Third account must be the source marker PDA for this message" + ); let cfg = InboxConfig::from_bytes(&config.account.data.clone().into_inner()) .expect("inbox config decodes"); @@ -85,15 +95,6 @@ fn dispatch( msg.src_zone != cfg.self_zone, "Source zone must not be this zone" ); - // 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!( - 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 mut shard = SeenShard::from_bytes(&seen.account.data.clone().into_inner()).expect("seen shard decodes"); @@ -137,19 +138,23 @@ fn dispatch( .map(|c| u32::from_le_bytes(c.try_into().unwrap_or_else(|_| unreachable!()))) .collect(); + // The marker leads, so a target reads its source at a fixed position + // without knowing anything about the accounts that follow it. + let mut call_pre_states = vec![marker.clone()]; + call_pre_states.extend(target_accounts.clone()); let call = ChainedCall { program_id: msg.target_program_id, - pre_states: target_accounts.clone(), + pre_states: call_pre_states, instruction_data, pda_seeds: vec![], }; (seen_post, vec![call]) }; - let mut post_states = vec![unchanged(&config), seen_post]; + let mut post_states = vec![unchanged(&config), seen_post, unchanged(&marker)]; post_states.extend(target_accounts.iter().map(unchanged)); - let mut output_pre_states = vec![config, seen]; + let mut output_pre_states = vec![config, seen, marker]; output_pre_states.extend(target_accounts); ProgramOutput::new( @@ -163,8 +168,7 @@ fn dispatch( .write(); } -/// Writes the inbox config (peer + target allowlists) into the config PDA exactly -/// once at genesis. +/// Writes the inbox config into the config PDA exactly once at genesis. fn init_config( self_program_id: ProgramId, caller_program_id: Option, @@ -181,9 +185,8 @@ fn init_config( "account must be the inbox config PDA" ); // Init-once, idempotent under genesis replay: a `default` config is a first - // init; an already-owned config must already hold exactly these allowlists (the - // genesis block is replayed onto seeded state during multi-sequencer - // reconstruction), otherwise reject a post-genesis attempt to change them. + // init; an already-owned config must already hold exactly this, since genesis + // is replayed onto seeded state during multi-sequencer reconstruction. // `new_claimed_if_default` alone would not stop the owning program from // rewriting its own config data on a later call. if config_meta.account != Account::default() { @@ -194,7 +197,7 @@ fn init_config( assert_eq!( config_meta.account.data.clone().into_inner(), config.to_bytes(), - "inbox config already initialized with different allowlists" + "inbox config already initialized differently" ); } diff --git a/lez/programs/cross_zone_outbox/core/src/lib.rs b/lez/programs/cross_zone_outbox/core/src/lib.rs index 736b5fa23..66f5dc111 100644 --- a/lez/programs/cross_zone_outbox/core/src/lib.rs +++ b/lez/programs/cross_zone_outbox/core/src/lib.rs @@ -5,7 +5,11 @@ use lee_core::{ }; use serde::{Deserialize, Serialize}; -const OUTBOX_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneOutbox/00000/"; +/// Versions the seed layout: bump on any change to its field list or offsets, +/// so slots under an old layout can never be re-derived. Redundant with the +/// image id, which relocates every PDA in this crate whenever the crate changes, +/// but the two version different things. +const OUTBOX_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneOutbox/00001/"; /// Raw 32-byte zone (channel) id; the host maps it to the zone-sdk `ChannelId`. pub type ZoneId = [u8; 32]; @@ -14,6 +18,10 @@ pub type ZoneId = [u8; 32]; pub enum Instruction { /// Records an outbound cross-zone message as a write to a self-owned PDA. /// + /// The slot is written once: a second `Emit` at the same + /// `(emitter, target_zone, ordinal)` fails the transaction rather than + /// replacing the record. + /// /// Required accounts (1): /// - Outbox PDA account Emit { @@ -28,12 +36,21 @@ pub enum Instruction { }, } -/// 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. +/// One emitted message, as stored in its outbox PDA. +/// +/// Carries the slot it occupies as well as the message, so a reader holding the +/// bytes knows who wrote them and where without inverting the address, which is +/// a hash. The source zone and block coordinates are filled by the destination's +/// watcher and are not stored here. #[derive(Clone, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)] pub struct OutboxRecord { + /// The program that called `Emit`, which is the immediate chained caller. + /// Cross-zone discovery names the top-level program instead, so joining a + /// record against a delivery is only sound while every emitter refuses to be + /// called by another program. + pub emitter: ProgramId, pub target_zone: ZoneId, + pub ordinal: u32, pub target_program_id: ProgramId, pub target_accounts: Vec<[u8; 32]>, pub payload: Vec, @@ -52,22 +69,34 @@ impl OutboxRecord { } } -/// PDA holding one emitted message, keyed by destination zone and a per-zone -/// ordinal. +/// PDA holding one emitted message, keyed by the emitting program, the +/// destination zone, and a per-emitter per-zone ordinal. +/// +/// `emitter` is the program that called `Emit`, which the guest takes from +/// `caller_program_id` rather than from the instruction. Without it in the +/// address two programs share a slot and one overwrites the other. #[must_use] -pub fn outbox_pda(outbox_id: ProgramId, target_zone: &ZoneId, ordinal: u32) -> AccountId { - AccountId::for_public_pda(&outbox_id, &outbox_pda_seed(target_zone, ordinal)) +pub fn outbox_pda( + outbox_id: ProgramId, + emitter: ProgramId, + target_zone: &ZoneId, + ordinal: u32, +) -> AccountId { + AccountId::for_public_pda(&outbox_id, &outbox_pda_seed(emitter, target_zone, ordinal)) } /// 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 { +pub fn outbox_pda_seed(emitter: ProgramId, target_zone: &ZoneId, ordinal: u32) -> PdaSeed { use risc0_zkvm::sha::{Impl, Sha256 as _}; - let mut bytes = [0_u8; 68]; + let mut bytes = [0_u8; 100]; bytes[..32].copy_from_slice(&OUTBOX_SEED_DOMAIN); - bytes[32..64].copy_from_slice(target_zone); - bytes[64..].copy_from_slice(&ordinal.to_le_bytes()); + for (word, chunk) in emitter.iter().zip(bytes[32..64].chunks_exact_mut(4)) { + chunk.copy_from_slice(&word.to_le_bytes()); + } + bytes[64..96].copy_from_slice(target_zone); + bytes[96..].copy_from_slice(&ordinal.to_le_bytes()); let seed: [u8; 32] = Impl::hash_bytes(&bytes) .as_bytes() @@ -80,14 +109,55 @@ pub fn outbox_pda_seed(target_zone: &ZoneId, ordinal: u32) -> PdaSeed { mod tests { use super::*; + const OUTBOX: ProgramId = [3; 8]; + const EMITTER: ProgramId = [4; 8]; + #[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)); + assert_eq!( + outbox_pda(OUTBOX, EMITTER, &zone_a, 0), + outbox_pda(OUTBOX, EMITTER, &zone_a, 0) + ); + assert_ne!( + outbox_pda(OUTBOX, EMITTER, &zone_a, 0), + outbox_pda(OUTBOX, EMITTER, &zone_a, 1) + ); + assert_ne!( + outbox_pda(OUTBOX, EMITTER, &zone_a, 0), + outbox_pda(OUTBOX, EMITTER, &zone_b, 0) + ); + } + + /// Two programs emitting to the same zone and ordinal must not share a slot, + /// or the second silently overwrites the first. + #[test] + fn outbox_pda_is_unique_per_emitter() { + let zone = [1; 32]; + let other: ProgramId = [5; 8]; + + assert_ne!( + outbox_pda(OUTBOX, EMITTER, &zone, 0), + outbox_pda(OUTBOX, other, &zone, 0) + ); + } + + #[test] + fn outbox_record_round_trips() { + let record = OutboxRecord { + emitter: EMITTER, + target_zone: [1; 32], + ordinal: 7, + target_program_id: [6; 8], + target_accounts: vec![[9; 32]], + payload: b"payload".to_vec(), + }; + + assert_eq!( + OutboxRecord::from_bytes(&record.to_bytes()).expect("record decodes"), + record + ); } } diff --git a/lez/programs/cross_zone_outbox/src/main.rs b/lez/programs/cross_zone_outbox/src/main.rs index 432e8d9c9..a4b674df1 100644 --- a/lez/programs/cross_zone_outbox/src/main.rs +++ b/lez/programs/cross_zone_outbox/src/main.rs @@ -1,6 +1,6 @@ use cross_zone_outbox_core::{Instruction, OutboxRecord, outbox_pda, outbox_pda_seed}; use lee_core::{ - account::AccountWithMetadata, + account::{Account, AccountWithMetadata}, program::{AccountPostState, Claim, ProgramInput, ProgramOutput, read_lee_inputs}, }; @@ -15,10 +15,14 @@ fn main() { instruction_words, ) = read_lee_inputs::(); - assert!( - caller_program_id.is_some(), - "Outbox is only callable through a chain call from a user program" - ); + // The emitter, and the only identity here the state machine verifies: it + // checks a guest's claimed caller against the real one. Note this is the + // immediate chained caller, not the top-level program that cross-zone + // discovery names; the two coincide only while every emitter refuses to be + // called by another program, which both do today. + let Some(emitter) = caller_program_id else { + panic!("Outbox is only callable through a chain call from a user program"); + }; let (target_zone, target_program_id, target_accounts, payload, ordinal) = match instruction { Instruction::Emit { @@ -41,13 +45,32 @@ fn main() { assert_eq!( outbox.account_id, - outbox_pda(self_program_id, &target_zone, ordinal), - "Account must be the outbox PDA for (target_zone, ordinal)" + outbox_pda(self_program_id, emitter, &target_zone, ordinal), + "Account must be the outbox PDA for (emitter, target_zone, ordinal)" + ); + + // A slot holds one message for ever. Identity first, so a wrong account that + // happens to be free is reported as the wrong account rather than as a used + // slot. + // + // This is the same predicate the state machine already requires of a first + // write, so guest and host agree by construction rather than by coincidence. + // + // It also means a slot can be denied to its intended writer: the ordinal is + // caller-chosen in a namespace every user of an emitter shares, and an + // emission needs no signature, so anyone can occupy one. A client must pick + // an ordinal the chain does not already hold rather than counting from zero. + assert_eq!( + outbox.account, + Account::default(), + "Outbox slot already written: one Emit per (emitter, target_zone, ordinal)" ); let mut post_account = outbox.account.clone(); post_account.data = OutboxRecord { + emitter, target_zone, + ordinal, target_program_id, target_accounts, payload, @@ -56,9 +79,10 @@ fn main() { .try_into() .expect("OutboxRecord fits in account data"); - let post = AccountPostState::new_claimed_if_default( + // Unconditional, since the pre-state is provably default by the assert above. + let post = AccountPostState::new_claimed( post_account, - Claim::Pda(outbox_pda_seed(&target_zone, ordinal)), + Claim::Pda(outbox_pda_seed(emitter, &target_zone, ordinal)), ); ProgramOutput::new( diff --git a/lez/programs/ping_core/Cargo.toml b/lez/programs/ping_core/Cargo.toml index 29870630f..554962ca4 100644 --- a/lez/programs/ping_core/Cargo.toml +++ b/lez/programs/ping_core/Cargo.toml @@ -8,5 +8,9 @@ license = { workspace = true } workspace = true [dependencies] +borsh.workspace = true 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..672560b60 100644 --- a/lez/programs/ping_core/src/lib.rs +++ b/lez/programs/ping_core/src/lib.rs @@ -1,3 +1,4 @@ +use borsh::{BorshDeserialize, BorshSerialize}; use lee_core::{ account::AccountId, program::{PdaSeed, ProgramId}, @@ -5,24 +6,79 @@ 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/"; +const RECEIVER_CONFIG_SEED: [u8; 32] = *b"/LEZ/v0.3/PingReceiverCfg/00000/"; +/// Raw 32-byte zone (channel) id, matching the inbox's. +pub type ZoneId = [u8; 32]; -/// Instruction delivered to `ping_receiver` by the inbox: record the payload. +/// Instruction to `ping_receiver`. +/// +/// Variants are append-only, for the same reason `SenderInstruction`'s are. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum ReceiverInstruction { + /// Record the payload, delivered by the inbox on behalf of a peer source + /// this receiver authorizes. + /// + /// Required accounts (3): the source marker, the receiver config PDA, then + /// the record PDA. Record { payload: Vec }, + /// Pins the deliverer and the peer sources it may deliver from, written once + /// into a default config PDA at genesis. A re-run holding anything different + /// is refused; an identical one is a no-op, which is what genesis replay does. + /// + /// Required accounts (1): the receiver config PDA. + InitConfig(ReceiverConfig), } -/// Instruction to `ping_sender`: forwarded verbatim into `cross_zone_outbox::Instruction::Emit`. +/// Who may deliver to this receiver, and which peer sources they may deliver from. +/// +/// `ping_receiver` holds nothing worth stealing, so this is not about value. It is +/// about the record meaning something: without it any program on any configured +/// peer can overwrite the record, and a delivery proves only that some peer sent +/// it. +#[derive(Clone, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize, Serialize, Deserialize)] +pub struct ReceiverConfig { + /// The program allowed to call `Record`: the cross-zone inbox. + pub deliverer: ProgramId, + /// The `(src_zone, src_program_id)` pairs a delivery may originate from. + pub sources: Vec<(ZoneId, ProgramId)>, +} + +impl ReceiverConfig { + #[must_use] + pub fn to_bytes(&self) -> Vec { + borsh::to_vec(self).expect("receiver config serializes") + } + + #[must_use] + pub fn from_bytes(bytes: &[u8]) -> Option { + borsh::from_slice(bytes).ok() + } +} + +/// 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 +92,99 @@ 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) +} + +/// PDA holding the sources `ping_receiver` accepts a delivery from. +#[must_use] +pub fn receiver_config_account_id(receiver_id: ProgramId) -> AccountId { + AccountId::for_public_pda(&receiver_id, &receiver_config_seed()) +} + +#[must_use] +pub const fn receiver_config_seed() -> PdaSeed { + PdaSeed::new(RECEIVER_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); + } + + /// `Record` is serialized by the source zone into the emission payload and + /// decoded by the destination, so its tag word is wire format. + #[test] + fn record_is_the_first_variant() { + let record = ReceiverInstruction::Record { payload: vec![] }; + let words = risc0_zkvm::serde::to_vec(&record).expect("Record serializes"); + assert_eq!(words[0], 0); + } + + #[test] + fn an_empty_receiver_config_does_not_decode() { + assert_eq!(ReceiverConfig::from_bytes(&[]), None); + } + + #[test] + fn receiver_config_round_trips() { + let config = ReceiverConfig { + deliverer: [1; 8], + sources: vec![([7; 32], [9; 8])], + }; + assert_eq!(ReceiverConfig::from_bytes(&config.to_bytes()), Some(config)); + } + + #[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_receiver/Cargo.toml b/lez/programs/ping_receiver/Cargo.toml index a1d88f399..73bbddc44 100644 --- a/lez/programs/ping_receiver/Cargo.toml +++ b/lez/programs/ping_receiver/Cargo.toml @@ -8,5 +8,6 @@ license = { workspace = true } workspace = true [dependencies] +cross_zone_inbox_core.workspace = true lee_core.workspace = true ping_core.workspace = true diff --git a/lez/programs/ping_receiver/src/main.rs b/lez/programs/ping_receiver/src/main.rs index 4fd9679fb..2c74baf25 100644 --- a/lez/programs/ping_receiver/src/main.rs +++ b/lez/programs/ping_receiver/src/main.rs @@ -1,8 +1,12 @@ +use cross_zone_inbox_core::inbox_source_marker_account_id; use lee_core::{ - account::AccountWithMetadata, - program::{AccountPostState, Claim, ProgramInput, ProgramOutput, read_lee_inputs}, + account::{Account, AccountWithMetadata}, + program::{AccountPostState, Claim, ProgramId, ProgramInput, ProgramOutput, read_lee_inputs}, +}; +use ping_core::{ + ReceiverConfig, ReceiverInstruction, ping_record_pda, ping_record_seed, + receiver_config_account_id, receiver_config_seed, }; -use ping_core::{ReceiverInstruction, ping_record_pda, ping_record_seed}; fn main() { let ( @@ -15,21 +19,61 @@ fn main() { instruction_words, ) = read_lee_inputs::(); + match instruction { + ReceiverInstruction::Record { payload } => record( + self_program_id, + caller_program_id, + pre_states, + instruction_words, + payload, + ), + ReceiverInstruction::InitConfig(config) => init_config( + self_program_id, + caller_program_id, + pre_states, + instruction_words, + &config, + ), + } +} + +fn record( + self_program_id: ProgramId, + caller_program_id: Option, + pre_states: Vec, + instruction_words: Vec, + payload: Vec, +) { + // pre_states: [source marker, config PDA, record PDA]. + let [marker, config, record] = <[AccountWithMetadata; 3]>::try_from(pre_states) + .expect("Record requires the source marker, config, and record accounts"); + + assert_eq!( + config.account_id, + receiver_config_account_id(self_program_id), + "Second account must be the receiver config PDA" + ); + let cfg = ReceiverConfig::from_bytes(&config.account.data.clone().into_inner()) + .expect("config account holds a receiver config"); + assert_eq!( + caller_program_id, + Some(cfg.deliverer), + "Record is only callable by the authorized deliverer (the cross-zone inbox)" + ); + // Which peer sent it is this program's own business. Without this the record + // says only that some program on some configured peer wrote it. assert!( - caller_program_id.is_some(), - "ping_receiver is only callable through a chained call" + cfg.sources.iter().any(|(src_zone, src_program_id)| { + marker.account_id + == inbox_source_marker_account_id(cfg.deliverer, src_zone, *src_program_id) + }), + "Record is only callable for a peer source this receiver authorizes" ); - let payload = match instruction { - ReceiverInstruction::Record { payload } => payload, - }; - - let [record] = <[AccountWithMetadata; 1]>::try_from(pre_states) - .expect("Record requires exactly 1 account"); assert_eq!( record.account_id, ping_record_pda(self_program_id), - "Account must be the ping record PDA" + "Third account must be the ping record PDA" ); let mut post_account = record.account.clone(); @@ -41,8 +85,70 @@ fn main() { self_program_id, caller_program_id, instruction_words, - vec![record], - vec![post], + vec![marker.clone(), config.clone(), record], + vec![ + AccountPostState::new(marker.account), + AccountPostState::new(config.account), + post, + ], + ) + .write(); +} + +/// Writes the deliverer and the authorized peer sources 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_value: &ReceiverConfig, +) { + 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, + receiver_config_account_id(self_program_id), + "account must be the receiver config PDA" + ); + // Init-once, idempotent under genesis replay: a `default` config is a first + // init; an already-owned one must already hold exactly this, 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, + "receiver config PDA is owned by another program" + ); + assert_eq!( + config.account.data.clone().into_inner(), + config_value.to_bytes(), + "receiver config already initialized differently" + ); + } + + let mut config_account = config.account.clone(); + config_account.data = config_value + .to_bytes() + .try_into() + .expect("receiver config fits in account data"); + let config_post = AccountPostState::new_claimed_if_default( + config_account, + Claim::Pda(receiver_config_seed()), + ); + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + vec![config], + vec![config_post], ) .write(); } 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/programs/wrapped_token/Cargo.toml b/lez/programs/wrapped_token/Cargo.toml index a80f60fff..3ec0022d6 100644 --- a/lez/programs/wrapped_token/Cargo.toml +++ b/lez/programs/wrapped_token/Cargo.toml @@ -8,5 +8,6 @@ license = { workspace = true } workspace = true [dependencies] +cross_zone_inbox_core.workspace = true lee_core.workspace = true wrapped_token_core.workspace = true diff --git a/lez/programs/wrapped_token/core/Cargo.toml b/lez/programs/wrapped_token/core/Cargo.toml index ef0aabbc8..1791a2d3e 100644 --- a/lez/programs/wrapped_token/core/Cargo.toml +++ b/lez/programs/wrapped_token/core/Cargo.toml @@ -8,6 +8,7 @@ license = { workspace = true } workspace = true [dependencies] +borsh.workspace = true lee_core.workspace = true serde = { workspace = true, features = ["alloc"] } risc0-zkvm.workspace = true diff --git a/lez/programs/wrapped_token/core/src/lib.rs b/lez/programs/wrapped_token/core/src/lib.rs index 2d5e0d775..2fba37c88 100644 --- a/lez/programs/wrapped_token/core/src/lib.rs +++ b/lez/programs/wrapped_token/core/src/lib.rs @@ -2,6 +2,7 @@ //! cross-zone bridge. Only the cross-zone inbox may mint; the guest enforces //! this by reading the authorized minter from a genesis-seeded config account. +use borsh::{BorshDeserialize, BorshSerialize}; use lee_core::{ account::AccountId, program::{PdaSeed, ProgramId}, @@ -22,21 +23,49 @@ pub const MAX_MINT_AMOUNT: u128 = 0xFFFF_FFFF_FFFF_FFFF; const CONFIG_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/WrappedTokenConfig/00/"; const HOLDING_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/WrappedTokenHold/00000"; +/// Raw 32-byte zone (channel) id, matching the inbox's. +pub type ZoneId = [u8; 32]; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum Instruction { /// Credit `amount` wrapped tokens to `recipient`'s holding. Delivered only by - /// the cross-zone inbox. + /// the cross-zone inbox, and only for a peer source this token authorizes. /// - /// Required accounts (2): the wrapped-token config PDA, then the recipient's - /// holding PDA. + /// Required accounts (3): the source marker, 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. + /// Pins the minter and the peer sources it may mint for, written once into a + /// default config PDA at genesis. A re-run holding anything different is + /// refused; an identical one is a no-op, which is what genesis replay does. /// /// Required accounts (1): the wrapped-token config PDA. - InitConfig { minter: ProgramId }, + InitConfig(WrappedTokenConfig), +} + +/// Who may mint, and which peer sources they may mint for. +/// +/// The source list is what makes this token authorize its own inbound value +/// rather than trusting a central route table to have done it. Borsh because the +/// list is variable length. +#[derive(Clone, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize, Serialize, Deserialize)] +pub struct WrappedTokenConfig { + /// The program allowed to call `Mint`: the cross-zone inbox. + pub minter: ProgramId, + /// The `(src_zone, src_program_id)` pairs a mint may originate from. Empty on + /// a zone with no peers, which authorizes nothing. + pub sources: Vec<(ZoneId, ProgramId)>, +} + +impl WrappedTokenConfig { + #[must_use] + pub fn to_bytes(&self) -> Vec { + borsh::to_vec(self).expect("wrapped-token config serializes") + } + + #[must_use] + pub fn from_bytes(bytes: &[u8]) -> Option { + borsh::from_slice(bytes).ok() + } } /// PDA holding the authorized minter program id (the cross-zone inbox), seeded at @@ -71,29 +100,6 @@ pub fn holding_seed(recipient: &[u8; 32]) -> PdaSeed { PdaSeed::new(seed) } -/// Encodes the authorized minter program id for the config account's data. -#[must_use] -pub fn minter_bytes(minter: ProgramId) -> [u8; 32] { - let mut bytes = [0_u8; 32]; - for (word, chunk) in minter.iter().zip(bytes.chunks_exact_mut(4)) { - chunk.copy_from_slice(&word.to_le_bytes()); - } - bytes -} - -/// Decodes the authorized minter program id from the config account's data. -#[must_use] -pub fn read_minter(data: &[u8]) -> Option { - if data.len() < 32 { - return None; - } - let mut minter = [0_u32; 8]; - for (word, chunk) in minter.iter_mut().zip(data[..32].chunks_exact(4)) { - *word = u32::from_le_bytes(chunk.try_into().unwrap_or_else(|_| unreachable!())); - } - Some(minter) -} - /// Reads a wrapped-token balance from account data; empty data is a zero balance. #[must_use] pub fn read_balance(data: &[u8]) -> u128 { @@ -113,9 +119,34 @@ mod tests { use super::*; #[test] - fn minter_round_trips() { - let minter: ProgramId = [1, 2, 3, 4, 5, 6, 7, 8]; - assert_eq!(read_minter(&minter_bytes(minter)), Some(minter)); + fn config_round_trips() { + let config = WrappedTokenConfig { + minter: [1, 2, 3, 4, 5, 6, 7, 8], + sources: vec![([7; 32], [9; 8]), ([8; 32], [4; 8])], + }; + assert_eq!( + WrappedTokenConfig::from_bytes(&config.to_bytes()), + Some(config) + ); + } + + /// An unclaimed config reads as empty, which must not decode to a config that + /// authorizes anything. + #[test] + fn an_empty_config_does_not_decode() { + assert_eq!(WrappedTokenConfig::from_bytes(&[]), None); + } + + /// The peer's `bridge_lock` serializes `Mint` into the emission payload, so + /// its tag word is wire format. + #[test] + fn mint_is_the_first_variant() { + let mint = Instruction::Mint { + recipient: [3; 32], + amount: 1, + }; + let words = risc0_zkvm::serde::to_vec(&mint).expect("Mint serializes"); + assert_eq!(words[0], 0); } #[test] diff --git a/lez/programs/wrapped_token/src/main.rs b/lez/programs/wrapped_token/src/main.rs index 88cb17887..52097b57b 100644 --- a/lez/programs/wrapped_token/src/main.rs +++ b/lez/programs/wrapped_token/src/main.rs @@ -1,10 +1,11 @@ +use cross_zone_inbox_core::inbox_source_marker_account_id; use lee_core::{ account::{Account, AccountWithMetadata}, program::{AccountPostState, Claim, ProgramInput, ProgramOutput, read_lee_inputs}, }; use wrapped_token_core::{ - Instruction, MAX_MINT_AMOUNT, balance_bytes, config_account_id, config_seed, - holding_account_id, holding_seed, minter_bytes, read_balance, read_minter, + Instruction, MAX_MINT_AMOUNT, WrappedTokenConfig, balance_bytes, config_account_id, + config_seed, holding_account_id, holding_seed, read_balance, }; fn main() { @@ -27,12 +28,12 @@ fn main() { recipient, amount, ), - Instruction::InitConfig { minter } => init_config( + Instruction::InitConfig(config) => init_config( self_program_id, caller_program_id, pre_states, instruction_words, - minter, + &config, ), } } @@ -45,29 +46,40 @@ fn mint( 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"); + // pre_states: [source marker, config PDA, recipient holding PDA]. + let [marker, config, holding] = <[AccountWithMetadata; 3]>::try_from(pre_states) + .expect("Mint requires the source marker, config, and recipient holding accounts"); // The config PDA is genesis-seeded with the authorized minter (the cross-zone // inbox). Pin the caller to it, since the guest cannot import the inbox id. assert_eq!( config.account_id, config_account_id(self_program_id), - "first account must be the wrapped-token config PDA" + "second account must be the wrapped-token config PDA" ); - let minter = read_minter(&config.account.data.clone().into_inner()) - .expect("config account holds an authorized minter id"); + let cfg = WrappedTokenConfig::from_bytes(&config.account.data.clone().into_inner()) + .expect("config account holds a wrapped-token config"); assert_eq!( caller_program_id, - Some(minter), + Some(cfg.minter), "Mint is only callable by the authorized minter (the cross-zone inbox)" ); + // The inbox vouches only that the message arrived; which peer sent it is this + // token's own business, and unbacked value is what gets minted if it takes + // anyone's word for it. The marker's address is the source, so re-deriving it + // from an authorized pair is the whole check. + assert!( + cfg.sources.iter().any(|(src_zone, src_program_id)| { + marker.account_id + == inbox_source_marker_account_id(cfg.minter, src_zone, *src_program_id) + }), + "Mint is only callable for a peer source this token authorizes" + ); assert_eq!( holding.account_id, holding_account_id(self_program_id, &recipient), - "second account must be the recipient holding PDA" + "third account must be the recipient holding PDA" ); assert!( @@ -93,19 +105,24 @@ fn mint( self_program_id, caller_program_id, instruction_words, - vec![config, holding], - vec![config_post, holding_post], + vec![marker.clone(), config, holding], + vec![ + AccountPostState::new(marker.account), + config_post, + holding_post, + ], ) .write(); } -/// Writes the authorized minter into the config PDA exactly once at genesis. +/// Writes the minter and the authorized peer sources 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, + config_value: &WrappedTokenConfig, ) { assert!( caller_program_id.is_none(), @@ -133,16 +150,16 @@ fn init_config( ); assert_eq!( config.account.data.clone().into_inner(), - minter_bytes(minter).to_vec(), - "wrapped-token config already initialized with a different minter" + config_value.to_bytes(), + "wrapped-token config already initialized differently" ); } let mut config_account = config.account.clone(); - config_account.data = minter_bytes(minter) - .to_vec() + config_account.data = config_value + .to_bytes() .try_into() - .expect("minter id fits in account data"); + .expect("wrapped-token config fits in account data"); let config_post = AccountPostState::new_claimed_if_default(config_account, Claim::Pda(config_seed())); diff --git a/lez/sequencer/core/src/cross_zone_watcher.rs b/lez/sequencer/core/src/cross_zone_watcher.rs index b48a56e14..9a910df10 100644 --- a/lez/sequencer/core/src/cross_zone_watcher.rs +++ b/lez/sequencer/core/src/cross_zone_watcher.rs @@ -1,8 +1,10 @@ use std::{sync::Arc, time::Duration}; use common::{HashType, block::Block, transaction::LeeTransaction}; -use cross_zone::{EmissionSource, build_dispatch_from_emission, extract_emission}; -use cross_zone_inbox_core::{CrossZoneRoute, message_key, routes_permit}; +use cross_zone::{ + EmissionSource, build_dispatch_from_emission, extract_emission, is_sequencer_only_program, +}; +use cross_zone_inbox_core::message_key; use futures::{Stream, StreamExt as _}; use lee::{GENESIS_BLOCK_ID, PublicKey}; use log::{debug, error, warn}; @@ -36,7 +38,6 @@ const STUCK_SLOT_ALERT_PASSES: u32 = 20; struct PeerContext { peer_zone: [u8; 32], self_zone: [u8; 32], - allowed_routes: Vec, expected_pubkey: Option, } @@ -280,7 +281,6 @@ pub fn spawn_watchers( PeerContext { peer_zone: peer.channel_id, self_zone, - allowed_routes: peer.allowed_routes, expected_pubkey, }, poll_interval, @@ -556,7 +556,6 @@ fn record_block_deliveries( ) -> bool { let peer_zone = peer.peer_zone; let self_zone = peer.self_zone; - 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 @@ -574,16 +573,15 @@ fn record_block_deliveries( if emission.target_zone != self_zone { continue; } - // 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, - ) { + // Targets authorize their own sources now, so this is not authorization, + // it is hygiene: a delivery the zone will certainly refuse still costs a + // pending-list slot and three execution attempts before it is dead + // lettered. Kept host-side only, never in `extract_emission` or the + // verifier's re-derivation, where a check that depends on this build would + // make the two disagree and halt ingestion. + if is_sequencer_only_program(emission.target_program_id) { warn!( - "Watcher dropping message from peer {}: no route from that source program to that target", + "Watcher dropping message from peer {}: a peer may not dispatch into a sequencer-only program", hex::encode(peer_zone) ); continue; @@ -667,7 +665,7 @@ mod tests { }; use logos_blockchain_core::mantle::ops::channel::{MsgId, inscribe::Inscription}; use logos_blockchain_zone_sdk::ZoneBlock; - use ping_core::{SenderInstruction, ping_record_pda}; + use ping_core::{SenderInstruction, ping_record_pda, receiver_config_account_id}; use storage::sequencer::{DB_META_PENDING_CROSS_ZONE_DISPATCHES_KEY, RocksDBIO}; use tempfile::TempDir; @@ -680,10 +678,6 @@ mod tests { PeerContext { peer_zone: PEER_ZONE, self_zone: SELF_ZONE, - allowed_routes: vec![CrossZoneRoute { - src_program_id: programs::ping_sender().id(), - target_program_id: programs::ping_receiver().id(), - }], expected_pubkey: None, } } @@ -707,10 +701,12 @@ 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()], + target_accounts: vec![ + receiver_config_account_id(receiver_id).into_value(), + ping_record_pda(receiver_id).into_value(), + ], payload: b"hi".to_vec(), ordinal: 0, }; @@ -1080,13 +1076,49 @@ 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. + async fn a_delivery_into_a_sequencer_only_program_is_never_recorded() { + // Targets authorize their own sources, so the watcher no longer decides + // who may reach what. It still refuses to queue a delivery the zone will + // certainly refuse: the inbox is injected by this node alone, so a peer + // naming it as a target is junk that would cost a pending slot and three + // execution attempts. + let (_dir, dbio) = store(); + let mut cursor = None; + let mut tip = None; + + let outcome = consume_peer_stream( + stream::iter(vec![peer_block_msg_to( + 1, + 0, + programs::cross_zone_inbox().id(), + )]), + &peer_context(), + &dbio, + &mut cursor, + &mut tip, + ) + .await; + + assert_eq!( + outcome, + PassOutcome::Drained, + "a message the watcher drops is not a failure" + ); + assert!( + recorded_keys(&dbio).is_empty(), + "a message aimed at a sequencer-only program 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 a_delivery_to_an_unrelated_target_is_still_recorded() { + // The watcher is not the authorization point any more. A target it knows + // nothing about is recorded and delivered, and that target decides. let (_dir, dbio) = store(); let mut cursor = None; let mut tip = None; @@ -1104,19 +1136,11 @@ mod tests { ) .await; + assert_eq!(outcome, PassOutcome::Drained); 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" + recorded_keys(&dbio).len(), + 1, + "the watcher records it and lets the target refuse it" ); } diff --git a/lez/sequencer/core/src/lib.rs b/lez/sequencer/core/src/lib.rs index aeb1055d5..941b492b8 100644 --- a/lez/sequencer/core/src/lib.rs +++ b/lez/sequencer/core/src/lib.rs @@ -1523,16 +1523,24 @@ fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec (lee::V03State, Vec) -> LeeTransaction { src_program_id: programs::ping_sender().id(), }, receiver_id, - &[ping_record_pda(receiver_id).into_value()], + &[ + receiver_config_account_id(receiver_id).into_value(), + ping_record_pda(receiver_id).into_value(), + ], payload, )) } diff --git a/test_fixtures/fixtures/prebuilt_sequencer_db.dump b/test_fixtures/fixtures/prebuilt_sequencer_db.dump index 79ac275f1..0fe43eded 100644 Binary files a/test_fixtures/fixtures/prebuilt_sequencer_db.dump and b/test_fixtures/fixtures/prebuilt_sequencer_db.dump differ diff --git a/tools/cross_zone_chat/Cargo.toml b/tools/cross_zone_chat/Cargo.toml index 2c74e6dfa..a7c5de31a 100644 --- a/tools/cross_zone_chat/Cargo.toml +++ b/tools/cross_zone_chat/Cargo.toml @@ -23,3 +23,4 @@ serde = { workspace = true, features = ["derive"] } axum.workspace = true log.workspace = true env_logger.workspace = true +rand.workspace = true diff --git a/tools/cross_zone_chat/src/main.rs b/tools/cross_zone_chat/src/main.rs index dc527abc0..83962eaf8 100644 --- a/tools/cross_zone_chat/src/main.rs +++ b/tools/cross_zone_chat/src/main.rs @@ -29,8 +29,8 @@ clippy::unused_async, clippy::needless_pass_by_value, clippy::infinite_loop, - reason = "Demo binary: stdout banner is the deliverable; ordinal/elapsed arithmetic is \ - bounded at chat scale; the per-zone scanners and finality poller are daemon \ + reason = "Demo binary: stdout banner is the deliverable; elapsed arithmetic is bounded at chat \ + scale; the per-zone scanners and finality poller are daemon \ loops that run for the process lifetime; axum handlers must be `async` and take \ their extractors (State/Json/Query) by value to satisfy the framework's bounds." )] @@ -57,11 +57,14 @@ use common::{block::BedrockStatus, transaction::LeeTransaction}; use cross_zone_inbox_core::{CrossZoneConfig, CrossZonePeer, CrossZoneRoute, Instruction, ZoneId}; use cross_zone_outbox_core::outbox_pda; use lee::{ - ProgramId, PublicTransaction, + Account, ProgramId, PublicTransaction, public_transaction::{Message, WitnessSet}, }; -use log::warn; -use ping_core::{ReceiverInstruction, SenderInstruction, ping_record_pda}; +use log::{info, warn}; +use ping_core::{ + ReceiverInstruction, SenderInstruction, ping_record_pda, receiver_config_account_id, + sender_config_account_id, +}; use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder}; use serde::{Deserialize, Serialize}; use test_fixtures::{ @@ -71,6 +74,10 @@ use test_fixtures::{ const HTTP_PORT: u16 = 8088; const POLL_INTERVAL: Duration = Duration::from_secs(1); +/// Ordinals probed for a free outbox slot before giving up. +const ORDINAL_PROBE_LIMIT: u32 = 1_024; +/// Transient RPC failures tolerated per probed slot. +const RPC_RETRY_LIMIT: u32 = 5; /// One chat message tracked through its cross-zone pipeline. Displayed in the /// receiving zone's column; the stage fields drive the on-page timeline. @@ -127,8 +134,12 @@ struct ZoneRuntime { /// The peer zone's channel id; the target of sends from this zone. other_zone: ZoneId, client: SequencerClient, - /// Monotonic outbox ordinal per (this zone -> peer); each send must use a - /// fresh value because the outbox PDA is claimed only when default. + /// Next outbox ordinal to try for (this zone -> peer). + /// + /// An outbox slot is written once and the ordinal names a slot in a space + /// every user of `ping_sender` shares, so a fixed starting point collides + /// with whatever the chain already holds. Seeded from a free slot found by + /// [`next_free_ordinal`], then incremented per send. ordinal: AtomicU32, } @@ -304,16 +315,26 @@ async fn main() -> Result<()> { .await .context("Failed to set up zone B sequencer")?; + let client_a = sequencer_client(seq_a.addr())?; + let client_b = sequencer_client(seq_b.addr())?; + let ordinal_a = next_free_ordinal(&client_a, &zone_b) + .await + .context("Failed to find a free outbox ordinal for zone A")?; + let ordinal_b = next_free_ordinal(&client_b, &zone_a) + .await + .context("Failed to find a free outbox ordinal for zone B")?; + info!("Outbox ordinals start at A={ordinal_a} B={ordinal_b}"); + let state = Arc::new(AppState { zone_a: ZoneRuntime { other_zone: zone_b, - client: sequencer_client(seq_a.addr())?, - ordinal: AtomicU32::new(0), + client: client_a, + ordinal: AtomicU32::new(ordinal_a), }, zone_b: ZoneRuntime { other_zone: zone_a, - client: sequencer_client(seq_b.addr())?, - ordinal: AtomicU32::new(0), + client: client_b, + ordinal: AtomicU32::new(ordinal_b), }, next_id: AtomicU64::new(1), messages: Mutex::new(Vec::new()), @@ -365,6 +386,55 @@ fn sequencer_client(addr: SocketAddr) -> Result { .context("Failed to build sequencer client") } +/// A free outbox slot for this zone's `ping_sender` to start counting from: the +/// first unwritten one at or after a random ordinal. +/// +/// An outbox slot is written once, so a send into an occupied one fails at block +/// production, and `send_transaction` checks a transaction only statelessly, so +/// nothing tells the sender. The predicate here is the one the guest asserts, +/// asked before submitting instead of after. +/// +/// Random rather than zero because the ordinal space is shared by every user of +/// `ping_sender` and a slot costs one unsigned transaction to occupy, so any +/// fixed starting point can be squatted. On a chain this process owns, which is +/// what `just cross-zone-chat` boots, nothing is occupied and this returns on +/// its first try; it earns its keep against a chain the tool did not create. +/// +/// It only places the first send. Later ordinals come from incrementing, so a +/// slot taken after this returns still collides, at a probability of the +/// occupied count over 2^32. +async fn next_free_ordinal(client: &SequencerClient, target_zone: &ZoneId) -> Result { + let outbox_id = programs::cross_zone_outbox().id(); + let emitter = programs::ping_sender().id(); + let start: u32 = rand::random(); + + for offset in 0..ORDINAL_PROBE_LIMIT { + let ordinal = start.wrapping_add(offset); + let slot = outbox_pda(outbox_id, emitter, target_zone, ordinal); + // Retried rather than propagated: by here the run has already paid for a + // Bedrock bring-up and two sequencer boots, and every other RPC caller + // in this tool rides out a transient error rather than ending the run. + let mut attempt = 0_u32; + let account = loop { + match client.get_account(slot).await { + Ok(account) => break account, + Err(err) if attempt < RPC_RETRY_LIMIT => { + attempt += 1; + warn!("Outbox probe failed, retrying ({attempt}/{RPC_RETRY_LIMIT}): {err}"); + tokio::time::sleep(POLL_INTERVAL).await; + } + Err(err) => { + return Err(err).context("Failed to read an outbox slot while probing"); + } + } + }; + if account == Account::default() { + return Ok(ordinal); + } + } + anyhow::bail!("No free outbox ordinal in {ORDINAL_PROBE_LIMIT} tried from {start}") +} + /// Scans one zone's new blocks. A `ping_sender` tx marks the message's source /// block (its outbound leg); an inbox dispatch marks delivery on this zone. /// Runs forever; transient RPC errors are logged and retried. @@ -455,7 +525,9 @@ fn decode_inbox_text(instruction_data: &[u32]) -> 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) } @@ -470,7 +542,9 @@ fn decode_payload(payload: &[u8]) -> Option { .collect(); let instruction: ReceiverInstruction = risc0_zkvm::serde::from_slice::(&words).ok()?; - let ReceiverInstruction::Record { payload: bytes } = instruction; + let ReceiverInstruction::Record { payload: bytes } = instruction else { + return None; + }; Some(String::from_utf8_lossy(&bytes).into_owned()) } @@ -487,18 +561,21 @@ 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()], + target_accounts: vec![ + receiver_config_account_id(receiver_id).into_value(), + ping_record_pda(receiver_id).into_value(), + ], payload, ordinal, }; - let outbox_account = outbox_pda(outbox_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, )