From 4fd465b5296bd1a9c2bb0d1f976fd9c54a92b162 Mon Sep 17 00:00:00 2001 From: moudyellaz Date: Wed, 24 Jun 2026 11:13:06 +0200 Subject: [PATCH] test(cross-zone): add lock-on-A mint-on-B bridge round trip --- integration_tests/Cargo.toml | 2 + integration_tests/tests/cross_zone_bridge.rs | 184 ++++++++++++ lee/state_machine/Cargo.toml | 2 + .../src/cross_zone_bridge_tests.rs | 275 ++++++++++++++++++ lee/state_machine/src/lib.rs | 3 + 5 files changed, 466 insertions(+) create mode 100644 integration_tests/tests/cross_zone_bridge.rs create mode 100644 lee/state_machine/src/cross_zone_bridge_tests.rs diff --git a/integration_tests/Cargo.toml b/integration_tests/Cargo.toml index c460b881..ba4c4500 100644 --- a/integration_tests/Cargo.toml +++ b/integration_tests/Cargo.toml @@ -25,6 +25,8 @@ faucet_core.workspace = true bridge_core.workspace = true ping_core.workspace = true cross_zone_outbox_core.workspace = true +bridge_lock_core.workspace = true +wrapped_token_core.workspace = true risc0-zkvm.workspace = true indexer_service_rpc = { workspace = true, features = ["client"] } sequencer_service_rpc = { workspace = true, features = ["client"] } diff --git a/integration_tests/tests/cross_zone_bridge.rs b/integration_tests/tests/cross_zone_bridge.rs new file mode 100644 index 00000000..689558ad --- /dev/null +++ b/integration_tests/tests/cross_zone_bridge.rs @@ -0,0 +1,184 @@ +#![expect( + clippy::tests_outside_test_module, + clippy::arithmetic_side_effects, + reason = "We don't care about these in tests" +)] + +//! Demo 2: a wrapped-token bridge over the cross-zone spine. A holder locks part +//! of their bridgeable balance on zone A; the watcher carries the emitted mint to +//! zone B, where the indexer re-derives and verifies it (Option B) before the +//! 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. + +use std::{net::SocketAddr, time::Duration}; + +use anyhow::{Context as _, Result}; +use common::transaction::LeeTransaction; +use cross_zone_outbox_core::outbox_pda; +use integration_tests::{ + config::{self, SequencerPartialConfig}, + indexer_client::IndexerClient, + setup::{setup_bedrock_node, setup_indexer, setup_sequencer}, +}; +use lee::{ + AccountId, PrivateKey, PublicKey, PublicTransaction, + program::Program, + public_transaction::{Message, WitnessSet}, +}; +use sequencer_core::config::{CrossZoneConfig, CrossZonePeer, GenesisAction}; +use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder}; +use tokio::test; + +const DELIVERY_TIMEOUT: Duration = Duration::from_secs(600); +const INITIAL_BALANCE: u128 = 100; +const LOCK_AMOUNT: u128 = 30; +const RECIPIENT: [u8; 32] = [9; 32]; + +#[test] +async fn lock_on_zone_a_mints_wrapped_token_on_zone_b() -> Result<()> { + // Declared first so it outlives both zones (drops run in reverse order). + let (_bedrock, bedrock_addr) = setup_bedrock_node() + .await + .context("Failed to set up shared Bedrock node")?; + + let partial = SequencerPartialConfig::default(); + let channel_a = config::bedrock_channel_id(); + let channel_b = config::bedrock_channel_id_b(); + let zone_b: [u8; 32] = *channel_b.as_ref(); + + let holder_key = PrivateKey::try_new([7; 32]).expect("valid key"); + let holder_id = AccountId::from(&PublicKey::new_from_private_key(&holder_key)); + + let wrapped_token_id = Program::wrapped_token().id(); + let cross_zone = CrossZoneConfig { + peers: vec![CrossZonePeer { + channel_id: *channel_a.as_ref(), + allowed_targets: vec![wrapped_token_id], + }], + }; + + // Zone A seeds the holder's bridgeable balance. Zone B runs the watcher on its + // sequencer and the verifier on its indexer. + let genesis_a = vec![GenesisAction::SupplyBridgeLockHolding { + holder: holder_id, + amount: INITIAL_BALANCE, + }]; + let (seq_a, _seq_a_home) = setup_sequencer(partial, bedrock_addr, genesis_a, channel_a, None) + .await + .context("Failed to set up zone A sequencer")?; + let (_seq_b, _seq_b_home) = + setup_sequencer(partial, bedrock_addr, vec![], channel_b, Some(cross_zone.clone())) + .await + .context("Failed to set up zone B sequencer")?; + let (idx_b, _idx_b_home) = setup_indexer(bedrock_addr, channel_b, Some(cross_zone)) + .await + .context("Failed to set up zone B indexer")?; + + // Lock LOCK_AMOUNT on zone A, addressed to the recipient on zone B. + let lock = build_lock_tx(&holder_key, holder_id, zone_b); + sequencer_client(seq_a.addr())? + .send_transaction(lock) + .await + .context("Failed to submit lock on zone A")?; + + // Wait until zone B's indexer reflects the verified mint. + let holding_id = wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT); + let indexer_url = config::addr_to_url(config::UrlProtocol::Ws, idx_b.addr()) + .context("Failed to build indexer URL")?; + let indexer = IndexerClient::new(&indexer_url) + .await + .context("Failed to build indexer client")?; + + let minted = wait_for_mint(&indexer, holding_id).await?; + assert_eq!( + minted, LOCK_AMOUNT, + "zone B must mint exactly the locked amount" + ); + + // Conservation: the mint on B must be backed by an equal lock on A. The lock + // has already landed (it preceded delivery), so zone A reflects the debit and + // escrow now. + let seq_a_client = sequencer_client(seq_a.addr())?; + let escrow_id = bridge_lock_core::escrow_account_id(Program::bridge_lock().id()); + let escrowed = + bridge_lock_core::read_balance(&seq_a_client.get_account(escrow_id).await?.data.into_inner()); + assert_eq!(escrowed, LOCK_AMOUNT, "zone A escrow must hold the locked amount"); + let remaining = + bridge_lock_core::read_balance(&seq_a_client.get_account(holder_id).await?.data.into_inner()); + assert_eq!( + remaining, + INITIAL_BALANCE - LOCK_AMOUNT, + "zone A holder must be debited by the locked amount" + ); + Ok(()) +} + +/// Builds a signed bridge_lock Lock that forwards a wrapped-token Mint of the +/// locked amount to the recipient on the target zone. +fn build_lock_tx(holder_key: &PrivateKey, holder_id: AccountId, target_zone: [u8; 32]) -> LeeTransaction { + let bridge_lock_id = Program::bridge_lock().id(); + let wrapped_token_id = Program::wrapped_token().id(); + let outbox_id = Program::cross_zone_outbox().id(); + let ordinal = 0; + + let mint = wrapped_token_core::Instruction::Mint { + recipient: RECIPIENT, + amount: LOCK_AMOUNT, + }; + let words = risc0_zkvm::serde::to_vec(&mint).expect("serialize mint"); + let payload: Vec = words.iter().flat_map(|word| word.to_le_bytes()).collect(); + + 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, + target_program_id: wrapped_token_id, + target_accounts, + payload, + outbox_program_id: outbox_id, + ordinal, + }; + + let accounts = vec![ + holder_id, + bridge_lock_core::escrow_account_id(bridge_lock_id), + outbox_pda(outbox_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) + .expect("build lock message"); + let witness = WitnessSet::for_message(&message, &[holder_key]); + LeeTransaction::Public(PublicTransaction::new(message, witness)) +} + +fn sequencer_client(addr: SocketAddr) -> Result { + let url = config::addr_to_url(config::UrlProtocol::Http, addr) + .context("Failed to build sequencer URL")?; + SequencerClientBuilder::default() + .build(url) + .context("Failed to build sequencer client") +} + +/// Polls zone B's indexer until the recipient's wrapped holding is non-zero. +async fn wait_for_mint(indexer: &IndexerClient, holding_id: AccountId) -> Result { + let account_id = indexer_service_protocol::AccountId { + value: holding_id.into_value(), + }; + let wait = async { + loop { + let account = + indexer_service_rpc::RpcClient::get_account(&**indexer, account_id).await?; + let balance = wrapped_token_core::read_balance(&account.data.0); + if balance != 0 { + return Ok::(balance); + } + tokio::time::sleep(Duration::from_secs(3)).await; + } + }; + tokio::time::timeout(DELIVERY_TIMEOUT, wait) + .await + .context("Zone B's indexer did not mint the wrapped token in time")? +} diff --git a/lee/state_machine/Cargo.toml b/lee/state_machine/Cargo.toml index c4521f5e..5e7fe722 100644 --- a/lee/state_machine/Cargo.toml +++ b/lee/state_machine/Cargo.toml @@ -36,6 +36,8 @@ lee_core = { workspace = true, features = ["test_utils"] } token_core.workspace = true authenticated_transfer_core.workspace = true cross_zone_inbox_core.workspace = true +cross_zone_outbox_core.workspace = true +bridge_lock_core.workspace = true ping_core.workspace = true test_program_methods.workspace = true diff --git a/lee/state_machine/src/cross_zone_bridge_tests.rs b/lee/state_machine/src/cross_zone_bridge_tests.rs new file mode 100644 index 00000000..1c000475 --- /dev/null +++ b/lee/state_machine/src/cross_zone_bridge_tests.rs @@ -0,0 +1,275 @@ +#![expect( + clippy::tests_outside_test_module, + clippy::arithmetic_side_effects, + reason = "We don't care about these in tests" +)] + +//! Single-zone state-machine tests for the wrapped-token bridge (Demo 2). They +//! drive the two guest hops in isolation, no watcher or Bedrock: the source +//! `bridge_lock::Lock` (which escrows and chains `outbox::Emit`), then a +//! hand-built `cross_zone_inbox::Dispatch` carrying the wrapped-token mint to +//! `wrapped_token::Mint`. Fast, so they pin guest logic before the e2e exercises +//! the plumbing. + +use std::collections::BTreeMap; + +use cross_zone_inbox_core::{ + CrossZoneMessage, InboxConfig, Instruction as InboxInstruction, SeenShard, + inbox_config_account_id, inbox_seen_shard_account_id, message_key, +}; +use cross_zone_outbox_core::{OutboxRecord, outbox_pda}; +use lee_core::account::Account; + +use crate::{ + AccountId, PrivateKey, PublicKey, PublicTransaction, V03State, + program::Program, + public_transaction::{Message, WitnessSet}, + validated_state_diff::ValidatedStateDiff, +}; + +const INITIAL_BALANCE: u128 = 100; +const LOCK_AMOUNT: u128 = 30; +const RECIPIENT: [u8; 32] = [9; 32]; + +/// The wrapped-token `Mint` the bridge forwards, serialized as the cross-zone +/// payload (risc0 words, little-endian bytes). +fn mint_payload() -> Vec { + let mint = wrapped_token_core::Instruction::Mint { + recipient: RECIPIENT, + amount: LOCK_AMOUNT, + }; + let words = risc0_zkvm::serde::to_vec(&mint).expect("serialize mint"); + words.iter().flat_map(|word| word.to_le_bytes()).collect() +} + +/// Drives `bridge_lock::Lock` and asserts it debits the holder, credits the +/// escrow, and records the forwarded mint in the outbox PDA. +#[test] +fn lock_escrows_balance_and_emits_to_outbox() { + let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0); + + let bridge_lock_id = Program::bridge_lock().id(); + let wrapped_token_id = Program::wrapped_token().id(); + let outbox_id = Program::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)); + state.force_insert_account( + holder_id, + Account { + program_owner: bridge_lock_id, + balance: 0, + data: bridge_lock_core::balance_bytes(INITIAL_BALANCE) + .to_vec() + .try_into() + .expect("balance fits in account data"), + nonce: 0_u128.into(), + }, + ); + + 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, &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 diff = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0) + .expect("lock must validate and execute"); + let public_diff = diff.public_diff(); + + let holder_after = + bridge_lock_core::read_balance(&public_diff[&holder_id].data.clone().into_inner()); + assert_eq!(holder_after, INITIAL_BALANCE - LOCK_AMOUNT, "holder debited"); + + let escrow_after = + bridge_lock_core::read_balance(&public_diff[&escrow_id].data.clone().into_inner()); + assert_eq!(escrow_after, LOCK_AMOUNT, "escrow credited"); + + let record = OutboxRecord::from_bytes(&public_diff[&outbox_record_id].data.clone().into_inner()) + .expect("outbox PDA holds an OutboxRecord"); + assert_eq!(record.target_zone, zone_b); + assert_eq!(record.target_program_id, wrapped_token_id); + assert_eq!(record.payload, payload, "emitted payload is the wrapped mint"); +} + +/// 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] +fn inbox_dispatch_mints_wrapped_token() { + let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0); + + let inbox_id = Program::cross_zone_inbox().id(); + let wrapped_token_id = Program::wrapped_token().id(); + + let self_zone = [1_u8; 32]; + let src_zone = [2_u8; 32]; + let src_block_id = 5; + + // Seed the inbox config allowing src_zone -> wrapped_token. + let mut allowed_targets = BTreeMap::new(); + allowed_targets.insert(src_zone, vec![wrapped_token_id]); + let config = InboxConfig { + self_zone, + allowed_peers: BTreeMap::new(), + allowed_targets, + }; + let config_id = inbox_config_account_id(inbox_id); + state.force_insert_account( + config_id, + Account { + program_owner: inbox_id, + balance: 0, + data: config + .to_bytes() + .try_into() + .expect("config fits in account data"), + nonce: 0_u128.into(), + }, + ); + + let msg = CrossZoneMessage { + src_zone, + src_block_id, + src_tx_index: 0, + src_program_id: [9_u32; 8], + target_program_id: wrapped_token_id, + payload: mint_payload(), + 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![config_id, seen_id, wrapped_config_id, holding_id], + vec![], + InboxInstruction::Dispatch(msg), + ) + .expect("build dispatch message"); + let tx = PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![])); + + let diff = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0) + .expect("dispatch must validate and execute"); + let public_diff = diff.public_diff(); + + let minted = + wrapped_token_core::read_balance(&public_diff[&holding_id].data.clone().into_inner()); + assert_eq!(minted, LOCK_AMOUNT, "recipient holding minted the locked amount"); +} + +/// A dispatch whose message key is already in the seen-shard is an idempotent +/// no-op: the inbox makes no chained call, so the wrapped token is not minted a +/// second time. This is the bridge's replay defense. +#[test] +fn mint_replay_rejected() { + let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0); + + let inbox_id = Program::cross_zone_inbox().id(); + let wrapped_token_id = Program::wrapped_token().id(); + + let self_zone = [1_u8; 32]; + let src_zone = [2_u8; 32]; + let src_block_id = 5; + let src_tx_index = 0; + + let mut allowed_targets = BTreeMap::new(); + allowed_targets.insert(src_zone, vec![wrapped_token_id]); + let config = InboxConfig { + self_zone, + allowed_peers: BTreeMap::new(), + allowed_targets, + }; + let config_id = inbox_config_account_id(inbox_id); + state.force_insert_account( + config_id, + Account { + program_owner: inbox_id, + balance: 0, + data: config + .to_bytes() + .try_into() + .expect("config fits in account data"), + nonce: 0_u128.into(), + }, + ); + + // Seed the seen-shard as already containing this message's key, so the inbox + // takes the replay no-op branch. The shard is inbox-owned (claimed on a prior + // delivery), so the guest leaves it untouched. + let seen_id = inbox_seen_shard_account_id(inbox_id, &src_zone, src_block_id); + let mut shard = SeenShard::default(); + shard.insert(message_key(&src_zone, src_block_id, src_tx_index)); + state.force_insert_account( + seen_id, + Account { + program_owner: inbox_id, + balance: 0, + data: shard.to_bytes().try_into().expect("shard fits in account data"), + nonce: 0_u128.into(), + }, + ); + + let msg = CrossZoneMessage { + src_zone, + src_block_id, + src_tx_index, + src_program_id: [9_u32; 8], + target_program_id: wrapped_token_id, + payload: mint_payload(), + l1_inclusion_witness: None, + }; + + 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![config_id, seen_id, wrapped_config_id, holding_id], + vec![], + InboxInstruction::Dispatch(msg), + ) + .expect("build dispatch message"); + let tx = PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![])); + + let diff = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0) + .expect("a replayed dispatch is a valid no-op, not an error"); + let public_diff = diff.public_diff(); + + // No mint: the holding is never credited on replay. + let minted = public_diff + .get(&holding_id) + .map_or(0, |account| wrapped_token_core::read_balance(&account.data.clone().into_inner())); + assert_eq!(minted, 0, "a replayed message must not mint again"); + + // The seen-shard is untouched by the no-op. + if let Some(seen) = public_diff.get(&seen_id) { + let shard_after = SeenShard::from_bytes(&seen.data.clone().into_inner()) + .expect("seen shard decodes"); + assert_eq!(shard_after, shard, "replay must not modify the seen-shard"); + } +} diff --git a/lee/state_machine/src/lib.rs b/lee/state_machine/src/lib.rs index 87dfd513..58584176 100644 --- a/lee/state_machine/src/lib.rs +++ b/lee/state_machine/src/lib.rs @@ -36,6 +36,9 @@ mod validated_state_diff; #[cfg(test)] mod cross_zone_dispatch_tests; +#[cfg(test)] +mod cross_zone_bridge_tests; + pub mod program_methods { include!(concat!(env!("OUT_DIR"), "/program_methods/mod.rs")); }