diff --git a/Cargo.lock b/Cargo.lock index ebe1637b..d68d52fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1145,6 +1145,15 @@ dependencies = [ "serde", ] +[[package]] +name = "bridge_lock_core" +version = "0.1.0" +dependencies = [ + "lee", + "lee_core", + "serde", +] + [[package]] name = "bs58" version = "0.5.1" @@ -1801,8 +1810,10 @@ name = "cross_zone_inbox_core" version = "0.1.0" dependencies = [ "borsh", + "bridge_lock_core", "lee", "lee_core", + "ping_core", "risc0-zkvm", "serde", ] @@ -3823,6 +3834,7 @@ dependencies = [ "async-stream", "authenticated_transfer_core", "borsh", + "bridge_lock_core", "common", "cross_zone_inbox_core", "futures", @@ -3988,8 +4000,10 @@ dependencies = [ "authenticated_transfer_core", "borsh", "bridge_core", + "bridge_lock_core", "bytesize", "common", + "cross_zone_inbox_core", "cross_zone_outbox_core", "faucet_core", "futures", @@ -4019,6 +4033,7 @@ dependencies = [ "vault_core", "wallet", "wallet-ffi", + "wrapped_token_core", ] [[package]] @@ -4530,8 +4545,10 @@ dependencies = [ "authenticated_transfer_core", "borsh", "bridge_core", + "bridge_lock_core", "clock_core", "cross_zone_inbox_core", + "cross_zone_outbox_core", "env_logger", "faucet_core", "hex", @@ -4551,6 +4568,7 @@ dependencies = [ "test_program_methods", "thiserror 2.0.18", "token_core", + "wrapped_token_core", ] [[package]] @@ -7421,6 +7439,7 @@ dependencies = [ "ata_program", "authenticated_transfer_core", "bridge_core", + "bridge_lock_core", "clock_core", "cross_zone_inbox_core", "cross_zone_outbox_core", @@ -7432,6 +7451,7 @@ dependencies = [ "token_core", "token_program", "vault_core", + "wrapped_token_core", ] [[package]] @@ -8867,6 +8887,7 @@ dependencies = [ "anyhow", "borsh", "bridge_core", + "bridge_lock_core", "bytesize", "chrono", "common", @@ -11475,6 +11496,15 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wrapped_token_core" +version = "0.1.0" +dependencies = [ + "lee_core", + "risc0-zkvm", + "serde", +] + [[package]] name = "writeable" version = "0.6.3" diff --git a/Cargo.toml b/Cargo.toml index 0fd60a92..f1410330 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,8 @@ members = [ "programs/cross_zone_outbox/core", "programs/cross_zone_inbox/core", "programs/ping/core", + "programs/wrapped_token/core", + "programs/bridge_lock/core", "lez/sequencer/core", "lez/sequencer/service", "lez/sequencer/service/protocol", @@ -86,6 +88,8 @@ vault_core = { path = "programs/vault/core" } cross_zone_outbox_core = { path = "programs/cross_zone_outbox/core" } cross_zone_inbox_core = { path = "programs/cross_zone_inbox/core" } ping_core = { path = "programs/ping/core" } +wrapped_token_core = { path = "programs/wrapped_token/core" } +bridge_lock_core = { path = "programs/bridge_lock/core" } test_program_methods = { path = "test_program_methods" } testnet_initial_state = { path = "lez/testnet_initial_state" } keycard_wallet = { path = "lez/keycard_wallet" } diff --git a/artifacts/program_methods/bridge_lock.bin b/artifacts/program_methods/bridge_lock.bin new file mode 100644 index 00000000..d4aa0cd1 Binary files /dev/null and b/artifacts/program_methods/bridge_lock.bin differ diff --git a/artifacts/program_methods/wrapped_token.bin b/artifacts/program_methods/wrapped_token.bin new file mode 100644 index 00000000..09f69b1e Binary files /dev/null and b/artifacts/program_methods/wrapped_token.bin differ diff --git a/lee/state_machine/Cargo.toml b/lee/state_machine/Cargo.toml index 7337cb47..c4521f5e 100644 --- a/lee/state_machine/Cargo.toml +++ b/lee/state_machine/Cargo.toml @@ -12,6 +12,7 @@ lee_core = { workspace = true, features = ["host"] } clock_core.workspace = true faucet_core.workspace = true bridge_core.workspace = true +wrapped_token_core.workspace = true anyhow.workspace = true thiserror.workspace = true diff --git a/lee/state_machine/src/program.rs b/lee/state_machine/src/program.rs index e5ee73d6..46629f0f 100644 --- a/lee/state_machine/src/program.rs +++ b/lee/state_machine/src/program.rs @@ -14,7 +14,7 @@ use crate::{ CLOCK_ID, CROSS_ZONE_INBOX_ELF, CROSS_ZONE_INBOX_ID, CROSS_ZONE_OUTBOX_ELF, CROSS_ZONE_OUTBOX_ID, FAUCET_ELF, FAUCET_ID, PINATA_ELF, PINATA_ID, PING_RECEIVER_ELF, PING_RECEIVER_ID, PING_SENDER_ELF, PING_SENDER_ID, TOKEN_ELF, TOKEN_ID, VAULT_ELF, - VAULT_ID, + VAULT_ID, BRIDGE_LOCK_ELF, BRIDGE_LOCK_ID, WRAPPED_TOKEN_ELF, WRAPPED_TOKEN_ID, }, }; @@ -207,6 +207,22 @@ impl Program { elf: PING_SENDER_ELF.to_vec(), } } + + #[must_use] + pub fn bridge_lock() -> Self { + Self { + id: BRIDGE_LOCK_ID, + elf: BRIDGE_LOCK_ELF.to_vec(), + } + } + + #[must_use] + pub fn wrapped_token() -> Self { + Self { + id: WRAPPED_TOKEN_ID, + elf: WRAPPED_TOKEN_ELF.to_vec(), + } + } } // TODO: Testnet only. Refactor to prevent compilation on mainnet. diff --git a/lee/state_machine/src/state.rs b/lee/state_machine/src/state.rs index cbcbad53..1f3484fd 100644 --- a/lee/state_machine/src/state.rs +++ b/lee/state_machine/src/state.rs @@ -201,6 +201,23 @@ impl V03State { this.insert_program(Program::cross_zone_inbox()); this.insert_program(Program::ping_sender()); this.insert_program(Program::ping_receiver()); + this.insert_program(Program::bridge_lock()); + this.insert_program(Program::wrapped_token()); + + // Seed the wrapped-token config with its authorized minter (the cross-zone + // inbox), so the guest can pin its caller without importing the inbox id. + let wrapped_token_id = Program::wrapped_token().id(); + this.public_state.insert( + wrapped_token_core::config_account_id(wrapped_token_id), + Account { + program_owner: wrapped_token_id, + data: wrapped_token_core::minter_bytes(Program::cross_zone_inbox().id()) + .to_vec() + .try_into() + .expect("minter id fits in account data"), + ..Account::default() + }, + ); this } @@ -705,6 +722,17 @@ pub mod tests { }, ); } + this.insert( + wrapped_token_core::config_account_id(Program::wrapped_token().id()), + Account { + program_owner: Program::wrapped_token().id(), + data: wrapped_token_core::minter_bytes(Program::cross_zone_inbox().id()) + .to_vec() + .try_into() + .unwrap(), + ..Account::default() + }, + ); this }; let expected_builtin_programs = { @@ -727,6 +755,8 @@ pub mod tests { this.insert(Program::cross_zone_inbox().id(), Program::cross_zone_inbox()); this.insert(Program::ping_sender().id(), Program::ping_sender()); this.insert(Program::ping_receiver().id(), Program::ping_receiver()); + this.insert(Program::bridge_lock().id(), Program::bridge_lock()); + this.insert(Program::wrapped_token().id(), Program::wrapped_token()); this }; diff --git a/program_methods/guest/Cargo.toml b/program_methods/guest/Cargo.toml index 318af4b6..3db42314 100644 --- a/program_methods/guest/Cargo.toml +++ b/program_methods/guest/Cargo.toml @@ -23,5 +23,7 @@ vault_core.workspace = true cross_zone_outbox_core.workspace = true cross_zone_inbox_core.workspace = true ping_core.workspace = true +wrapped_token_core.workspace = true +bridge_lock_core.workspace = true risc0-zkvm.workspace = true serde = { workspace = true, default-features = false } diff --git a/program_methods/guest/src/bin/bridge_lock.rs b/program_methods/guest/src/bin/bridge_lock.rs new file mode 100644 index 00000000..0d278d51 --- /dev/null +++ b/program_methods/guest/src/bin/bridge_lock.rs @@ -0,0 +1,117 @@ +use bridge_lock_core::{Instruction, balance_bytes, escrow_account_id, escrow_seed, read_balance}; +use cross_zone_outbox_core::Instruction as OutboxInstruction; +use lee_core::{ + account::AccountWithMetadata, + program::{AccountPostState, ChainedCall, Claim, ProgramInput, ProgramOutput, read_lee_inputs}, +}; +use wrapped_token_core::Instruction as WrappedInstruction; + +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + instruction, + }, + instruction_words, + ) = read_lee_inputs::(); + + assert!( + caller_program_id.is_none(), + "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; + + // Value conservation: the forwarded payload must mint exactly what is locked. + let WrappedInstruction::Mint { + amount: mint_amount, + .. + } = decode_mint(&payload); + assert_eq!( + mint_amount, amount, + "locked amount must equal the wrapped mint amount" + ); + + // 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"); + assert_eq!( + holder.account.program_owner, self_program_id, + "holder account must be a bridge_lock holding" + ); + assert_eq!( + escrow.account_id, + escrow_account_id(self_program_id), + "second account must be the escrow PDA" + ); + + let holder_new = read_balance(&holder.account.data.clone().into_inner()) + .checked_sub(amount) + .expect("insufficient balance to lock"); + let escrow_new = read_balance(&escrow.account.data.clone().into_inner()) + .checked_add(amount) + .expect("escrow balance overflow"); + + let mut holder_account = holder.account.clone(); + holder_account.data = balance_bytes(holder_new) + .to_vec() + .try_into() + .expect("balance fits in account data"); + let holder_post = AccountPostState::new(holder_account); + + let mut escrow_account = escrow.account.clone(); + escrow_account.data = balance_bytes(escrow_new) + .to_vec() + .try_into() + .expect("balance fits in account data"); + let escrow_post = + AccountPostState::new_claimed_if_default(escrow_account, Claim::Pda(escrow_seed())); + + let call = ChainedCall::new( + outbox_program_id, + vec![outbox.clone()], + &OutboxInstruction::Emit { + target_zone, + target_program_id, + target_accounts, + payload, + ordinal, + }, + ); + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + vec![holder, escrow, outbox.clone()], + vec![holder_post, escrow_post, AccountPostState::new(outbox.account)], + ) + .with_chained_calls(vec![call]) + .write(); +} + +/// Decodes the cross-zone payload (risc0 words, little-endian bytes) into the +/// wrapped-token instruction it carries. +fn decode_mint(payload: &[u8]) -> WrappedInstruction { + assert!( + payload.len() % 4 == 0, + "payload must be u32-aligned instruction words" + ); + let words: Vec = payload + .chunks_exact(4) + .map(|chunk| u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])) + .collect(); + risc0_zkvm::serde::from_slice(&words).expect("payload decodes to a wrapped-token instruction") +} diff --git a/program_methods/guest/src/bin/wrapped_token.rs b/program_methods/guest/src/bin/wrapped_token.rs new file mode 100644 index 00000000..2fb4c6af --- /dev/null +++ b/program_methods/guest/src/bin/wrapped_token.rs @@ -0,0 +1,68 @@ +use lee_core::{ + account::AccountWithMetadata, + program::{AccountPostState, Claim, ProgramInput, ProgramOutput, read_lee_inputs}, +}; +use wrapped_token_core::{ + Instruction, balance_bytes, config_account_id, holding_account_id, holding_seed, read_balance, + read_minter, +}; + +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + instruction, + }, + instruction_words, + ) = read_lee_inputs::(); + + let Instruction::Mint { recipient, amount } = instruction; + + // 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"); + + // 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" + ); + let minter = read_minter(&config.account.data.clone().into_inner()) + .expect("config account holds an authorized minter id"); + assert_eq!( + caller_program_id, + Some(minter), + "Mint is only callable by the authorized minter (the cross-zone inbox)" + ); + + assert_eq!( + holding.account_id, + holding_account_id(self_program_id, &recipient), + "second account must be the recipient holding PDA" + ); + + let new_balance = read_balance(&holding.account.data.clone().into_inner()) + .checked_add(amount) + .expect("wrapped-token balance overflow"); + let mut holding_account = holding.account.clone(); + holding_account.data = balance_bytes(new_balance) + .to_vec() + .try_into() + .expect("balance fits in account data"); + let holding_post = + AccountPostState::new_claimed_if_default(holding_account, Claim::Pda(holding_seed(&recipient))); + let config_post = AccountPostState::new(config.account.clone()); + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + vec![config, holding], + vec![config_post, holding_post], + ) + .write(); +} diff --git a/programs/bridge_lock/core/Cargo.toml b/programs/bridge_lock/core/Cargo.toml new file mode 100644 index 00000000..2c9e2f58 --- /dev/null +++ b/programs/bridge_lock/core/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "bridge_lock_core" +version = "0.1.0" +edition = "2024" +license = { workspace = true } + +[lints] +workspace = true + +[dependencies] +lee_core.workspace = true +serde = { workspace = true, features = ["alloc"] } +lee = { workspace = true, optional = true } + +[features] +# Host-only genesis helper; pulls `lee`, so the risc0 guest builds without it. +host = ["dep:lee"] diff --git a/programs/bridge_lock/core/src/lib.rs b/programs/bridge_lock/core/src/lib.rs new file mode 100644 index 00000000..eae1d681 --- /dev/null +++ b/programs/bridge_lock/core/src/lib.rs @@ -0,0 +1,92 @@ +//! Core types for the bridge-lock program, the source side of the cross-zone +//! token bridge. A holder locks part of their balance into an escrow and emits a +//! cross-zone message minting the wrapped token on the target zone. + +use lee_core::{ + account::AccountId, + program::{PdaSeed, ProgramId}, +}; +use serde::{Deserialize, Serialize}; + +const ESCROW_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/BridgeLockEscrow/0000/"; + +#[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. + /// + /// Required accounts (3): 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, + }, +} + +/// PDA accumulating all locked balance on this zone. +#[must_use] +pub fn escrow_account_id(bridge_lock_id: ProgramId) -> AccountId { + AccountId::for_public_pda(&bridge_lock_id, &escrow_seed()) +} + +#[must_use] +pub fn escrow_seed() -> PdaSeed { + PdaSeed::new(ESCROW_SEED_DOMAIN) +} + +/// Reads a bridgeable balance from account data; empty data is a zero balance. +#[must_use] +pub fn read_balance(data: &[u8]) -> u128 { + if data.len() < 16 { + return 0; + } + u128::from_le_bytes(data[..16].try_into().unwrap_or_else(|_| unreachable!())) +} + +#[must_use] +pub fn balance_bytes(amount: u128) -> [u8; 16] { + amount.to_le_bytes() +} + +/// Builds the genesis holding account funding a holder's bridgeable balance: +/// owned by bridge_lock, data is the LE balance, at the holder's account id. It +/// is not produced by any transaction, so the sequencer and the indexer both +/// seed it through this one builder to keep their genesis states identical. +#[cfg(feature = "host")] +#[must_use] +pub fn build_holding_account( + holder: AccountId, + amount: u128, +) -> (AccountId, lee_core::account::Account) { + let account = lee_core::account::Account { + program_owner: lee::program::Program::bridge_lock().id(), + data: balance_bytes(amount) + .to_vec() + .try_into() + .expect("balance fits in account data"), + ..Default::default() + }; + (holder, account) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn balance_round_trips() { + assert_eq!(read_balance(&balance_bytes(7)), 7); + assert_eq!(read_balance(&[]), 0); + } + + #[test] + fn escrow_is_stable() { + let id: ProgramId = [4; 8]; + assert_eq!(escrow_account_id(id), escrow_account_id(id)); + } +} diff --git a/programs/wrapped_token/core/Cargo.toml b/programs/wrapped_token/core/Cargo.toml new file mode 100644 index 00000000..ef0aabbc --- /dev/null +++ b/programs/wrapped_token/core/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "wrapped_token_core" +version = "0.1.0" +edition = "2024" +license = { workspace = true } + +[lints] +workspace = true + +[dependencies] +lee_core.workspace = true +serde = { workspace = true, features = ["alloc"] } +risc0-zkvm.workspace = true diff --git a/programs/wrapped_token/core/src/lib.rs b/programs/wrapped_token/core/src/lib.rs new file mode 100644 index 00000000..7c953af2 --- /dev/null +++ b/programs/wrapped_token/core/src/lib.rs @@ -0,0 +1,115 @@ +//! Core types for the wrapped-token program, the destination side of the +//! 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 lee_core::{ + account::AccountId, + program::{PdaSeed, ProgramId}, +}; +use serde::{Deserialize, Serialize}; + +const CONFIG_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/WrappedTokenConfig/00/"; +const HOLDING_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/WrappedTokenHold/00000"; + +#[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. + /// + /// Required accounts (2): the wrapped-token config PDA, then the recipient's + /// holding PDA. + Mint { recipient: [u8; 32], amount: u128 }, +} + +/// PDA holding the authorized minter program id (the cross-zone inbox), seeded at +/// genesis so the guest can pin its caller without importing the inbox image id. +#[must_use] +pub fn config_account_id(wrapped_token_id: ProgramId) -> AccountId { + AccountId::for_public_pda(&wrapped_token_id, &config_seed()) +} + +#[must_use] +pub fn config_seed() -> PdaSeed { + PdaSeed::new(CONFIG_SEED_DOMAIN) +} + +/// PDA holding one recipient's wrapped-token balance. +#[must_use] +pub fn holding_account_id(wrapped_token_id: ProgramId, recipient: &[u8; 32]) -> AccountId { + AccountId::for_public_pda(&wrapped_token_id, &holding_seed(recipient)) +} + +#[must_use] +pub fn holding_seed(recipient: &[u8; 32]) -> PdaSeed { + use risc0_zkvm::sha::{Impl, Sha256 as _}; + + let mut bytes = Vec::with_capacity(HOLDING_SEED_DOMAIN.len() + recipient.len()); + bytes.extend_from_slice(&HOLDING_SEED_DOMAIN); + bytes.extend_from_slice(recipient); + let seed: [u8; 32] = Impl::hash_bytes(&bytes) + .as_bytes() + .try_into() + .unwrap_or_else(|_| unreachable!()); + 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 { + if data.len() < 16 { + return 0; + } + u128::from_le_bytes(data[..16].try_into().unwrap_or_else(|_| unreachable!())) +} + +#[must_use] +pub fn balance_bytes(amount: u128) -> [u8; 16] { + amount.to_le_bytes() +} + +#[cfg(test)] +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)); + } + + #[test] + fn balance_round_trips() { + assert_eq!(read_balance(&balance_bytes(42)), 42); + assert_eq!(read_balance(&[]), 0); + } + + #[test] + fn holding_is_unique_per_recipient() { + let id: ProgramId = [9; 8]; + assert_ne!(holding_account_id(id, &[1; 32]), holding_account_id(id, &[2; 32])); + assert_eq!(holding_account_id(id, &[1; 32]), holding_account_id(id, &[1; 32])); + } +}