From 421e76b946b647b7f4fa5e2a4db7977703eda341 Mon Sep 17 00:00:00 2001 From: moudyellaz Date: Sun, 9 Aug 2026 19:02:28 +0200 Subject: [PATCH] feat(cross-zone)!: pin bridge_lock's outbox, mint target, and mint accounts BREAKING CHANGE: Instruction::Lock drops outbox_program_id and takes the bridge-lock config PDA as its first account, changing both the instruction encoding and the account list. bridge_lock's image id moves, relocating its escrow and config PDAs and requiring a fresh genesis. Sequencer, indexer and every peer zone must upgrade together: a stale decoder re-derives a different dispatch and reports Forged. --- Cargo.lock | 1 + integration_tests/tests/cross_zone_bridge.rs | 19 +- .../tests/cross_zone_state_machine.rs | 348 ++++++++++++++++-- lez/cross_zone/src/lib.rs | 22 +- lez/programs/bridge_lock/core/Cargo.toml | 3 + lez/programs/bridge_lock/core/src/lib.rs | 96 ++++- lez/programs/bridge_lock/src/main.rs | 168 +++++++-- lez/sequencer/core/src/lib.rs | 14 +- 8 files changed, 594 insertions(+), 77 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f3063ba77..da68f21bf 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", ] diff --git a/integration_tests/tests/cross_zone_bridge.rs b/integration_tests/tests/cross_zone_bridge.rs index 3d3ee8cf0..76b087626 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; @@ -156,19 +154,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, - programs::bridge_lock().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_state_machine.rs b/integration_tests/tests/cross_zone_state_machine.rs index 968fc38a9..3cf842772 100644 --- a/integration_tests/tests/cross_zone_state_machine.rs +++ b/integration_tests/tests/cross_zone_state_machine.rs @@ -113,6 +113,26 @@ fn seed_ping_sender_config(state: &mut V03State) { )]); } +/// 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() + }, + )]); +} + /// 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 { @@ -299,33 +319,12 @@ fn lock_escrows_balance_and_emits_to_outbox() { ..Default::default() }, )]); + seed_bridge_lock_config(&mut state); let payload = mint_payload(); - let target_accounts = vec![ - wrapped_token_core::config_account_id(wrapped_token_id).into_value(), - wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT).into_value(), - ]; - let lock = bridge_lock_core::Instruction::Lock { - amount: LOCK_AMOUNT, - target_zone: zone_b, - target_program_id: wrapped_token_id, - target_accounts, - payload: payload.clone(), - outbox_program_id: outbox_id, - ordinal, - }; - let escrow_id = bridge_lock_core::escrow_account_id(bridge_lock_id); let outbox_record_id = outbox_pda(outbox_id, bridge_lock_id, &zone_b, ordinal); - let message = Message::try_new( - bridge_lock_id, - vec![holder_id, escrow_id, outbox_record_id], - vec![0_u128.into()], - lock, - ) - .expect("build lock message"); - let witness = WitnessSet::for_message(&message, &[&holder_key]); - let tx = PublicTransaction::new(message, witness); + let tx = lock_tx(&holder_key, holder_id, zone_b, ordinal, 0); let diff = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0) .expect("lock must validate and execute"); @@ -365,25 +364,53 @@ fn lock_tx( ordinal: u32, nonce: u128, ) -> PublicTransaction { - let bridge_lock_id = programs::bridge_lock().id(); 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: wrapped_token_id, - target_accounts: vec![ - wrapped_token_core::config_account_id(wrapped_token_id).into_value(), - wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT).into_value(), - ], + target_program_id, + target_accounts, payload: mint_payload(), - outbox_program_id: outbox_id, 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), @@ -415,6 +442,7 @@ fn a_second_emit_at_the_same_slot_is_rejected() { ..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) @@ -463,6 +491,7 @@ fn two_emitters_share_an_ordinal_without_colliding() { }, )]); 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); @@ -533,6 +562,265 @@ fn a_send_into_a_foreign_outbox_slot_is_rejected() { ); } +/// 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] diff --git a/lez/cross_zone/src/lib.rs b/lez/cross_zone/src/lib.rs index d0eafd6fd..b868e916c 100644 --- a/lez/cross_zone/src/lib.rs +++ b/lez/cross_zone/src/lib.rs @@ -83,13 +83,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, @@ -234,6 +237,21 @@ pub fn build_ping_sender_init_config_tx() -> lee::PublicTransaction { ) } +/// 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(), + }, + ) +} + /// Builds an unsigned, sequencer-origin genesis transaction invoking `instruction` /// on `program_id` over `account_ids`. fn genesis_public_tx( 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/sequencer/core/src/lib.rs b/lez/sequencer/core/src/lib.rs index 7cf5b260c..13c5e5ec8 100644 --- a/lez/sequencer/core/src/lib.rs +++ b/lez/sequencer/core/src/lib.rs @@ -1523,14 +1523,15 @@ fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec (lee::V03State, Vec