feat(cross-zone)!: add wrapped_token and bridge_lock programs

BREAKING CHANGE: registers the wrapped_token and bridge_lock builtin programs and seeds the wrapped_token config account at genesis, changing the genesis state.
This commit is contained in:
moudyellaz 2026-06-24 10:08:55 +02:00
parent 60741a7191
commit 5d77359fd8
14 changed files with 506 additions and 1 deletions

30
Cargo.lock generated
View File

@ -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"

View File

@ -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" }

Binary file not shown.

Binary file not shown.

View File

@ -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

View File

@ -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.

View File

@ -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
};

View File

@ -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 }

View File

@ -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::<Instruction>();
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<u32> = 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")
}

View File

@ -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::<Instruction>();
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();
}

View File

@ -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"]

View File

@ -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<u8>,
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));
}
}

View File

@ -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

View File

@ -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<ProgramId> {
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]));
}
}