diff --git a/Cargo.lock b/Cargo.lock index b3373975..d6afde36 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1154,6 +1154,25 @@ dependencies = [ "serde", ] +[[package]] +name = "bridge_lock_core" +version = "0.1.0" +dependencies = [ + "lee_core", + "serde", +] + +[[package]] +name = "bridge_lock_program" +version = "0.1.0" +dependencies = [ + "bridge_lock_core", + "cross_zone_outbox_core", + "lee_core", + "risc0-zkvm", + "wrapped_token_core", +] + [[package]] name = "bridge_program" version = "0.1.0" @@ -1833,6 +1852,76 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" +[[package]] +name = "cross_zone" +version = "0.1.0" +dependencies = [ + "bridge_lock_core", + "cross_zone_inbox_core", + "lee", + "lee_core", + "ping_core", + "programs", + "risc0-zkvm", +] + +[[package]] +name = "cross_zone_chat" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum 0.8.9", + "common", + "cross_zone_inbox_core", + "cross_zone_outbox_core", + "env_logger", + "lee", + "log", + "ping_core", + "programs", + "risc0-zkvm", + "sequencer_service_rpc", + "serde", + "test_fixtures", + "tokio", +] + +[[package]] +name = "cross_zone_inbox_core" +version = "0.1.0" +dependencies = [ + "borsh", + "lee_core", + "risc0-zkvm", + "serde", +] + +[[package]] +name = "cross_zone_inbox_program" +version = "0.1.0" +dependencies = [ + "cross_zone_inbox_core", + "lee_core", +] + +[[package]] +name = "cross_zone_outbox_core" +version = "0.1.0" +dependencies = [ + "borsh", + "lee_core", + "risc0-zkvm", + "serde", +] + +[[package]] +name = "cross_zone_outbox_program" +version = "0.1.0" +dependencies = [ + "cross_zone_outbox_core", + "lee_core", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -3851,13 +3940,19 @@ dependencies = [ "async-stream", "borsh", "common", + "cross_zone", + "cross_zone_inbox_core", "futures", + "hex", "humantime-serde", "lee", "lee_core", "log", "logos-blockchain-core", "logos-blockchain-zone-sdk", + "ping_core", + "programs", + "risc0-zkvm", "serde", "serde_json", "storage", @@ -4012,8 +4107,11 @@ dependencies = [ "authenticated_transfer_core", "borsh", "bridge_core", + "bridge_lock_core", "bytesize", "common", + "cross_zone_inbox_core", + "cross_zone_outbox_core", "faucet_core", "futures", "hex", @@ -4029,8 +4127,10 @@ dependencies = [ "logos-blockchain-key-management-system-service", "logos-blockchain-zone-sdk", "num-bigint 0.4.6", + "ping_core", "programs", "reqwest", + "risc0-zkvm", "sequencer_core", "sequencer_service_rpc", "serde_json", @@ -4043,6 +4143,7 @@ dependencies = [ "vault_core", "wallet", "wallet-ffi", + "wrapped_token_core", ] [[package]] @@ -7175,6 +7276,31 @@ dependencies = [ "token_core", ] +[[package]] +name = "ping_core" +version = "0.1.0" +dependencies = [ + "lee_core", + "serde", +] + +[[package]] +name = "ping_receiver_program" +version = "0.1.0" +dependencies = [ + "lee_core", + "ping_core", +] + +[[package]] +name = "ping_sender_program" +version = "0.1.0" +dependencies = [ + "cross_zone_outbox_core", + "lee_core", + "ping_core", +] + [[package]] name = "pkcs1" version = "0.7.5" @@ -7447,15 +7573,20 @@ dependencies = [ "associated_token_account_program", "authenticated_transfer_core", "bridge_core", + "bridge_lock_core", "build_utils", "clock_core", + "cross_zone_inbox_core", + "cross_zone_outbox_core", "faucet_core", "lee", "lee_core", + "ping_core", "risc0-zkvm", "token_core", "token_program", "vault_core", + "wrapped_token_core", ] [[package]] @@ -8894,6 +9025,8 @@ dependencies = [ "bytesize", "chrono", "common", + "cross_zone", + "cross_zone_inbox_core", "faucet_core", "futures", "hex", @@ -9843,6 +9976,7 @@ dependencies = [ "programs", "serde", "system_accounts", + "wrapped_token_core", ] [[package]] @@ -11552,6 +11686,23 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wrapped_token_core" +version = "0.1.0" +dependencies = [ + "lee_core", + "risc0-zkvm", + "serde", +] + +[[package]] +name = "wrapped_token_program" +version = "0.1.0" +dependencies = [ + "lee_core", + "wrapped_token_core", +] + [[package]] name = "writeable" version = "0.6.3" diff --git a/Cargo.toml b/Cargo.toml index 53b684b3..164b64cc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,13 @@ members = [ "lez/programs/pinata_token", "lez/programs/token", "lez/programs/vault", + "lez/programs/cross_zone_inbox", + "lez/programs/cross_zone_outbox", + "lez/programs/bridge_lock", + "lez/programs/wrapped_token", + "lez/programs/ping_sender", + "lez/programs/ping_receiver", + "lez/cross_zone", "test_programs", "test_programs/guest", @@ -58,6 +65,7 @@ members = [ "tools/cycle_bench", "tools/crypto_primitives_bench", "tools/integration_bench", + "tools/cross_zone_chat", ] [workspace.dependencies] @@ -94,6 +102,12 @@ authenticated_transfer_core = { path = "lez/programs/authenticated_transfer/core faucet_core = { path = "lez/programs/faucet/core" } bridge_core = { path = "lez/programs/bridge/core" } vault_core = { path = "lez/programs/vault/core" } +cross_zone_inbox_core = { path = "lez/programs/cross_zone_inbox/core" } +cross_zone_outbox_core = { path = "lez/programs/cross_zone_outbox/core" } +bridge_lock_core = { path = "lez/programs/bridge_lock/core" } +wrapped_token_core = { path = "lez/programs/wrapped_token/core" } +ping_core = { path = "lez/programs/ping_core" } +cross_zone = { path = "lez/cross_zone" } build_utils = { path = "build_utils" } test_programs = { path = "test_programs" } testnet_initial_state = { path = "lez/testnet_initial_state" } @@ -182,6 +196,7 @@ elliptic-curve = { version = "0.13.8", features = ["arithmetic"] } actix-web = { version = "4.13.0", default-features = false, features = [ "macros", ] } +axum = "0.8.4" clap = { version = "4.5.42", features = ["derive", "env"] } reqwest = { version = "0.12", features = ["json", "rustls-tls", "stream"] } pyo3 = { version = "0.29", features = ["auto-initialize"] } diff --git a/Justfile b/Justfile index 41566868..cf7c833e 100644 --- a/Justfile +++ b/Justfile @@ -6,6 +6,11 @@ default: # ---- Configuration ---- ARTIFACTS := "artifacts" +# On macOS the integration-test binary links pyo3 against the CommandLineTools +# Python framework with no embedded rpath, so it needs this to launch. Empty on +# Linux/CI, which is unaffected. +DEMO_ENV := if os() == "macos" { "DYLD_FALLBACK_FRAMEWORK_PATH=/Library/Developer/CommandLineTools/Library/Frameworks" } else { "" } + # Build risc0 program artifacts. build-artifacts: @echo "🔨 Building artifacts" @@ -99,6 +104,26 @@ wallet-import-test-accounts: just run-wallet account list +# Demo: cross-zone ping. Boots two zones on one Bedrock and sends a message from +# zone A to zone B, where the indexer re-derives and verifies it (Option B) +# before ping_receiver records it. Dev mode, no proving. +demo-cross-zone-ping: + @echo "📡 Cross-zone ping demo (message A → B, indexer-verified)" + {{DEMO_ENV}} RISC0_DEV_MODE=1 cargo test -p integration_tests --release --test cross_zone_verified -- --nocapture + +# Demo: cross-zone wrapped-token bridge. Locks a balance on zone A and mints the +# wrapped token to a recipient on zone B over the same verified spine. +demo-cross-zone-bridge: + @echo "🌉 Cross-zone bridge demo (lock on A, mint on B)" + {{DEMO_ENV}} RISC0_DEV_MODE=1 cargo test -p integration_tests --release --test cross_zone_bridge -- --nocapture + +# Demo: interactive cross-zone chat. Boots two zones on one Bedrock and serves a +# local two-column web UI; type in one zone and watch the message cross into the +# other. Two people can chat across the zones. Dev mode, no proving. +cross-zone-chat: + @echo "💬 Cross-zone chat demo — open the printed localhost URL" + {{DEMO_ENV}} RISC0_DEV_MODE=1 cargo run -p cross_zone_chat --release + # Clean runtime data clean: @echo "🧹 Cleaning run artifacts" diff --git a/artifacts/lez/programs/amm.bin b/artifacts/lez/programs/amm.bin index 11382930..d86dae93 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 1f01ba89..03e5b252 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 2fc88f43..9a23257d 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 8ee361a7..d9ad7282 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 new file mode 100644 index 00000000..224d182e Binary files /dev/null and b/artifacts/lez/programs/bridge_lock.bin differ diff --git a/artifacts/lez/programs/clock.bin b/artifacts/lez/programs/clock.bin index 45467bd3..0fa4fbae 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 new file mode 100644 index 00000000..376f55db Binary files /dev/null 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 new file mode 100644 index 00000000..c7814a7e Binary files /dev/null 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 6204eb9c..a14dbbf2 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 8f16d3e1..8dd35ccd 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 0caf5046..bfec3f77 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 new file mode 100644 index 00000000..7b6390b0 Binary files /dev/null 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 new file mode 100644 index 00000000..bd70e8a6 Binary files /dev/null and b/artifacts/lez/programs/ping_sender.bin differ diff --git a/artifacts/lez/programs/token.bin b/artifacts/lez/programs/token.bin index 1786fa01..2a515a03 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 d670b6bc..87829416 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 new file mode 100644 index 00000000..c775919c Binary files /dev/null and b/artifacts/lez/programs/wrapped_token.bin differ diff --git a/integration_tests/Cargo.toml b/integration_tests/Cargo.toml index bba06ec0..a225d657 100644 --- a/integration_tests/Cargo.toml +++ b/integration_tests/Cargo.toml @@ -22,6 +22,12 @@ associated_token_account_core.workspace = true vault_core.workspace = true faucet_core.workspace = true bridge_core.workspace = true +ping_core.workspace = true +cross_zone_outbox_core.workspace = true +cross_zone_inbox_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"] } wallet-ffi.workspace = true diff --git a/integration_tests/tests/cross_zone_bridge.rs b/integration_tests/tests/cross_zone_bridge.rs new file mode 100644 index 00000000..63b14c95 --- /dev/null +++ b/integration_tests/tests/cross_zone_bridge.rs @@ -0,0 +1,197 @@ +#![expect( + clippy::tests_outside_test_module, + reason = "top-level test functions are conventional for integration 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, + 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 = programs::wrapped_token().id(); + let cross_zone = CrossZoneConfig { + peers: vec![CrossZonePeer { + channel_id: *channel_a.as_ref(), + allowed_targets: vec![wrapped_token_id], + expected_block_signing_pubkey: None, + }], + }; + + // 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(programs::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 = programs::bridge_lock().id(); + let wrapped_token_id = programs::wrapped_token().id(); + let outbox_id = programs::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/integration_tests/tests/cross_zone_ingress_guard.rs b/integration_tests/tests/cross_zone_ingress_guard.rs new file mode 100644 index 00000000..5f401869 --- /dev/null +++ b/integration_tests/tests/cross_zone_ingress_guard.rs @@ -0,0 +1,80 @@ +#![expect( + clippy::tests_outside_test_module, + reason = "We don't care about these in tests" +)] + +//! M6 ingress guard: the cross-zone inbox is sequencer-only. Only the watcher +//! injects inbox dispatches; a user must not be able to invoke the inbox through +//! the public RPC, or anyone could forge an inbound cross-zone delivery. The +//! inbox guest's caller-is-none assertion passes for a top-level user tx, so the +//! sequencer ingress guard is the only thing that stops this. + +use std::net::SocketAddr; + +use anyhow::{Context as _, Result}; +use common::transaction::LeeTransaction; +use cross_zone_inbox_core::{ + CrossZoneMessage, Instruction, inbox_config_account_id, inbox_seen_shard_account_id, +}; +use integration_tests::{ + config::{self, SequencerPartialConfig}, + setup::{setup_bedrock_node, setup_sequencer}, +}; +use lee::{ + PublicTransaction, + public_transaction::{Message, WitnessSet}, +}; +use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder}; +use tokio::test; + +#[test] +async fn user_origin_inbox_call_rejected() -> Result<()> { + let (_bedrock, bedrock_addr) = setup_bedrock_node() + .await + .context("Failed to set up Bedrock node")?; + let partial = SequencerPartialConfig::default(); + let channel = config::bedrock_channel_id(); + let (seq, _seq_home) = setup_sequencer(partial, bedrock_addr, vec![], channel, None) + .await + .context("Failed to set up sequencer")?; + + // A user hand-builds a top-level inbox Dispatch and submits it via RPC. + let inbox_id = programs::cross_zone_inbox().id(); + let msg = CrossZoneMessage { + src_zone: [2; 32], + src_block_id: 1, + src_tx_index: 0, + src_program_id: [9; 8], + target_program_id: programs::ping_receiver().id(), + payload: vec![], + l1_inclusion_witness: None, + }; + let seen_id = inbox_seen_shard_account_id(inbox_id, &msg.src_zone, msg.src_block_id); + let message = Message::try_new( + inbox_id, + vec![inbox_config_account_id(inbox_id), seen_id], + vec![], + Instruction::Dispatch(msg), + ) + .expect("build dispatch message"); + let tx = LeeTransaction::Public(PublicTransaction::new( + message, + WitnessSet::from_raw_parts(vec![]), + )); + + let result = sequencer_client(seq.addr())?.send_transaction(tx).await; + let err = result.expect_err("the sequencer must reject a user-origin inbox call"); + assert!( + err.to_string().contains("sequencer-only"), + "rejection should cite the sequencer-only guard, got: {err}" + ); + Ok(()) +} + +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") +} diff --git a/integration_tests/tests/cross_zone_ping.rs b/integration_tests/tests/cross_zone_ping.rs new file mode 100644 index 00000000..19223b5f --- /dev/null +++ b/integration_tests/tests/cross_zone_ping.rs @@ -0,0 +1,143 @@ +#![expect( + clippy::tests_outside_test_module, + reason = "top-level test functions are conventional for integration tests" +)] + +//! End-to-end cross-zone round trip: a ping submitted on zone A is delivered by +//! zone B's watcher to `ping_receiver` on zone B, which records the payload. +//! +//! Two sequencers share one Bedrock node (no indexers): zone A publishes the +//! ping to Bedrock, zone B's watcher reads zone A's finalized blocks, injects the +//! inbox dispatch, and zone B's sequencer delivers it. This is the M3 milestone, +//! sequencer-trusted, with no indexer re-derivation (that is M4). + +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}, + setup::{setup_bedrock_node, setup_sequencer}, +}; +use lee::{AccountId, PublicTransaction, public_transaction::Message}; +use lee_core::program::ProgramId; +use ping_core::{ReceiverInstruction, SenderInstruction, ping_record_pda}; +use sequencer_core::config::{CrossZoneConfig, CrossZonePeer}; +use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder}; +use tokio::test; + +const DELIVERY_TIMEOUT: Duration = Duration::from_secs(480); +const PING_PAYLOAD: &[u8] = b"hello-cross-zone"; + +#[test] +async fn ping_crosses_from_zone_a_to_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_a: [u8; 32] = *channel_a.as_ref(); + let zone_b: [u8; 32] = *channel_b.as_ref(); + + let receiver_id = programs::ping_receiver().id(); + + // Zone B watches zone A and allows delivery only to ping_receiver. + let cross_zone = CrossZoneConfig { + peers: vec![CrossZonePeer { + channel_id: zone_a, + allowed_targets: vec![receiver_id], + expected_block_signing_pubkey: None, + }], + }; + + let (seq_a, _seq_a_home) = setup_sequencer(partial, bedrock_addr, vec![], 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)) + .await + .context("Failed to set up zone B sequencer")?; + + // Submit the ping on zone A, addressed to ping_receiver on zone B. + let ping = build_ping_tx(zone_b, receiver_id); + sequencer_client(seq_a.addr())? + .send_transaction(ping) + .await + .context("Failed to submit ping on zone A")?; + + // Wait until zone B's sequencer records the delivered payload. + let record_id = ping_record_pda(receiver_id); + let delivered = wait_for_delivery(sequencer_client(seq_b.addr())?, record_id).await?; + + assert_eq!( + delivered, PING_PAYLOAD, + "Zone B must record the payload delivered from zone A" + ); + Ok(()) +} + +/// Builds a top-level `ping_sender` transaction that chains into the outbox to emit +/// a message carrying a `ping_receiver::Record` instruction for the target zone. +fn build_ping_tx(target_zone: [u8; 32], receiver_id: ProgramId) -> LeeTransaction { + let outbox_id = programs::cross_zone_outbox().id(); + let ordinal = 0; + + // The payload is the ping_receiver instruction, serialized as risc0 words in + // little-endian bytes (the contract the inbox reverses when forwarding). + let words = risc0_zkvm::serde::to_vec(&ReceiverInstruction::Record { + payload: PING_PAYLOAD.to_vec(), + }) + .expect("serialize ping instruction"); + 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()], + payload, + ordinal, + }; + + let outbox_account = outbox_pda(outbox_id, &target_zone, ordinal); + let message = Message::try_new( + programs::ping_sender().id(), + vec![outbox_account], + vec![], + send, + ) + .expect("build ping message"); + LeeTransaction::Public(PublicTransaction::new( + message, + lee::public_transaction::WitnessSet::from_raw_parts(vec![]), + )) +} + +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 sequencer until the ping record PDA holds a payload. +async fn wait_for_delivery(client: SequencerClient, record_id: AccountId) -> Result> { + let wait = async { + loop { + let account = client.get_account(record_id).await?; + let data = account.data.into_inner(); + if !data.is_empty() { + return Ok::, anyhow::Error>(data); + } + tokio::time::sleep(Duration::from_secs(3)).await; + } + }; + tokio::time::timeout(DELIVERY_TIMEOUT, wait) + .await + .context("Zone B did not record the cross-zone payload in time")? +} diff --git a/integration_tests/tests/cross_zone_state_machine.rs b/integration_tests/tests/cross_zone_state_machine.rs new file mode 100644 index 00000000..1b67914a --- /dev/null +++ b/integration_tests/tests/cross_zone_state_machine.rs @@ -0,0 +1,373 @@ +#![expect( + clippy::tests_outside_test_module, + reason = "top-level test functions are conventional for integration tests" +)] + +//! Single-zone state-machine tests for cross-zone delivery (ping demo) and the +//! wrapped-token bridge (Demo 2). They drive the guests in isolation, no watcher +//! or Bedrock: a hand-built `cross_zone_inbox::Dispatch` (as the watcher would +//! inject) and the source `bridge_lock::Lock` (which escrows and chains +//! `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, 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::{ + AccountId, PrivateKey, PublicKey, PublicTransaction, V03State, ValidatedStateDiff, + public_transaction::{Message, WitnessSet}, +}; +use lee_core::account::Account; +use ping_core::{ReceiverInstruction, ping_record_pda}; + +const INITIAL_BALANCE: u128 = 100; +const LOCK_AMOUNT: u128 = 30; +const RECIPIENT: [u8; 32] = [9; 32]; + +/// State registering the cross-zone builtins these tests exercise. +fn base_state() -> V03State { + V03State::new().with_programs([ + programs::cross_zone_inbox(), + programs::cross_zone_outbox(), + 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], + target: lee_core::program::ProgramId, +) { + let inbox_id = programs::cross_zone_inbox().id(); + let mut allowed_targets = BTreeMap::new(); + allowed_targets.insert(src_zone, vec![target]); + let config = InboxConfig { + self_zone, + allowed_peers: BTreeMap::new(), + allowed_targets, + }; + state.insert_genesis_account( + inbox_config_account_id(inbox_id), + Account { + program_owner: inbox_id, + balance: 0, + data: config + .to_bytes() + .try_into() + .expect("config fits in account data"), + nonce: 0_u128.into(), + }, + ); +} + +/// 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) { + let wrapped_token_id = programs::wrapped_token().id(); + state.insert_genesis_account( + 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() + .try_into() + .expect("minter id fits in account data"), + ..Default::default() + }, + ); +} + +/// 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 `cross_zone_inbox::Dispatch` directly through the state machine +/// (no watcher) and asserts the message is delivered to `ping_receiver`, which +/// records the payload into its own PDA. +#[test] +fn inbox_dispatch_delivers_payload_to_ping_receiver() { + let inbox_id = programs::cross_zone_inbox().id(); + let receiver_id = programs::ping_receiver().id(); + + let self_zone = [1_u8; 32]; + let src_zone = [2_u8; 32]; + let src_block_id = 5; + + let mut state = base_state(); + seed_inbox_config(&mut state, self_zone, src_zone, receiver_id); + + // The payload is the ping_receiver instruction, serialized as risc0 words in + // little-endian bytes (the contract the inbox reverses when forwarding). + let inner = b"hello-cross-zone".to_vec(); + let words = risc0_zkvm::serde::to_vec(&ReceiverInstruction::Record { + payload: inner.clone(), + }) + .expect("serialize ping instruction"); + let payload: Vec = words.iter().flat_map(|word| word.to_le_bytes()).collect(); + + let msg = CrossZoneMessage { + src_zone, + src_block_id, + src_tx_index: 0, + src_program_id: [9_u32; 8], + target_program_id: receiver_id, + payload, + l1_inclusion_witness: None, + }; + + let seen_id = inbox_seen_shard_account_id(inbox_id, &src_zone, src_block_id); + 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], + 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 record = diff + .public_diff() + .get(&record_id) + .expect("ping record account must change") + .clone(); + assert_eq!( + record.data.into_inner(), + inner, + "ping_receiver must record the delivered payload" + ); +} + +/// 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 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 mut state = base_state(); + + let holder_key = PrivateKey::try_new([7; 32]).expect("valid key"); + let holder_id = AccountId::from(&PublicKey::new_from_private_key(&holder_key)); + state.insert_genesis_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 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 src_block_id = 5; + + let mut state = base_state(); + seed_inbox_config(&mut state, self_zone, src_zone, wrapped_token_id); + seed_wrapped_config(&mut state); + + 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![ + inbox_config_account_id(inbox_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 minted = wrapped_token_core::read_balance( + &diff.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 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 src_block_id = 5; + let src_tx_index = 0; + + let mut state = base_state(); + seed_inbox_config(&mut state, self_zone, src_zone, wrapped_token_id); + seed_wrapped_config(&mut state); + + // 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.insert_genesis_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![ + inbox_config_account_id(inbox_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/integration_tests/tests/cross_zone_verified.rs b/integration_tests/tests/cross_zone_verified.rs new file mode 100644 index 00000000..e2f016c0 --- /dev/null +++ b/integration_tests/tests/cross_zone_verified.rs @@ -0,0 +1,162 @@ +#![expect( + clippy::tests_outside_test_module, + reason = "top-level test functions are conventional for integration tests" +)] + +//! Cross-zone round trip with the indexer in the loop (Option B). A ping on zone +//! A is delivered to zone B, and zone B's indexer independently re-derives the +//! injected dispatch from zone A's finalized blocks before applying it. The +//! payload landing in the indexer's state proves verification passed; a forgery +//! would have halted the indexer instead. + +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, PublicTransaction, public_transaction::Message}; +use lee_core::program::ProgramId; +use ping_core::{ReceiverInstruction, SenderInstruction, ping_record_pda}; +use sequencer_core::config::{CrossZoneConfig, CrossZonePeer}; +use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder}; +use tokio::test; + +const DELIVERY_TIMEOUT: Duration = Duration::from_secs(600); +const PING_PAYLOAD: &[u8] = b"hello-verified-zone"; + +#[test] +async fn indexer_verifies_and_delivers_cross_zone_ping() -> 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_a: [u8; 32] = *channel_a.as_ref(); + let zone_b: [u8; 32] = *channel_b.as_ref(); + + let receiver_id = programs::ping_receiver().id(); + let cross_zone = CrossZoneConfig { + peers: vec![CrossZonePeer { + channel_id: zone_a, + allowed_targets: vec![receiver_id], + expected_block_signing_pubkey: None, + }], + }; + + // Zone A: source. Zone B: destination, with the watcher on its sequencer and + // the verifier on its indexer. + let (seq_a, _seq_a_home) = setup_sequencer(partial, bedrock_addr, vec![], channel_a, None) + .await + .context("Failed to set up zone A sequencer")?; + let (_idx_a, _idx_a_home) = setup_indexer(bedrock_addr, channel_a, None) + .await + .context("Failed to set up zone A indexer")?; + 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")?; + + // Submit the ping on zone A, addressed to ping_receiver on zone B. + let ping = build_ping_tx(zone_b, receiver_id); + sequencer_client(seq_a.addr())? + .send_transaction(ping) + .await + .context("Failed to submit ping on zone A")?; + + // Wait until zone B's indexer records the delivered payload. The indexer only + // applies the dispatch after re-deriving and verifying it. + let record_id = ping_record_pda(receiver_id); + 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 delivered = wait_for_indexer_delivery(&indexer, record_id).await?; + assert_eq!( + delivered, PING_PAYLOAD, + "Zone B's indexer must record the verified cross-zone payload" + ); + Ok(()) +} + +fn build_ping_tx(target_zone: [u8; 32], receiver_id: ProgramId) -> LeeTransaction { + let outbox_id = programs::cross_zone_outbox().id(); + let ordinal = 0; + + let words = risc0_zkvm::serde::to_vec(&ReceiverInstruction::Record { + payload: PING_PAYLOAD.to_vec(), + }) + .expect("serialize ping instruction"); + 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()], + payload, + ordinal, + }; + + let outbox_account = outbox_pda(outbox_id, &target_zone, ordinal); + let message = Message::try_new( + programs::ping_sender().id(), + vec![outbox_account], + vec![], + send, + ) + .expect("build ping message"); + LeeTransaction::Public(PublicTransaction::new( + message, + lee::public_transaction::WitnessSet::from_raw_parts(vec![]), + )) +} + +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 ping record PDA holds a payload. +async fn wait_for_indexer_delivery( + indexer: &IndexerClient, + record_id: AccountId, +) -> Result> { + let account_id = indexer_service_protocol::AccountId { + value: record_id.into_value(), + }; + let wait = async { + loop { + let account = + indexer_service_rpc::RpcClient::get_account(&**indexer, account_id).await?; + let data = account.data.0; + if !data.is_empty() { + return Ok::, anyhow::Error>(data); + } + tokio::time::sleep(Duration::from_secs(3)).await; + } + }; + tokio::time::timeout(DELIVERY_TIMEOUT, wait) + .await + .context("Zone B's indexer did not record the payload in time")? +} diff --git a/integration_tests/tests/indexer_ffi_helpers/mod.rs b/integration_tests/tests/indexer_ffi_helpers/mod.rs index 170102fd..09e0a927 100644 --- a/integration_tests/tests/indexer_ffi_helpers/mod.rs +++ b/integration_tests/tests/indexer_ffi_helpers/mod.rs @@ -50,8 +50,12 @@ pub fn setup_indexer_ffi(bedrock_addr: SocketAddr) -> Result<(IndexerServiceFFI, temp_indexer_dir.path().display() ); - let indexer_config = integration_tests::config::indexer_config(bedrock_addr) - .context("Failed to create Indexer config")?; + let indexer_config = integration_tests::config::indexer_config( + bedrock_addr, + integration_tests::config::bedrock_channel_id(), + None, + ) + .context("Failed to create Indexer config")?; let config_json = serde_json::to_vec(&indexer_config)?; let config_path = temp_indexer_dir.path().join("indexer_config.json"); diff --git a/integration_tests/tests/two_zone.rs b/integration_tests/tests/two_zone.rs new file mode 100644 index 00000000..c895acd1 --- /dev/null +++ b/integration_tests/tests/two_zone.rs @@ -0,0 +1,110 @@ +#![expect( + clippy::tests_outside_test_module, + reason = "top-level test functions are conventional for integration tests" +)] + +//! Two zones (sequencer + indexer each, on separate channels) sharing one +//! Bedrock node, each producing and finalizing blocks independently. + +use std::{net::SocketAddr, time::Duration}; + +use anyhow::{Context as _, Result}; +use indexer_service_rpc::RpcClient as _; +use integration_tests::{ + config::{self, SequencerPartialConfig}, + indexer_client::IndexerClient, + setup::{setup_bedrock_node, setup_indexer, setup_sequencer}, +}; +use sequencer_service_rpc::{RpcClient as _, SequencerClientBuilder}; +use tokio::test; + +const ZONE_LIVE_TIMEOUT: Duration = Duration::from_secs(360); + +// Genesis is block 1, so reaching 2 means a block was produced past it. +const MIN_BLOCK_ID: u64 = 2; + +#[test] +async fn two_zones_share_one_bedrock_and_both_advance() -> 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(); + + // Empty genesis is enough: the clock transaction drives block production. + let (seq_a, _seq_a_home) = setup_sequencer(partial, bedrock_addr, vec![], channel_a, None) + .await + .context("Failed to set up zone A sequencer")?; + let (idx_a, _idx_a_home) = setup_indexer(bedrock_addr, channel_a, None) + .await + .context("Failed to set up zone A indexer")?; + let (seq_b, _seq_b_home) = setup_sequencer(partial, bedrock_addr, vec![], channel_b, None) + .await + .context("Failed to set up zone B sequencer")?; + let (idx_b, _idx_b_home) = setup_indexer(bedrock_addr, channel_b, None) + .await + .context("Failed to set up zone B indexer")?; + + let (height_a, height_b) = tokio::try_join!( + wait_until_zone_live("A", seq_a.addr(), idx_a.addr()), + wait_until_zone_live("B", seq_b.addr(), idx_b.addr()), + )?; + + assert!( + height_a >= MIN_BLOCK_ID, + "Zone A indexer only reached block {height_a}, expected >= {MIN_BLOCK_ID}" + ); + assert!( + height_b >= MIN_BLOCK_ID, + "Zone B indexer only reached block {height_b}, expected >= {MIN_BLOCK_ID}" + ); + + Ok(()) +} + +/// Wait for the sequencer to produce past genesis and the indexer to finalize up +/// to it. Returns the indexer's finalized block id. +async fn wait_until_zone_live( + label: &str, + sequencer_addr: SocketAddr, + indexer_addr: SocketAddr, +) -> Result { + let sequencer_url = config::addr_to_url(config::UrlProtocol::Http, sequencer_addr) + .context("Failed to build sequencer URL")?; + let sequencer = SequencerClientBuilder::default() + .build(sequencer_url) + .context("Failed to build sequencer client")?; + + let indexer_url = config::addr_to_url(config::UrlProtocol::Ws, indexer_addr) + .context("Failed to build indexer URL")?; + let indexer = IndexerClient::new(&indexer_url) + .await + .context("Failed to build indexer client")?; + + let wait = async { + loop { + if sequencer.get_last_block_id().await? >= MIN_BLOCK_ID { + break; + } + tokio::time::sleep(Duration::from_secs(2)).await; + } + let target = sequencer.get_last_block_id().await?; + loop { + let finalized = indexer.get_last_finalized_block_id().await?.unwrap_or(0); + if finalized >= target { + log::info!( + "Zone {label} live: sequencer at {target}, indexer finalized {finalized}" + ); + return Ok::(finalized); + } + tokio::time::sleep(Duration::from_secs(2)).await; + } + }; + + tokio::time::timeout(ZONE_LIVE_TIMEOUT, wait) + .await + .with_context(|| format!("Zone {label} did not become live within {ZONE_LIVE_TIMEOUT:?}"))? +} diff --git a/lee/state_machine/src/state/mod.rs b/lee/state_machine/src/state/mod.rs index fcf71a21..5f5a4639 100644 --- a/lee/state_machine/src/state/mod.rs +++ b/lee/state_machine/src/state/mod.rs @@ -194,6 +194,14 @@ impl V03State { self.programs.insert(program.id(), program); } + /// Seeds a single genesis account that is not produced by any transaction + /// (e.g. the cross-zone inbox config or a bridge-lock holding). Lets the + /// sequencer and indexer seed identical zone-specific state after building + /// the shared initial state. + pub fn insert_genesis_account(&mut self, account_id: AccountId, account: Account) { + self.public_state.insert(account_id, account); + } + pub fn apply_state_diff(&mut self, diff: ValidatedStateDiff) { let StateDiff { signer_account_ids, diff --git a/lez/common/src/block.rs b/lez/common/src/block.rs index a8149e7c..b22eec9f 100644 --- a/lez/common/src/block.rs +++ b/lez/common/src/block.rs @@ -68,6 +68,16 @@ impl Block { } .compute_hash() } + + /// Recomputes the signed hash from the block contents and checks the header + /// signature against `expected_pubkey`. Used to pin a peer zone's + /// block-signing key, so a block inscribed by anyone other than that zone's + /// sequencer is rejected even if it reached the channel. + #[must_use] + pub fn is_signed_by(&self, expected_pubkey: &lee::PublicKey) -> bool { + let hash = HashableBlockData::from(self.clone()).compute_hash(); + self.header.signature.is_valid_for(&hash.0, expected_pubkey) + } } impl Serialize for Block { diff --git a/lez/cross_zone/Cargo.toml b/lez/cross_zone/Cargo.toml new file mode 100644 index 00000000..465436f1 --- /dev/null +++ b/lez/cross_zone/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "cross_zone" +version = "0.1.0" +edition = "2024" +license = { workspace = true } + +[lints] +workspace = true + +[dependencies] +lee.workspace = true +lee_core.workspace = true +programs.workspace = true +cross_zone_inbox_core.workspace = true +bridge_lock_core.workspace = true +ping_core.workspace = true +risc0-zkvm.workspace = true diff --git a/lez/cross_zone/src/lib.rs b/lez/cross_zone/src/lib.rs new file mode 100644 index 00000000..544d4286 --- /dev/null +++ b/lez/cross_zone/src/lib.rs @@ -0,0 +1,189 @@ +//! Host-side cross-zone helpers that need program ids (`programs`) or the state +//! machine (`lee`), kept out of the guest-pure cores. Mirrors `system_accounts`: +//! it resolves builtin program ids and bakes them into transactions and genesis +//! accounts for the watcher (sequencer) and verifier (indexer). + +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, +}; +use lee_core::{ + account::{Account, AccountId}, + program::ProgramId, +}; + +/// The cross-zone emission fields a watcher or verifier reads off a source +/// transaction, common to every emitter program. +pub struct Emission { + pub target_zone: ZoneId, + pub target_program_id: ProgramId, + pub target_accounts: Vec<[u8; 32]>, + pub payload: Vec, +} + +/// Whether a program may only be invoked by sequencer-origin transactions. +/// +/// The cross-zone inbox is injected solely by the watcher; a user-submitted call +/// must be rejected at ingress, since `TransactionOrigin` is not carried in the +/// block. +#[must_use] +pub fn is_sequencer_only_program(program_id: ProgramId) -> bool { + program_id == programs::cross_zone_inbox().id() +} + +/// Extracts the cross-zone emission from a source transaction. +/// +/// Recognizes the known emitter programs (`ping_sender`, `bridge_lock`). The +/// watcher and verifier both use this so they agree on what a given source tx +/// emits. +#[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 { + target_zone, + target_program_id, + target_accounts, + payload, + .. + } = risc0_zkvm::serde::from_slice(instruction_data).ok()?; + Some(Emission { + target_zone, + target_program_id, + target_accounts, + payload, + }) + } else if program_id == programs::bridge_lock().id() { + let bridge_lock_core::Instruction::Lock { + target_zone, + target_program_id, + target_accounts, + payload, + .. + } = risc0_zkvm::serde::from_slice(instruction_data).ok()?; + Some(Emission { + target_zone, + target_program_id, + target_accounts, + payload, + }) + } else { + None + } +} + +/// Builds the sequencer-origin dispatch transaction. Pure for fixed inputs, so +/// the watcher's injected tx and the indexer's re-derived tx are byte-identical. +fn build_inbox_dispatch_tx( + inbox_id: ProgramId, + msg: &CrossZoneMessage, + target_account_ids: Vec, +) -> lee::PublicTransaction { + let mut account_ids = Vec::with_capacity(target_account_ids.len().saturating_add(2)); + 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, + )); + account_ids.extend(target_account_ids); + + let message = lee::public_transaction::Message::try_new( + inbox_id, + account_ids, + vec![], + Instruction::Dispatch(msg.clone()), + ) + .expect("inbox dispatch instruction must serialize"); + + lee::PublicTransaction::new( + message, + lee::public_transaction::WitnessSet::from_raw_parts(vec![]), + ) +} + +/// Builds the dispatch transaction for one peer emission. +/// +/// Both the sequencer's watcher and the indexer's verifier go through this so +/// their transactions are byte-identical for the same emission (the basis of the +/// Option B check). +#[must_use] +pub fn build_dispatch_from_emission( + src_zone: ZoneId, + src_block_id: u64, + src_tx_index: u32, + src_program_id: ProgramId, + target_program_id: ProgramId, + target_accounts: &[[u8; 32]], + payload: Vec, +) -> lee::PublicTransaction { + let msg = CrossZoneMessage { + src_zone, + src_block_id, + src_tx_index, + src_program_id, + target_program_id, + payload, + l1_inclusion_witness: None, + }; + let target_ids = target_accounts + .iter() + .copied() + .map(AccountId::new) + .collect(); + build_inbox_dispatch_tx(programs::cross_zone_inbox().id(), &msg, target_ids) +} + +/// Builds the inbox config account a zone seeds into genesis state. +/// +/// Lets the inbox guest authorize inbound peer messages. The sequencer and +/// indexer seed the same account from the same config, keeping their replayed +/// state consistent. +#[must_use] +pub fn build_inbox_config_account( + self_zone: ZoneId, + cross_zone: &CrossZoneConfig, +) -> (AccountId, Account) { + let inbox_id = programs::cross_zone_inbox().id(); + + let mut allowed_targets = BTreeMap::new(); + for peer in &cross_zone.peers { + allowed_targets.insert(peer.channel_id, peer.allowed_targets.clone()); + } + let config = InboxConfig { + self_zone, + allowed_peers: BTreeMap::new(), + allowed_targets, + }; + + let account = Account { + program_owner: inbox_id, + balance: 0, + data: config + .to_bytes() + .try_into() + .expect("inbox config fits in account data"), + nonce: 0_u128.into(), + }; + (inbox_config_account_id(inbox_id), account) +} + +/// Builds the genesis holding account funding a holder's bridgeable balance. +/// +/// Owned by `bridge_lock`, data is the LE balance. Not produced by any +/// transaction, so the sequencer and indexer both seed it through this one +/// builder. +#[must_use] +pub fn build_holding_account(holder: AccountId, amount: u128) -> (AccountId, Account) { + let account = Account { + program_owner: programs::bridge_lock().id(), + data: bridge_lock_core::balance_bytes(amount) + .to_vec() + .try_into() + .expect("balance fits in account data"), + ..Default::default() + }; + (holder, account) +} diff --git a/lez/indexer/core/Cargo.toml b/lez/indexer/core/Cargo.toml index 772b3536..14941773 100644 --- a/lez/indexer/core/Cargo.toml +++ b/lez/indexer/core/Cargo.toml @@ -16,6 +16,9 @@ common.workspace = true logos-blockchain-zone-sdk.workspace = true lee.workspace = true lee_core.workspace = true +cross_zone.workspace = true +cross_zone_inbox_core.workspace = true +programs.workspace = true storage.workspace = true testnet_initial_state.workspace = true @@ -32,6 +35,9 @@ serde_json.workspace = true thiserror.workspace = true async-stream.workspace = true tokio.workspace = true +risc0-zkvm.workspace = true +hex.workspace = true [dev-dependencies] tempfile.workspace = true +ping_core.workspace = true diff --git a/lez/indexer/core/src/block_store.rs b/lez/indexer/core/src/block_store.rs index 4b26dbe4..8c974399 100644 --- a/lez/indexer/core/src/block_store.rs +++ b/lez/indexer/core/src/block_store.rs @@ -45,13 +45,19 @@ pub struct IndexerStore { impl IndexerStore { /// Starting database at the start of new chain. /// Creates files if necessary. - pub fn open_db(location: &Path) -> Result { + pub fn open_db(location: &Path, genesis_seed: Vec<(AccountId, Account)>) -> Result { #[cfg(not(feature = "testnet"))] - let initial_state = testnet_initial_state::initial_state(); + let mut initial_state = testnet_initial_state::initial_state(); #[cfg(feature = "testnet")] - let initial_state = testnet_initial_state::initial_state_testnet(); + let mut initial_state = testnet_initial_state::initial_state_testnet(); + // Seed any zone-specific genesis accounts (the cross-zone inbox config and + // bridge-lock holdings) so the indexer's replayed state matches the + // sequencer's; none are produced by a transaction. + for (account_id, account) in genesis_seed { + initial_state.insert_genesis_account(account_id, account); + } let dbio = RocksDBIO::open_or_create(location, &initial_state)?; let current_state = dbio.final_state()?; @@ -394,7 +400,7 @@ mod stall_reason_tests { #[tokio::test] async fn stall_reason_roundtrips_and_clears() { let dir = tempfile::tempdir().expect("tempdir"); - let store = IndexerStore::open_db(dir.path()).expect("open store"); + let store = IndexerStore::open_db(dir.path(), Vec::new()).expect("open store"); assert!(store.get_stall_reason().expect("get").is_none()); @@ -441,7 +447,7 @@ mod tests { fn correct_startup() { let home = tempdir().unwrap(); - let storage = IndexerStore::open_db(home.as_ref()).unwrap(); + let storage = IndexerStore::open_db(home.as_ref(), Vec::new()).unwrap(); let final_id = storage.get_last_block_id().unwrap(); @@ -451,7 +457,7 @@ mod tests { #[tokio::test] async fn accept_block_applies_transfers_and_advances_tip() { let home = tempdir().unwrap(); - let store = IndexerStore::open_db(home.as_ref()).unwrap(); + let store = IndexerStore::open_db(home.as_ref(), Vec::new()).unwrap(); let initial_accounts = initial_pub_accounts_private_keys(); let from = initial_accounts[0].account_id; @@ -493,7 +499,7 @@ mod tests { #[tokio::test] async fn account_state_at_block_reflects_history() { let home = tempdir().unwrap(); - let store = IndexerStore::open_db(home.as_ref()).unwrap(); + let store = IndexerStore::open_db(home.as_ref(), Vec::new()).unwrap(); let initial_accounts = initial_pub_accounts_private_keys(); let from = initial_accounts[0].account_id; @@ -559,7 +565,7 @@ mod accept_tests { #[tokio::test] async fn non_genesis_first_block_parks_with_unexpected_id() { let dir = tempfile::tempdir().expect("tempdir"); - let store = IndexerStore::open_db(dir.path()).expect("open store"); + let store = IndexerStore::open_db(dir.path(), Vec::new()).expect("open store"); let block = valid_hash_block(2, HashType([0_u8; 32])); let outcome = store @@ -582,7 +588,7 @@ mod accept_tests { #[tokio::test] async fn hash_mismatch_parks() { let dir = tempfile::tempdir().expect("tempdir"); - let store = IndexerStore::open_db(dir.path()).expect("open store"); + let store = IndexerStore::open_db(dir.path(), Vec::new()).expect("open store"); let mut block = valid_hash_block(1, HashType([0_u8; 32])); block.header.timestamp = 999; // invalidates the stored hash @@ -600,7 +606,7 @@ mod accept_tests { #[tokio::test] async fn second_break_bumps_orphan_count_and_keeps_first() { let dir = tempfile::tempdir().expect("tempdir"); - let store = IndexerStore::open_db(dir.path()).expect("open store"); + let store = IndexerStore::open_db(dir.path(), Vec::new()).expect("open store"); let first = valid_hash_block(2, HashType([0_u8; 32])); store @@ -621,7 +627,7 @@ mod accept_tests { #[tokio::test] async fn deserialize_break_records_stall_without_header() { let dir = tempfile::tempdir().expect("tempdir"); - let store = IndexerStore::open_db(dir.path()).expect("open store"); + let store = IndexerStore::open_db(dir.path(), Vec::new()).expect("open store"); store .record_stall( @@ -639,7 +645,7 @@ mod accept_tests { #[tokio::test] async fn parks_then_recovers_on_valid_continuation() { let dir = tempfile::tempdir().expect("tempdir"); - let store = IndexerStore::open_db(dir.path()).expect("open store"); + let store = IndexerStore::open_db(dir.path(), Vec::new()).expect("open store"); // Genesis (block 1, clock-only) applies and advances the tip. let genesis = produce_dummy_block(1, None, vec![]); @@ -687,7 +693,7 @@ mod accept_tests { #[tokio::test] async fn accept_block_records_tip_inscription_slot() { let dir = tempfile::tempdir().expect("tempdir"); - let store = IndexerStore::open_db(dir.path()).expect("open store"); + let store = IndexerStore::open_db(dir.path(), Vec::new()).expect("open store"); assert_eq!(store.get_tip_slot().expect("get"), None); @@ -729,7 +735,7 @@ mod accept_tests { use testnet_initial_state::initial_pub_accounts_private_keys; let dir = tempfile::tempdir().expect("tempdir"); - let store = IndexerStore::open_db(dir.path()).expect("open store"); + let store = IndexerStore::open_db(dir.path(), Vec::new()).expect("open store"); let accounts = initial_pub_accounts_private_keys(); let from = accounts[0].account_id; @@ -779,7 +785,7 @@ mod accept_tests { use testnet_initial_state::initial_pub_accounts_private_keys; let dir = tempfile::tempdir().expect("tempdir"); - let store = IndexerStore::open_db(dir.path()).expect("open store"); + let store = IndexerStore::open_db(dir.path(), Vec::new()).expect("open store"); let accounts = initial_pub_accounts_private_keys(); let from = accounts[0].account_id; @@ -839,7 +845,7 @@ mod accept_tests { use testnet_initial_state::initial_pub_accounts_private_keys; let dir = tempfile::tempdir().expect("tempdir"); - let store = IndexerStore::open_db(dir.path()).expect("open store"); + let store = IndexerStore::open_db(dir.path(), Vec::new()).expect("open store"); let accounts = initial_pub_accounts_private_keys(); let from = accounts[0].account_id; @@ -876,7 +882,7 @@ mod accept_tests { // The #605 restart: reopening past the boundary must work. drop(store); - let reopened = IndexerStore::open_db(dir.path()).expect("reopen"); + let reopened = IndexerStore::open_db(dir.path(), Vec::new()).expect("reopen"); assert_eq!(reopened.last_block().unwrap(), Some(101)); } @@ -885,7 +891,7 @@ mod accept_tests { use testnet_initial_state::initial_pub_accounts_private_keys; let dir = tempfile::tempdir().expect("tempdir"); - let store = IndexerStore::open_db(dir.path()).expect("open store"); + let store = IndexerStore::open_db(dir.path(), Vec::new()).expect("open store"); let accounts = initial_pub_accounts_private_keys(); let from = accounts[0].account_id; diff --git a/lez/indexer/core/src/chain_consistency.rs b/lez/indexer/core/src/chain_consistency.rs index 00f28822..df4b5ba6 100644 --- a/lez/indexer/core/src/chain_consistency.rs +++ b/lez/indexer/core/src/chain_consistency.rs @@ -319,6 +319,8 @@ mod tests { }, channel_id: ChannelId::from([1; 32]), allow_chain_reset: false, + cross_zone: None, + bridge_lock_holdings: Vec::new(), }; IndexerCore::open(config, dir).expect("open core") } diff --git a/lez/indexer/core/src/config.rs b/lez/indexer/core/src/config.rs index c069d5cf..159da23c 100644 --- a/lez/indexer/core/src/config.rs +++ b/lez/indexer/core/src/config.rs @@ -2,7 +2,9 @@ use std::{fs::File, io::BufReader, path::Path, time::Duration}; use anyhow::{Context as _, Result}; use common::config::BasicAuth; +use cross_zone_inbox_core::CrossZoneConfig; use humantime_serde; +use lee::AccountId; pub use logos_blockchain_core::mantle::ops::channel::ChannelId; use serde::{Deserialize, Serialize}; use url::Url; @@ -20,6 +22,14 @@ pub struct IndexerConfig { pub consensus_info_polling_interval: Duration, pub bedrock_config: ClientConfig, pub channel_id: ChannelId, + /// Cross-zone configuration. `None` disables the indexer's cross-zone handling. + #[serde(default)] + pub cross_zone: Option, + /// Bridge-lock holdings to seed into genesis, mirroring the sequencer's + /// `SupplyBridgeLockHolding` actions. They are not produced by any + /// transaction, so the indexer must seed them to match the sequencer's state. + #[serde(default)] + pub bridge_lock_holdings: Vec, /// Whether to wipe the indexer store and re-index from scratch when the startup /// chain-identity check finds the channel serving a different block than the one /// stored at the same id. @@ -29,6 +39,14 @@ pub struct IndexerConfig { pub allow_chain_reset: bool, } +/// A genesis-funded bridge-lock holder balance, configured identically on the +/// sequencer (via `SupplyBridgeLockHolding`) and the indexer. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BridgeLockHolding { + pub holder: AccountId, + pub amount: u128, +} + impl IndexerConfig { pub fn from_path(config_path: &Path) -> Result { let file = File::open(config_path).with_context(|| { diff --git a/lez/indexer/core/src/cross_zone_verifier.rs b/lez/indexer/core/src/cross_zone_verifier.rs new file mode 100644 index 00000000..6e2a818e --- /dev/null +++ b/lez/indexer/core/src/cross_zone_verifier.rs @@ -0,0 +1,500 @@ +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, + time::Duration, +}; + +use anyhow::{Result, bail}; +use common::{block::Block, transaction::LeeTransaction}; +use cross_zone::{build_dispatch_from_emission, extract_emission}; +use cross_zone_inbox_core::{ + CrossZoneMessage, Instruction as InboxInstruction, MessageKey, ZoneId, message_key, +}; +use futures::StreamExt as _; +use lee::PublicKey; +use log::{error, info}; +use logos_blockchain_core::mantle::ops::channel::ChannelId; +use logos_blockchain_zone_sdk::{ + CommonHttpClient, ZoneMessage, adapter::NodeHttpClient, indexer::ZoneIndexer, +}; +use tokio::sync::RwLock; + +use crate::config::IndexerConfig; + +/// How often the verifier logs that it is still waiting on a lagging peer reader, +/// so a stuck wait is observable without rejecting a legitimate message. +const LAG_LOG_INTERVAL: Duration = Duration::from_secs(30); + +/// Cache of finalized peer-zone blocks, filled by per-peer reader tasks and read +/// by the verifier to re-derive cross-zone dispatch transactions. +#[derive(Clone, Default)] +struct PeerBlocks { + chains: Arc>>>, +} + +impl PeerBlocks { + async fn insert(&self, zone: ZoneId, block: Block) { + self.chains + .write() + .await + .entry(zone) + .or_default() + .insert(block.header.block_id, block); + } + + async fn get(&self, zone: ZoneId, block_id: u64) -> Option { + self.chains + .read() + .await + .get(&zone) + .and_then(|chain| chain.get(&block_id).cloned()) + } + + /// The highest block id this reader has finalized for `zone`, or `None` if it + /// has read nothing yet. Used to tell forgery (we have read past the + /// referenced block and it is absent) from lag (we simply have not caught up). + async fn highest_seen(&self, zone: ZoneId) -> Option { + self.chains + .read() + .await + .get(&zone) + .and_then(|chain| chain.keys().copied().max()) + } +} + +/// The indexer-side Option B verifier. +/// +/// For every cross-zone dispatch in a block it re-derives the transaction from +/// the peer's finalized block and rejects it if the bytes differ (a forgery) or +/// the message was already delivered (a replay), so delivery no longer relies on +/// trusting the sequencer. +#[derive(Clone)] +pub struct CrossZoneVerifier { + self_zone: ZoneId, + /// Pinned block-signing key per peer zone, enforced during re-derivation. + peer_pubkeys: HashMap, + peers: PeerBlocks, + seen: Arc>>, +} + +impl CrossZoneVerifier { + /// Builds the verifier and spawns one peer reader per configured peer. + /// Returns `None` when cross-zone messaging is disabled. + pub fn start(config: &IndexerConfig) -> Option { + let cross_zone = config.cross_zone.as_ref()?; + let self_zone: ZoneId = *config.channel_id.as_ref(); + let peers = PeerBlocks::default(); + let mut peer_pubkeys = HashMap::new(); + + for peer in &cross_zone.peers { + let node = NodeHttpClient::new( + CommonHttpClient::new(config.bedrock_config.auth.clone().map(Into::into)), + config.bedrock_config.addr.clone(), + ); + if let Some(bytes) = peer.expected_block_signing_pubkey { + let pubkey = PublicKey::try_new(bytes) + .expect("configured peer block-signing pubkey is a valid key"); + peer_pubkeys.insert(peer.channel_id, pubkey); + } + tokio::spawn(read_peer( + ZoneIndexer::new(ChannelId::from(peer.channel_id), node), + peer.channel_id, + peers.clone(), + config.consensus_info_polling_interval, + )); + } + + Some(Self { + self_zone, + peer_pubkeys, + peers, + seen: Arc::new(RwLock::new(HashSet::new())), + }) + } + + /// Verifies every cross-zone dispatch in a block, returning `Err` on the + /// first forged or replayed dispatch. The caller halts ingestion on error. + pub async fn verify_block(&self, block: &Block) -> Result<()> { + for tx in &block.body.transactions { + let Some(msg) = Self::decode_dispatch(tx) else { + continue; + }; + + let key = message_key(&msg.src_zone, msg.src_block_id, msg.src_tx_index); + if self.seen.read().await.contains(&key) { + bail!( + "cross-zone replay: message {} re-delivered", + hex::encode(key) + ); + } + + let expected = self.rederive(&msg).await?; + if LeeTransaction::Public(expected) != *tx { + bail!( + "forged cross-zone dispatch from zone {} block {} tx {}: re-derivation mismatch", + hex::encode(msg.src_zone), + msg.src_block_id, + msg.src_tx_index + ); + } + + self.seen.write().await.insert(key); + info!( + "Verified cross-zone dispatch from zone {} block {} tx {}", + hex::encode(msg.src_zone), + msg.src_block_id, + msg.src_tx_index + ); + } + Ok(()) + } + + /// Decodes a transaction into the cross-zone message it dispatches, or `None` + /// if it is not an inbox dispatch. + fn decode_dispatch(tx: &LeeTransaction) -> Option { + let LeeTransaction::Public(public_tx) = tx else { + return None; + }; + if public_tx.message().program_id != programs::cross_zone_inbox().id() { + return None; + } + match risc0_zkvm::serde::from_slice::( + &public_tx.message().instruction_data, + ) { + Ok(InboxInstruction::Dispatch(msg)) => Some(msg), + Err(_) => None, + } + } + + /// Re-derives the dispatch transaction the watcher should have injected for + /// `msg`, reading the source emission from the peer's finalized block. + async fn rederive(&self, msg: &CrossZoneMessage) -> Result { + let peer_block = self + .wait_for_peer_block(msg.src_zone, msg.src_block_id) + .await?; + + // Equivocation defense: the source block must be signed by the peer's + // pinned block-signing key, not merely inscribed on the channel. + if let Some(expected) = self.peer_pubkeys.get(&msg.src_zone) + && !peer_block.is_signed_by(expected) + { + bail!( + "forged cross-zone dispatch: peer zone {} block {} is not signed by the pinned block-signing key", + hex::encode(msg.src_zone), + msg.src_block_id + ); + } + + let emission_tx = peer_block + .body + .transactions + .get(usize::try_from(msg.src_tx_index).expect("u32 index fits in usize")) + .ok_or_else(|| { + anyhow::anyhow!( + "src_tx_index {} out of range in peer block", + msg.src_tx_index + ) + })?; + + let LeeTransaction::Public(emission_tx) = emission_tx else { + bail!("peer emission transaction is not public"); + }; + let message = emission_tx.message(); + let emission = + extract_emission(message.program_id, &message.instruction_data).ok_or_else(|| { + anyhow::anyhow!("peer transaction at src_tx_index is not a recognized emitter") + })?; + + if emission.target_zone != self.self_zone { + bail!("peer emission targets a different zone"); + } + + Ok(build_dispatch_from_emission( + msg.src_zone, + msg.src_block_id, + msg.src_tx_index, + message.program_id, + emission.target_program_id, + &emission.target_accounts, + emission.payload, + )) + } + + /// Resolves the referenced peer block, distinguishing forgery from lag. + /// + /// If the block is cached, return it. If our peer reader has already + /// finalized past `block_id` and we still do not have it, the reference is to + /// a block that does not exist on the peer chain, a forgery, so reject now. + /// Otherwise the reader simply has not caught up yet: keep waiting, since a + /// legitimate dispatch is only injected after its peer block finalized and + /// our reader of the same finalized chain will see it too. Rejecting on a + /// timeout here would turn a lagging reader into a permanent halt of an + /// honest message. + async fn wait_for_peer_block(&self, zone: ZoneId, block_id: u64) -> Result { + let mut waited = Duration::ZERO; + loop { + if let Some(block) = self.peers.get(zone, block_id).await { + return Ok(block); + } + if self + .peers + .highest_seen(zone) + .await + .is_some_and(|h| h >= block_id) + { + bail!( + "forged cross-zone reference: peer zone {} finalized past block {} but it is absent", + hex::encode(zone), + block_id + ); + } + if !waited.is_zero() && waited.as_secs().is_multiple_of(LAG_LOG_INTERVAL.as_secs()) { + info!( + "Waiting for peer zone {} to finalize block {} ({}s); reader is behind", + hex::encode(zone), + block_id, + waited.as_secs() + ); + } + tokio::time::sleep(Duration::from_secs(1)).await; + waited = waited.saturating_add(Duration::from_secs(1)); + } + } +} + +/// Reads a peer zone's finalized blocks from Bedrock into the shared cache. +#[expect( + clippy::infinite_loop, + reason = "the peer reader runs for the lifetime of the indexer process" +)] +async fn read_peer( + zone_indexer: ZoneIndexer, + peer_zone: ZoneId, + peers: PeerBlocks, + poll_interval: Duration, +) { + info!( + "Cross-zone peer reader started for {}", + hex::encode(peer_zone) + ); + + let mut cursor = None; + loop { + let stream = match zone_indexer.next_messages(cursor).await { + Ok(stream) => stream, + Err(err) => { + error!( + "Peer reader next_messages failed for {}: {err}", + hex::encode(peer_zone) + ); + tokio::time::sleep(poll_interval).await; + continue; + } + }; + let mut stream = std::pin::pin!(stream); + + while let Some((msg, slot)) = stream.next().await { + if let ZoneMessage::Block(zone_block) = msg { + match borsh::from_slice::(&zone_block.data) { + Ok(block) => peers.insert(peer_zone, block).await, + Err(err) => error!("Peer reader failed to deserialize block: {err}"), + } + } + cursor = Some(slot); + } + + tokio::time::sleep(poll_interval).await; + } +} + +#[cfg(test)] +mod tests { + use common::test_utils::produce_dummy_block; + use lee::{ + PrivateKey, PublicKey, PublicTransaction, + public_transaction::{Message, WitnessSet}, + }; + use ping_core::{SenderInstruction, ping_record_pda}; + + use super::*; + + const SELF_ZONE: ZoneId = [1; 32]; + const PEER_ZONE: ZoneId = [2; 32]; + const PEER_BLOCK_ID: u64 = 5; + + fn verifier() -> CrossZoneVerifier { + verifier_with_pinned_keys(HashMap::new()) + } + + fn verifier_with_pinned_keys(peer_pubkeys: HashMap) -> CrossZoneVerifier { + CrossZoneVerifier { + self_zone: SELF_ZONE, + peer_pubkeys, + peers: PeerBlocks::default(), + seen: Arc::new(RwLock::new(HashSet::new())), + } + } + + /// A `ping_sender` emission addressed to `SELF_ZONE` carrying `payload`. + 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()], + payload: payload.to_vec(), + ordinal: 0, + }; + let message = Message::try_new(programs::ping_sender().id(), vec![], vec![], send) + .expect("emission serializes"); + LeeTransaction::Public(PublicTransaction::new( + message, + WitnessSet::from_raw_parts(vec![]), + )) + } + + /// The dispatch a watcher would inject for a `PEER_BLOCK_ID` emission of `payload`. + fn dispatch(payload: &[u8]) -> LeeTransaction { + let receiver_id = programs::ping_receiver().id(); + LeeTransaction::Public(build_dispatch_from_emission( + PEER_ZONE, + PEER_BLOCK_ID, + 0, + programs::ping_sender().id(), + receiver_id, + &[ping_record_pda(receiver_id).into_value()], + payload.to_vec(), + )) + } + + #[tokio::test] + async fn verifies_dispatch_matching_a_peer_emission() { + let verifier = verifier(); + verifier + .peers + .insert( + PEER_ZONE, + produce_dummy_block(PEER_BLOCK_ID, None, vec![emission(b"hi")]), + ) + .await; + + let block = produce_dummy_block(9, None, vec![dispatch(b"hi")]); + verifier + .verify_block(&block) + .await + .expect("dispatch matching the peer emission verifies"); + } + + #[tokio::test] + async fn rejects_dispatch_with_no_matching_emission() { + let verifier = verifier(); + // The peer block carries the real emission, but the block claims a + // different payload, so re-derivation does not reproduce it. + verifier + .peers + .insert( + PEER_ZONE, + produce_dummy_block(PEER_BLOCK_ID, None, vec![emission(b"real")]), + ) + .await; + + let block = produce_dummy_block(9, None, vec![dispatch(b"forged")]); + let err = verifier.verify_block(&block).await.unwrap_err(); + assert!( + err.to_string().contains("forged"), + "unexpected error: {err}" + ); + } + + #[tokio::test] + async fn verifies_dispatch_signed_by_the_pinned_peer_key() { + // produce_dummy_block signs with PrivateKey([37; 32]); pin its pubkey. + let signer = PublicKey::new_from_private_key(&PrivateKey::try_new([37; 32]).unwrap()); + let mut keys = HashMap::new(); + keys.insert(PEER_ZONE, signer); + let verifier = verifier_with_pinned_keys(keys); + verifier + .peers + .insert( + PEER_ZONE, + produce_dummy_block(PEER_BLOCK_ID, None, vec![emission(b"hi")]), + ) + .await; + + let block = produce_dummy_block(9, None, vec![dispatch(b"hi")]); + verifier + .verify_block(&block) + .await + .expect("a dispatch from the pinned signer verifies"); + } + + #[tokio::test] + async fn rejects_dispatch_from_a_block_not_signed_by_the_pinned_key() { + // Pin a different key than the one that signed the peer block. + let mut keys = HashMap::new(); + keys.insert(PEER_ZONE, PublicKey::try_new([42; 32]).unwrap()); + let verifier = verifier_with_pinned_keys(keys); + verifier + .peers + .insert( + PEER_ZONE, + produce_dummy_block(PEER_BLOCK_ID, None, vec![emission(b"hi")]), + ) + .await; + + let block = produce_dummy_block(9, None, vec![dispatch(b"hi")]); + let err = verifier.verify_block(&block).await.unwrap_err(); + assert!( + err.to_string().contains("pinned"), + "unexpected error: {err}" + ); + } + + #[tokio::test] + async fn rejects_reference_to_a_block_the_peer_never_finalized() { + let verifier = verifier(); + // The reader has finalized past PEER_BLOCK_ID (it holds a later block) but + // never saw PEER_BLOCK_ID itself, so a dispatch referencing it is a forgery + // and must be rejected rather than waited on forever. + verifier + .peers + .insert( + PEER_ZONE, + produce_dummy_block(PEER_BLOCK_ID + 1, None, vec![emission(b"hi")]), + ) + .await; + + let block = produce_dummy_block(9, None, vec![dispatch(b"hi")]); + let err = verifier.verify_block(&block).await.unwrap_err(); + assert!( + err.to_string().contains("forged"), + "unexpected error: {err}" + ); + } + + #[tokio::test] + async fn rejects_replayed_dispatch() { + let verifier = verifier(); + verifier + .peers + .insert( + PEER_ZONE, + produce_dummy_block(PEER_BLOCK_ID, None, vec![emission(b"hi")]), + ) + .await; + + let first = produce_dummy_block(9, None, vec![dispatch(b"hi")]); + verifier + .verify_block(&first) + .await + .expect("first delivery verifies"); + + let replay = produce_dummy_block(10, None, vec![dispatch(b"hi")]); + let err = verifier.verify_block(&replay).await.unwrap_err(); + assert!( + err.to_string().contains("replay"), + "unexpected error: {err}" + ); + } +} diff --git a/lez/indexer/core/src/lib.rs b/lez/indexer/core/src/lib.rs index 719caa08..7bee6387 100644 --- a/lez/indexer/core/src/lib.rs +++ b/lez/indexer/core/src/lib.rs @@ -17,11 +17,13 @@ use crate::{ block_store::{AcceptOutcome, IndexerStore}, chain_consistency::ChainConsistency, config::IndexerConfig, + cross_zone_verifier::CrossZoneVerifier, status::{IndexerStatus, IndexerSyncStatus}, }; pub mod block_store; pub mod chain_consistency; pub mod config; +pub mod cross_zone_verifier; pub mod ingest_error; mod retry; pub mod stall_reason; @@ -39,6 +41,8 @@ pub struct IndexerCore { pub store: IndexerStore, /// Live ingestion status; updated by the ingest stream, read by `status`. pub status: Arc>, + /// Option B cross-zone verifier; `None` when cross-zone messaging is disabled. + pub verifier: Option, } impl IndexerCore { @@ -89,12 +93,35 @@ impl IndexerCore { ); let zone_indexer = ZoneIndexer::new(config.channel_id, node.clone()); + // Genesis accounts the indexer must seed to match the sequencer's state, + // since none are produced by a transaction: the cross-zone inbox config + // and any bridge-lock holdings. Both go through the same builders the + // sequencer uses, so the states are byte-identical. + let mut genesis_seed = Vec::new(); + if let Some(cross_zone) = config.cross_zone.as_ref() { + let self_zone: [u8; 32] = *config.channel_id.as_ref(); + genesis_seed.push(cross_zone::build_inbox_config_account( + self_zone, cross_zone, + )); + } + for holding in &config.bridge_lock_holdings { + genesis_seed.push(cross_zone::build_holding_account( + holding.holder, + holding.amount, + )); + } + + // Option B verifier: re-derives each cross-zone dispatch from the peer's + // finalized blocks. `None` when cross-zone messaging is disabled. + let verifier = CrossZoneVerifier::start(&config); + Ok(Self { zone_indexer: Arc::new(zone_indexer), + store: IndexerStore::open_db(&home, genesis_seed)?, node, config, - store: IndexerStore::open_db(&home)?, status: Arc::new(ArcSwap::from_pointee(IndexerSyncStatus::starting())), + verifier, }) } @@ -229,6 +256,22 @@ impl IndexerCore { } }; + // Option B: re-derive and verify every cross-zone dispatch + // before applying the block. A forged or replayed dispatch + // halts ingestion rather than persisting an invalid state. + if let Some(verifier) = &self.verifier + && let Err(err) = verifier.verify_block(&block).await + { + error!( + "Cross-zone verification failed for block {}: {err:#}. Halting indexer ingestion.", + block.header.block_id + ); + self.set_status(IndexerSyncStatus::error(format!( + "cross-zone verification failed: {err:#}" + ))); + return; + } + match self.store.accept_block(&block, slot).await { Ok(AcceptOutcome::Applied) => { retry_gate.reset(); diff --git a/lez/programs/Cargo.toml b/lez/programs/Cargo.toml index 06038d7f..707c3d6b 100644 --- a/lez/programs/Cargo.toml +++ b/lez/programs/Cargo.toml @@ -54,6 +54,36 @@ name = "vault" path = "vault/src/main.rs" required-features = ["programs"] +[[bin]] +name = "cross_zone_outbox" +path = "cross_zone_outbox/src/main.rs" +required-features = ["programs"] + +[[bin]] +name = "cross_zone_inbox" +path = "cross_zone_inbox/src/main.rs" +required-features = ["programs"] + +[[bin]] +name = "ping_sender" +path = "ping_sender/src/main.rs" +required-features = ["programs"] + +[[bin]] +name = "ping_receiver" +path = "ping_receiver/src/main.rs" +required-features = ["programs"] + +[[bin]] +name = "bridge_lock" +path = "bridge_lock/src/main.rs" +required-features = ["programs"] + +[[bin]] +name = "wrapped_token" +path = "wrapped_token/src/main.rs" +required-features = ["programs"] + [features] # TODO: Uncomment once https://github.com/risc0/risc0/issues/3772 is resolved. # default = ["artifacts"] @@ -78,6 +108,11 @@ programs = [ "dep:faucet_core", "dep:token_core", "dep:vault_core", + "dep:cross_zone_inbox_core", + "dep:cross_zone_outbox_core", + "dep:bridge_lock_core", + "dep:wrapped_token_core", + "dep:ping_core", ] [dependencies] @@ -92,6 +127,11 @@ clock_core = { workspace = true, optional = true } faucet_core = { workspace = true, optional = true } token_core = { workspace = true, optional = true } vault_core = { workspace = true, optional = true } +cross_zone_inbox_core = { workspace = true, optional = true } +cross_zone_outbox_core = { workspace = true, optional = true } +bridge_lock_core = { workspace = true, optional = true } +wrapped_token_core = { workspace = true, optional = true } +ping_core = { workspace = true, optional = true } amm_program = { path = "amm", optional = true } associated_token_account_program = { path = "associated_token_account", optional = true } diff --git a/lez/programs/bridge_lock/Cargo.toml b/lez/programs/bridge_lock/Cargo.toml new file mode 100644 index 00000000..8a4d6c20 --- /dev/null +++ b/lez/programs/bridge_lock/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "bridge_lock_program" +version = "0.1.0" +edition = "2024" +license = { workspace = true } + +[lints] +workspace = true + +[dependencies] +lee_core.workspace = true +bridge_lock_core.workspace = true +cross_zone_outbox_core.workspace = true +wrapped_token_core.workspace = true +risc0-zkvm.workspace = true diff --git a/lez/programs/bridge_lock/core/Cargo.toml b/lez/programs/bridge_lock/core/Cargo.toml new file mode 100644 index 00000000..190fc4f2 --- /dev/null +++ b/lez/programs/bridge_lock/core/Cargo.toml @@ -0,0 +1,12 @@ +[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"] } diff --git a/lez/programs/bridge_lock/core/src/lib.rs b/lez/programs/bridge_lock/core/src/lib.rs new file mode 100644 index 00000000..e5a36113 --- /dev/null +++ b/lez/programs/bridge_lock/core/src/lib.rs @@ -0,0 +1,71 @@ +//! 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 const 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 const fn balance_bytes(amount: u128) -> [u8; 16] { + amount.to_le_bytes() +} + +#[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/lez/programs/bridge_lock/src/main.rs b/lez/programs/bridge_lock/src/main.rs new file mode 100644 index 00000000..aa8b5f93 --- /dev/null +++ b/lez/programs/bridge_lock/src/main.rs @@ -0,0 +1,121 @@ +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().is_multiple_of(4), + "payload must be u32-aligned instruction words" + ); + let words: Vec = payload + .chunks_exact(4) + .map(|chunk| u32::from_le_bytes(chunk.try_into().unwrap_or_else(|_| unreachable!()))) + .collect(); + risc0_zkvm::serde::from_slice(&words).expect("payload decodes to a wrapped-token instruction") +} diff --git a/lez/programs/cross_zone_inbox/Cargo.toml b/lez/programs/cross_zone_inbox/Cargo.toml new file mode 100644 index 00000000..6f4c651a --- /dev/null +++ b/lez/programs/cross_zone_inbox/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "cross_zone_inbox_program" +version = "0.1.0" +edition = "2024" +license = { workspace = true } + +[lints] +workspace = true + +[dependencies] +lee_core.workspace = true +cross_zone_inbox_core.workspace = true diff --git a/lez/programs/cross_zone_inbox/core/Cargo.toml b/lez/programs/cross_zone_inbox/core/Cargo.toml new file mode 100644 index 00000000..6bea109f --- /dev/null +++ b/lez/programs/cross_zone_inbox/core/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "cross_zone_inbox_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 +borsh.workspace = true diff --git a/lez/programs/cross_zone_inbox/core/src/lib.rs b/lez/programs/cross_zone_inbox/core/src/lib.rs new file mode 100644 index 00000000..761a8a1c --- /dev/null +++ b/lez/programs/cross_zone_inbox/core/src/lib.rs @@ -0,0 +1,203 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use borsh::{BorshDeserialize, BorshSerialize}; +use lee_core::{ + account::AccountId, + program::{PdaSeed, ProgramId}, +}; +use serde::{Deserialize, Serialize}; + +/// Source blocks per seen-set shard, so no single seen account grows without bound. +pub const EPOCH_BLOCKS: u64 = 10_000; + +const MESSAGE_KEY_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneMsgKey/00000/"; +const INBOX_CONFIG_SEED: [u8; 32] = *b"/LEZ/v0.3/CrossZoneInboxCfg/000/"; +const INBOX_SEEN_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneInboxSeen/00/"; + +/// Raw 32-byte zone (channel) id; the host maps it to the zone-sdk `ChannelId`. +pub type ZoneId = [u8; 32]; + +/// Block-signing public key pinned per peer zone. +pub type ExpectedPubkey = [u8; 32]; + +/// Content-addressed replay key for a delivered message. +pub type MessageKey = [u8; 32]; + +/// A peer zone whose outbox a zone watches for inbound cross-zone messages. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct CrossZonePeer { + /// The peer's Bedrock channel; its 32 bytes double as the peer's zone id. + pub channel_id: ZoneId, + /// Programs on the local zone a message from this peer is allowed to target. + pub allowed_targets: Vec, + /// The peer's block-signing public key, pinned to reject blocks inscribed by + /// anyone other than that zone's sequencer. `None` skips the check (the + /// channel signer is still authenticated by the zone-sdk). + #[serde(default)] + pub expected_block_signing_pubkey: Option<[u8; 32]>, +} + +/// Cross-zone configuration shared by a zone's sequencer (watcher) and indexer +/// (verifier): the peers it reads from Bedrock and, per peer, the local programs +/// they may deliver to. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct CrossZoneConfig { + pub peers: Vec, +} + +/// A finalized outbound message observed on a peer zone, addressed to a program +/// on this zone. The watcher fills it from the peer's block; it is never +/// self-reported by a user. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CrossZoneMessage { + pub src_zone: ZoneId, + pub src_block_id: u64, + pub src_tx_index: u32, + pub src_program_id: ProgramId, + pub target_program_id: ProgramId, + pub payload: Vec, + /// Reserved for a future source-state proof; MUST be `None` in v1. + pub l1_inclusion_witness: Option>, +} + +/// Peer and per-peer target allowlists, plus this inbox's own zone id. +#[derive( + Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, BorshSerialize, BorshDeserialize, +)] +pub struct InboxConfig { + pub self_zone: ZoneId, + pub allowed_peers: BTreeMap, + pub allowed_targets: BTreeMap>, +} + +impl InboxConfig { + /// Borsh-encoded form stored in the inbox config account. + #[must_use] + pub fn to_bytes(&self) -> Vec { + borsh::to_vec(self).expect("InboxConfig serializes") + } + + /// Decodes an [`InboxConfig`] from account data. + pub fn from_bytes(bytes: &[u8]) -> borsh::io::Result { + borsh::from_slice(bytes) + } +} + +/// The replay keys seen for one `(src_zone, epoch)` shard. +#[derive(Clone, Debug, Default, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +pub struct SeenShard(pub BTreeSet); + +impl SeenShard { + /// Decodes a shard from account data; empty data is an empty shard. + pub fn from_bytes(bytes: &[u8]) -> borsh::io::Result { + if bytes.is_empty() { + return Ok(Self::default()); + } + borsh::from_slice(bytes) + } + + #[must_use] + pub fn to_bytes(&self) -> Vec { + borsh::to_vec(self).expect("SeenShard serializes") + } + + #[must_use] + pub fn contains(&self, key: &MessageKey) -> bool { + self.0.contains(key) + } + + /// Inserts a key; returns true if it was newly inserted. + pub fn insert(&mut self, key: MessageKey) -> bool { + self.0.insert(key) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Instruction { + /// Delivers a finalized peer message to its target program. + Dispatch(CrossZoneMessage), +} + +/// Content-addressed replay key for a delivered message. +/// +/// Hashes `(src_zone, src_block_id, src_tx_index)` under a domain separator. +/// Watcher-independent and immune to proof malleability, since it keys on block +/// id plus index rather than a tx hash. +#[must_use] +pub fn message_key(src_zone: &ZoneId, src_block_id: u64, src_tx_index: u32) -> MessageKey { + use risc0_zkvm::sha::{Impl, Sha256 as _}; + + let mut bytes = [0_u8; 76]; + bytes[..32].copy_from_slice(&MESSAGE_KEY_DOMAIN); + bytes[32..64].copy_from_slice(src_zone); + bytes[64..72].copy_from_slice(&src_block_id.to_le_bytes()); + bytes[72..].copy_from_slice(&src_tx_index.to_le_bytes()); + + Impl::hash_bytes(&bytes) + .as_bytes() + .try_into() + .unwrap_or_else(|_| unreachable!()) +} + +/// The config account holding the allowlists. +#[must_use] +pub fn inbox_config_account_id(inbox_id: ProgramId) -> AccountId { + AccountId::for_public_pda(&inbox_id, &PdaSeed::new(INBOX_CONFIG_SEED)) +} + +/// The seen-set shard for the `(src_zone, epoch)` the message falls in. +#[must_use] +pub fn inbox_seen_shard_account_id( + inbox_id: ProgramId, + src_zone: &ZoneId, + src_block_id: u64, +) -> AccountId { + AccountId::for_public_pda(&inbox_id, &inbox_seen_shard_seed(src_zone, src_block_id)) +} + +/// Seed of the seen-shard PDA, exposed so the guest can claim the account. +#[must_use] +pub fn inbox_seen_shard_seed(src_zone: &ZoneId, src_block_id: u64) -> PdaSeed { + use risc0_zkvm::sha::{Impl, Sha256 as _}; + + let src_epoch = src_block_id.wrapping_div(EPOCH_BLOCKS); + let mut bytes = [0_u8; 72]; + bytes[..32].copy_from_slice(&INBOX_SEEN_SEED_DOMAIN); + bytes[32..64].copy_from_slice(src_zone); + bytes[64..].copy_from_slice(&src_epoch.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 super::*; + + fn zone(b: u8) -> ZoneId { + [b; 32] + } + + #[test] + fn message_key_is_stable_and_content_addressed() { + assert_eq!(message_key(&zone(1), 7, 3), message_key(&zone(1), 7, 3)); + assert_ne!(message_key(&zone(1), 7, 3), message_key(&zone(2), 7, 3)); + assert_ne!(message_key(&zone(1), 7, 3), message_key(&zone(1), 8, 3)); + assert_ne!(message_key(&zone(1), 7, 3), message_key(&zone(1), 7, 4)); + } + + #[test] + fn seen_shards_split_on_epoch_boundary() { + let id: ProgramId = [9; 8]; + assert_eq!( + inbox_seen_shard_account_id(id, &zone(1), 0), + inbox_seen_shard_account_id(id, &zone(1), EPOCH_BLOCKS - 1), + ); + assert_ne!( + inbox_seen_shard_account_id(id, &zone(1), EPOCH_BLOCKS - 1), + inbox_seen_shard_account_id(id, &zone(1), EPOCH_BLOCKS), + ); + } +} diff --git a/lez/programs/cross_zone_inbox/src/main.rs b/lez/programs/cross_zone_inbox/src/main.rs new file mode 100644 index 00000000..8bb06ed8 --- /dev/null +++ b/lez/programs/cross_zone_inbox/src/main.rs @@ -0,0 +1,125 @@ +use cross_zone_inbox_core::{ + InboxConfig, Instruction, SeenShard, inbox_config_account_id, inbox_seen_shard_account_id, + inbox_seen_shard_seed, message_key, +}; +use lee_core::{ + account::AccountWithMetadata, + program::{AccountPostState, ChainedCall, Claim, ProgramInput, ProgramOutput, read_lee_inputs}, +}; + +fn unchanged(pre: &AccountWithMetadata) -> AccountPostState { + AccountPostState::new(pre.account.clone()) +} + +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + instruction, + }, + instruction_words, + ) = read_lee_inputs::(); + + assert!( + caller_program_id.is_none(), + "Inbox is only invoked as a top-level sequencer-origin transaction" + ); + + let Instruction::Dispatch(msg) = instruction; + + assert!( + msg.l1_inclusion_witness.is_none(), + "l1_inclusion_witness must be None in v1" + ); + + // pre_states layout: [config, seen_shard, 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 target_accounts: Vec = accounts.collect(); + + assert_eq!( + config.account_id, + inbox_config_account_id(self_program_id), + "First account must be the inbox config PDA" + ); + assert_eq!( + seen.account_id, + inbox_seen_shard_account_id(self_program_id, &msg.src_zone, msg.src_block_id), + "Second account must be the seen-shard PDA" + ); + + let cfg = InboxConfig::from_bytes(&config.account.data.clone().into_inner()) + .expect("inbox config decodes"); + + assert!( + msg.src_zone != cfg.self_zone, + "Source zone must not be this zone" + ); + let allowed_targets = cfg + .allowed_targets + .get(&msg.src_zone) + .expect("Source zone is not an allowed peer"); + assert!( + allowed_targets.contains(&msg.target_program_id), + "Target program is not allowed for this peer" + ); + + let key = message_key(&msg.src_zone, msg.src_block_id, msg.src_tx_index); + let mut shard = + SeenShard::from_bytes(&seen.account.data.clone().into_inner()).expect("seen shard decodes"); + let already_seen = shard.contains(&key); + + // On replay this is a no-op: the seen shard is untouched and no call is made. + let (seen_post, chained_calls) = if already_seen { + (unchanged(&seen), vec![]) + } else { + shard.insert(key); + let mut seen_account = seen.account.clone(); + seen_account.data = shard + .to_bytes() + .try_into() + .expect("seen shard fits in account data"); + let seen_post = AccountPostState::new_claimed_if_default( + seen_account, + Claim::Pda(inbox_seen_shard_seed(&msg.src_zone, msg.src_block_id)), + ); + + // The payload carries the target instruction as risc0 words, little-endian. + assert!( + msg.payload.len().is_multiple_of(4), + "payload must be u32-aligned instruction words" + ); + let instruction_data = msg + .payload + .chunks_exact(4) + .map(|c| u32::from_le_bytes(c.try_into().unwrap_or_else(|_| unreachable!()))) + .collect(); + + let call = ChainedCall { + program_id: msg.target_program_id, + pre_states: target_accounts.clone(), + instruction_data, + pda_seeds: vec![], + }; + (seen_post, vec![call]) + }; + + let mut post_states = vec![unchanged(&config), seen_post]; + post_states.extend(target_accounts.iter().map(unchanged)); + + let mut output_pre_states = vec![config, seen]; + output_pre_states.extend(target_accounts); + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + output_pre_states, + post_states, + ) + .with_chained_calls(chained_calls) + .write(); +} diff --git a/lez/programs/cross_zone_outbox/Cargo.toml b/lez/programs/cross_zone_outbox/Cargo.toml new file mode 100644 index 00000000..2f236cbf --- /dev/null +++ b/lez/programs/cross_zone_outbox/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "cross_zone_outbox_program" +version = "0.1.0" +edition = "2024" +license = { workspace = true } + +[lints] +workspace = true + +[dependencies] +lee_core.workspace = true +cross_zone_outbox_core.workspace = true diff --git a/lez/programs/cross_zone_outbox/core/Cargo.toml b/lez/programs/cross_zone_outbox/core/Cargo.toml new file mode 100644 index 00000000..c7876286 --- /dev/null +++ b/lez/programs/cross_zone_outbox/core/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "cross_zone_outbox_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 +borsh.workspace = true diff --git a/lez/programs/cross_zone_outbox/core/src/lib.rs b/lez/programs/cross_zone_outbox/core/src/lib.rs new file mode 100644 index 00000000..736b5fa2 --- /dev/null +++ b/lez/programs/cross_zone_outbox/core/src/lib.rs @@ -0,0 +1,93 @@ +use borsh::{BorshDeserialize, BorshSerialize}; +use lee_core::{ + account::AccountId, + program::{PdaSeed, ProgramId}, +}; +use serde::{Deserialize, Serialize}; + +const OUTBOX_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneOutbox/00000/"; + +/// Raw 32-byte zone (channel) id; the host maps it to the zone-sdk `ChannelId`. +pub type ZoneId = [u8; 32]; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Instruction { + /// Records an outbound cross-zone message as a write to a self-owned PDA. + /// + /// Required accounts (1): + /// - Outbox PDA account + Emit { + target_zone: ZoneId, + target_program_id: ProgramId, + /// Accounts the destination inbox must hand to the target program's + /// chained call. The emitter specifies them; the watcher forwards them + /// verbatim so the inbox stays target-agnostic. + target_accounts: Vec<[u8; 32]>, + payload: Vec, + ordinal: u32, + }, +} + +/// 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. +#[derive(Clone, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +pub struct OutboxRecord { + pub target_zone: ZoneId, + pub target_program_id: ProgramId, + pub target_accounts: Vec<[u8; 32]>, + pub payload: Vec, +} + +impl OutboxRecord { + /// Borsh-encoded form stored in the outbox PDA's account data. + #[must_use] + pub fn to_bytes(&self) -> Vec { + borsh::to_vec(self).expect("OutboxRecord serializes") + } + + /// Decodes an [`OutboxRecord`] from account data. + pub fn from_bytes(bytes: &[u8]) -> borsh::io::Result { + borsh::from_slice(bytes) + } +} + +/// PDA holding one emitted message, keyed by destination zone and a per-zone +/// ordinal. +#[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)) +} + +/// 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 { + use risc0_zkvm::sha::{Impl, Sha256 as _}; + + let mut bytes = [0_u8; 68]; + 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()); + + let seed: [u8; 32] = Impl::hash_bytes(&bytes) + .as_bytes() + .try_into() + .unwrap_or_else(|_| unreachable!()); + PdaSeed::new(seed) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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)); + } +} diff --git a/lez/programs/cross_zone_outbox/src/main.rs b/lez/programs/cross_zone_outbox/src/main.rs new file mode 100644 index 00000000..432e8d9c --- /dev/null +++ b/lez/programs/cross_zone_outbox/src/main.rs @@ -0,0 +1,72 @@ +use cross_zone_outbox_core::{Instruction, OutboxRecord, outbox_pda, outbox_pda_seed}; +use lee_core::{ + account::AccountWithMetadata, + program::{AccountPostState, Claim, ProgramInput, ProgramOutput, read_lee_inputs}, +}; + +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + instruction, + }, + instruction_words, + ) = read_lee_inputs::(); + + assert!( + caller_program_id.is_some(), + "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 { + target_zone, + target_program_id, + target_accounts, + payload, + ordinal, + } => ( + target_zone, + target_program_id, + target_accounts, + payload, + ordinal, + ), + }; + + let [outbox] = + <[AccountWithMetadata; 1]>::try_from(pre_states).expect("Emit requires exactly 1 account"); + + assert_eq!( + outbox.account_id, + outbox_pda(self_program_id, &target_zone, ordinal), + "Account must be the outbox PDA for (target_zone, ordinal)" + ); + + let mut post_account = outbox.account.clone(); + post_account.data = OutboxRecord { + target_zone, + target_program_id, + target_accounts, + payload, + } + .to_bytes() + .try_into() + .expect("OutboxRecord fits in account data"); + + let post = AccountPostState::new_claimed_if_default( + post_account, + Claim::Pda(outbox_pda_seed(&target_zone, ordinal)), + ); + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + vec![outbox], + vec![post], + ) + .write(); +} diff --git a/lez/programs/ping_core/Cargo.toml b/lez/programs/ping_core/Cargo.toml new file mode 100644 index 00000000..29870630 --- /dev/null +++ b/lez/programs/ping_core/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "ping_core" +version = "0.1.0" +edition = "2024" +license = { workspace = true } + +[lints] +workspace = true + +[dependencies] +lee_core.workspace = true +serde = { workspace = true, features = ["alloc"] } diff --git a/lez/programs/ping_core/src/lib.rs b/lez/programs/ping_core/src/lib.rs new file mode 100644 index 00000000..80b27247 --- /dev/null +++ b/lez/programs/ping_core/src/lib.rs @@ -0,0 +1,38 @@ +use lee_core::{ + account::AccountId, + program::{PdaSeed, ProgramId}, +}; +use serde::{Deserialize, Serialize}; + +const PING_RECORD_SEED: [u8; 32] = *b"/LEZ/v0.3/PingRecord/0000000000/"; + +/// Instruction delivered to `ping_receiver` by the inbox: record the payload. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum ReceiverInstruction { + Record { payload: Vec }, +} + +/// Instruction to `ping_sender`: forwarded verbatim into `cross_zone_outbox::Instruction::Emit`. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum SenderInstruction { + Send { + outbox_program_id: ProgramId, + target_zone: [u8; 32], + target_program_id: ProgramId, + target_accounts: Vec<[u8; 32]>, + payload: Vec, + ordinal: u32, + }, +} + +/// The account a `ping_receiver` records the latest delivered payload into. +#[must_use] +pub fn ping_record_pda(receiver_id: ProgramId) -> AccountId { + AccountId::for_public_pda(&receiver_id, &ping_record_seed()) +} + +/// Seed of the record PDA, exposed so the guest can claim the account. +#[must_use] +pub const fn ping_record_seed() -> PdaSeed { + PdaSeed::new(PING_RECORD_SEED) +} diff --git a/lez/programs/ping_receiver/Cargo.toml b/lez/programs/ping_receiver/Cargo.toml new file mode 100644 index 00000000..a1d88f39 --- /dev/null +++ b/lez/programs/ping_receiver/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "ping_receiver_program" +version = "0.1.0" +edition = "2024" +license = { workspace = true } + +[lints] +workspace = true + +[dependencies] +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 new file mode 100644 index 00000000..4fd9679f --- /dev/null +++ b/lez/programs/ping_receiver/src/main.rs @@ -0,0 +1,48 @@ +use lee_core::{ + account::AccountWithMetadata, + program::{AccountPostState, Claim, ProgramInput, ProgramOutput, read_lee_inputs}, +}; +use ping_core::{ReceiverInstruction, ping_record_pda, ping_record_seed}; + +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + instruction, + }, + instruction_words, + ) = read_lee_inputs::(); + + assert!( + caller_program_id.is_some(), + "ping_receiver is only callable through a chained call" + ); + + 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" + ); + + let mut post_account = record.account.clone(); + post_account.data = payload.try_into().expect("payload fits in account data"); + let post = + AccountPostState::new_claimed_if_default(post_account, Claim::Pda(ping_record_seed())); + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + vec![record], + vec![post], + ) + .write(); +} diff --git a/lez/programs/ping_sender/Cargo.toml b/lez/programs/ping_sender/Cargo.toml new file mode 100644 index 00000000..8567ae55 --- /dev/null +++ b/lez/programs/ping_sender/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "ping_sender_program" +version = "0.1.0" +edition = "2024" +license = { workspace = true } + +[lints] +workspace = true + +[dependencies] +lee_core.workspace = true +ping_core.workspace = true +cross_zone_outbox_core.workspace = true diff --git a/lez/programs/ping_sender/src/main.rs b/lez/programs/ping_sender/src/main.rs new file mode 100644 index 00000000..d0ad04f5 --- /dev/null +++ b/lez/programs/ping_sender/src/main.rs @@ -0,0 +1,59 @@ +use cross_zone_outbox_core::Instruction as OutboxInstruction; +use lee_core::{ + account::AccountWithMetadata, + program::{AccountPostState, ChainedCall, ProgramInput, ProgramOutput, read_lee_inputs}, +}; +use ping_core::SenderInstruction; + +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + instruction, + }, + instruction_words, + ) = read_lee_inputs::(); + + assert!( + caller_program_id.is_none(), + "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; + + // 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"); + + 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![outbox.clone()], + vec![AccountPostState::new(outbox.account)], + ) + .with_chained_calls(vec![call]) + .write(); +} diff --git a/lez/programs/src/lib.rs b/lez/programs/src/lib.rs index 4acade0f..fb448038 100644 --- a/lez/programs/src/lib.rs +++ b/lez/programs/src/lib.rs @@ -10,9 +10,12 @@ mod inner { use guests::{ AMM_ELF, AMM_ID, ASSOCIATED_TOKEN_ACCOUNT_ELF, ASSOCIATED_TOKEN_ACCOUNT_ID, - AUTHENTICATED_TRANSFER_ELF, AUTHENTICATED_TRANSFER_ID, BRIDGE_ELF, BRIDGE_ID, CLOCK_ELF, - CLOCK_ID, FAUCET_ELF, FAUCET_ID, PINATA_ELF, PINATA_ID, PINATA_TOKEN_ELF, PINATA_TOKEN_ID, - TOKEN_ELF, TOKEN_ID, VAULT_ELF, VAULT_ID, + AUTHENTICATED_TRANSFER_ELF, AUTHENTICATED_TRANSFER_ID, BRIDGE_ELF, BRIDGE_ID, + BRIDGE_LOCK_ELF, BRIDGE_LOCK_ID, CLOCK_ELF, 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, PINATA_TOKEN_ELF, PINATA_TOKEN_ID, PING_RECEIVER_ELF, + PING_RECEIVER_ID, PING_SENDER_ELF, PING_SENDER_ID, TOKEN_ELF, TOKEN_ID, VAULT_ELF, + VAULT_ID, WRAPPED_TOKEN_ELF, WRAPPED_TOKEN_ID, }; use lee::program::Program; @@ -87,6 +90,42 @@ mod inner { Program::new_unchecked(BRIDGE_ID, Cow::Borrowed(BRIDGE_ELF)) } + #[must_use] + #[inline] + pub const fn cross_zone_outbox() -> Program { + Program::new_unchecked(CROSS_ZONE_OUTBOX_ID, Cow::Borrowed(CROSS_ZONE_OUTBOX_ELF)) + } + + #[must_use] + #[inline] + pub const fn cross_zone_inbox() -> Program { + Program::new_unchecked(CROSS_ZONE_INBOX_ID, Cow::Borrowed(CROSS_ZONE_INBOX_ELF)) + } + + #[must_use] + #[inline] + pub const fn ping_sender() -> Program { + Program::new_unchecked(PING_SENDER_ID, Cow::Borrowed(PING_SENDER_ELF)) + } + + #[must_use] + #[inline] + pub const fn ping_receiver() -> Program { + Program::new_unchecked(PING_RECEIVER_ID, Cow::Borrowed(PING_RECEIVER_ELF)) + } + + #[must_use] + #[inline] + pub const fn bridge_lock() -> Program { + Program::new_unchecked(BRIDGE_LOCK_ID, Cow::Borrowed(BRIDGE_LOCK_ELF)) + } + + #[must_use] + #[inline] + pub const fn wrapped_token() -> Program { + Program::new_unchecked(WRAPPED_TOKEN_ID, Cow::Borrowed(WRAPPED_TOKEN_ELF)) + } + #[cfg(test)] mod tests { use super::*; @@ -127,6 +166,12 @@ mod inner { (PINATA_TOKEN_ELF, PINATA_TOKEN_ID), (TOKEN_ELF, TOKEN_ID), (VAULT_ELF, VAULT_ID), + (CROSS_ZONE_OUTBOX_ELF, CROSS_ZONE_OUTBOX_ID), + (CROSS_ZONE_INBOX_ELF, CROSS_ZONE_INBOX_ID), + (PING_SENDER_ELF, PING_SENDER_ID), + (PING_RECEIVER_ELF, PING_RECEIVER_ID), + (BRIDGE_LOCK_ELF, BRIDGE_LOCK_ID), + (WRAPPED_TOKEN_ELF, WRAPPED_TOKEN_ID), ]; for (elf, expected_id) in cases { let program = Program::new((*elf).into()).unwrap(); diff --git a/lez/programs/wrapped_token/Cargo.toml b/lez/programs/wrapped_token/Cargo.toml new file mode 100644 index 00000000..a80f60ff --- /dev/null +++ b/lez/programs/wrapped_token/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "wrapped_token_program" +version = "0.1.0" +edition = "2024" +license = { workspace = true } + +[lints] +workspace = true + +[dependencies] +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 new file mode 100644 index 00000000..ef0aabbc --- /dev/null +++ b/lez/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/lez/programs/wrapped_token/core/src/lib.rs b/lez/programs/wrapped_token/core/src/lib.rs new file mode 100644 index 00000000..8e13567a --- /dev/null +++ b/lez/programs/wrapped_token/core/src/lib.rs @@ -0,0 +1,121 @@ +//! 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 const 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 = [0_u8; 64]; + bytes[..32].copy_from_slice(&HOLDING_SEED_DOMAIN); + bytes[32..].copy_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 const 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]) + ); + } +} diff --git a/lez/programs/wrapped_token/src/main.rs b/lez/programs/wrapped_token/src/main.rs new file mode 100644 index 00000000..d0e7595f --- /dev/null +++ b/lez/programs/wrapped_token/src/main.rs @@ -0,0 +1,70 @@ +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/lez/sequencer/core/Cargo.toml b/lez/sequencer/core/Cargo.toml index 1e753000..cd89f223 100644 --- a/lez/sequencer/core/Cargo.toml +++ b/lez/sequencer/core/Cargo.toml @@ -20,6 +20,8 @@ bridge_core.workspace = true vault_core.workspace = true programs.workspace = true system_accounts.workspace = true +cross_zone.workspace = true +cross_zone_inbox_core.workspace = true logos-blockchain-key-management-system-service.workspace = true logos-blockchain-core.workspace = true diff --git a/lez/sequencer/core/src/config.rs b/lez/sequencer/core/src/config.rs index b445bcd5..a11a9988 100644 --- a/lez/sequencer/core/src/config.rs +++ b/lez/sequencer/core/src/config.rs @@ -8,6 +8,7 @@ use std::{ use anyhow::Result; use bytesize::ByteSize; use common::config::BasicAuth; +pub use cross_zone_inbox_core::{CrossZoneConfig, CrossZonePeer}; use humantime_serde; use lee::AccountId; use logos_blockchain_core::mantle::ops::channel::ChannelId; @@ -25,6 +26,11 @@ pub enum GenesisAction { SupplyBridgeAccount { balance: u128, }, + /// Seeds a bridge-lock holder's initial bridgeable balance into genesis state. + SupplyBridgeLockHolding { + holder: AccountId, + amount: u128, + }, } // TODO: Provide default values @@ -53,6 +59,9 @@ pub struct SequencerConfig { /// Genesis configuration. #[serde(default)] pub genesis: Vec, + /// Cross-zone messaging configuration. `None` disables the watcher. + #[serde(default)] + pub cross_zone: Option, } #[derive(Clone, Serialize, Deserialize)] diff --git a/lez/sequencer/core/src/cross_zone_watcher.rs b/lez/sequencer/core/src/cross_zone_watcher.rs new file mode 100644 index 00000000..ffa238c4 --- /dev/null +++ b/lez/sequencer/core/src/cross_zone_watcher.rs @@ -0,0 +1,185 @@ +use std::time::Duration; + +use common::{block::Block, transaction::LeeTransaction}; +use cross_zone::{build_dispatch_from_emission, extract_emission}; +use futures::StreamExt as _; +use lee::PublicKey; +use lee_core::program::ProgramId; +use log::{debug, error, info, warn}; +use logos_blockchain_core::mantle::ops::channel::ChannelId; +use logos_blockchain_zone_sdk::{ + CommonHttpClient, ZoneMessage, adapter::NodeHttpClient, indexer::ZoneIndexer, +}; +use mempool::MemPoolHandle; + +use crate::{ + TransactionOrigin, + config::{BedrockConfig, CrossZoneConfig}, +}; + +/// Spawns one watcher task per configured peer. +/// +/// Each task reads the peer's finalized blocks from Bedrock, recognizes outbound +/// messages addressed to this zone, and injects the matching inbox dispatch as a +/// sequencer-origin transaction into the local mempool. +pub fn spawn_watchers( + bedrock_config: &BedrockConfig, + cross_zone: &CrossZoneConfig, + poll_interval: Duration, + mempool_handle: &MemPoolHandle<(TransactionOrigin, LeeTransaction)>, +) { + let self_zone: [u8; 32] = *bedrock_config.channel_id.as_ref(); + + for peer in cross_zone.peers.clone() { + let node = NodeHttpClient::new( + CommonHttpClient::new(bedrock_config.auth.clone().map(Into::into)), + bedrock_config.node_url.clone(), + ); + let expected_pubkey = peer.expected_block_signing_pubkey.map(|bytes| { + PublicKey::try_new(bytes).expect("configured peer block-signing pubkey is a valid key") + }); + tokio::spawn(watch_peer( + ZoneIndexer::new(ChannelId::from(peer.channel_id), node), + peer.channel_id, + peer.allowed_targets, + expected_pubkey, + self_zone, + poll_interval, + mempool_handle.clone(), + )); + } +} + +#[expect( + clippy::infinite_loop, + reason = "the peer watcher runs for the lifetime of the sequencer process" +)] +async fn watch_peer( + zone_indexer: ZoneIndexer, + peer_zone: [u8; 32], + allowed_targets: Vec, + expected_pubkey: Option, + self_zone: [u8; 32], + poll_interval: Duration, + mempool_handle: MemPoolHandle<(TransactionOrigin, LeeTransaction)>, +) { + info!( + "Cross-zone watcher started for peer {}", + hex::encode(peer_zone) + ); + + let mut cursor = None; + loop { + let stream = match zone_indexer.next_messages(cursor).await { + Ok(stream) => stream, + Err(err) => { + error!( + "Watcher next_messages failed for peer {}: {err}", + hex::encode(peer_zone) + ); + tokio::time::sleep(poll_interval).await; + continue; + } + }; + let mut stream = std::pin::pin!(stream); + + while let Some((msg, slot)) = stream.next().await { + let zone_block = match msg { + ZoneMessage::Block(block) => block, + ZoneMessage::Deposit(_) | ZoneMessage::Withdraw(_) => continue, + }; + match borsh::from_slice::(&zone_block.data) { + Ok(block) => { + debug!( + "Watcher observed finalized peer {} block {}", + hex::encode(peer_zone), + block.header.block_id + ); + // Reject blocks not signed by the pinned peer key (equivocation): + // the channel signer is authenticated by the zone-sdk, but that + // does not prove the peer's honest sequencer produced the block. + if expected_pubkey + .as_ref() + .is_some_and(|pk| !block.is_signed_by(pk)) + { + warn!( + "Watcher dropping peer {} block {}: block-signing key does not match the pinned key", + hex::encode(peer_zone), + block.header.block_id + ); + } else { + deliver_block( + &block, + peer_zone, + self_zone, + &allowed_targets, + &mempool_handle, + ) + .await; + } + } + Err(err) => error!("Watcher failed to deserialize peer block: {err}"), + } + cursor = Some(slot); + } + + // Stream ended (caught up to the peer's last finalized block); poll again. + tokio::time::sleep(poll_interval).await; + } +} + +/// Scans one peer block for outbound messages and injects a dispatch per match. +async fn deliver_block( + block: &Block, + peer_zone: [u8; 32], + self_zone: [u8; 32], + allowed_targets: &[ProgramId], + mempool_handle: &MemPoolHandle<(TransactionOrigin, LeeTransaction)>, +) { + for (index, tx) in block.body.transactions.iter().enumerate() { + let LeeTransaction::Public(public_tx) = tx else { + continue; + }; + let message = public_tx.message(); + let Some(emission) = extract_emission(message.program_id, &message.instruction_data) else { + continue; + }; + + if emission.target_zone != self_zone { + continue; + } + if !allowed_targets.contains(&emission.target_program_id) { + warn!( + "Watcher dropping message to disallowed target from peer {}", + hex::encode(peer_zone) + ); + continue; + } + + let dispatch = build_dispatch_from_emission( + peer_zone, + block.header.block_id, + u32::try_from(index).unwrap_or(u32::MAX), + message.program_id, + emission.target_program_id, + &emission.target_accounts, + emission.payload, + ); + + match mempool_handle + .push(( + TransactionOrigin::Sequencer, + LeeTransaction::Public(dispatch), + )) + .await + { + Ok(()) => info!( + "Watcher injected cross-zone dispatch from peer {} block {} tx {}", + hex::encode(peer_zone), + block.header.block_id, + index + ), + Err(err) => error!("Watcher failed to enqueue inbox dispatch: {err}"), + } + } +} diff --git a/lez/sequencer/core/src/lib.rs b/lez/sequencer/core/src/lib.rs index 6c8d5ce2..3a96e6d5 100644 --- a/lez/sequencer/core/src/lib.rs +++ b/lez/sequencer/core/src/lib.rs @@ -32,6 +32,7 @@ use crate::{ pub mod block_publisher; pub mod block_store; pub mod config; +pub mod cross_zone_watcher; #[cfg(feature = "mock")] pub mod mock; @@ -181,6 +182,17 @@ impl SequencerCore { } } + // Cross-zone messaging: start a watcher per configured peer. The inbox + // config account is seeded into genesis state in `build_genesis_state`. + if let Some(cross_zone) = &config.cross_zone { + cross_zone_watcher::spawn_watchers( + &config.bedrock_config, + cross_zone, + config.block_create_timeout, + &mempool_handle, + ); + } + let sequencer_core = Self { state, store, @@ -628,14 +640,18 @@ fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec build_supply_account_genesis_transaction(account_id, *balance), + } => Some(build_supply_account_genesis_transaction( + account_id, *balance, + )), GenesisAction::SupplyBridgeAccount { balance } => { - build_supply_bridge_account_genesis_transaction(*balance) + Some(build_supply_bridge_account_genesis_transaction(*balance)) } + // Force-inserted below: bridge_lock has no mint transaction. + GenesisAction::SupplyBridgeLockHolding { .. } => None, }) .chain(std::iter::once(clock_invocation(0))) .inspect(|tx| { @@ -646,9 +662,36 @@ fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec bool { + cross_zone::is_sequencer_only_program(program_id) +} + fn build_supply_account_genesis_transaction( account_id: &AccountId, balance: u128, diff --git a/lez/sequencer/core/src/tests.rs b/lez/sequencer/core/src/tests.rs index 4b5ebfbc..05e2ec4c 100644 --- a/lez/sequencer/core/src/tests.rs +++ b/lez/sequencer/core/src/tests.rs @@ -33,6 +33,7 @@ use crate::{ block_store::SequencerStore, build_genesis_state, config::{BedrockConfig, SequencerConfig}, + is_sequencer_only_program, mock::SequencerCoreWithMockClients, }; @@ -59,9 +60,21 @@ fn setup_sequencer_config() -> SequencerConfig { }, retry_pending_blocks_timeout: Duration::from_mins(4), genesis: vec![], + cross_zone: None, } } +#[test] +fn only_the_cross_zone_inbox_is_sequencer_only() { + assert!(is_sequencer_only_program(programs::cross_zone_inbox().id())); + assert!(!is_sequencer_only_program( + programs::cross_zone_outbox().id() + )); + assert!(!is_sequencer_only_program(programs::wrapped_token().id())); + assert!(!is_sequencer_only_program(programs::ping_sender().id())); + assert!(!is_sequencer_only_program(programs::clock().id())); +} + fn create_signing_key_for_account1() -> lee::PrivateKey { initial_pub_accounts_private_keys()[0].pub_sign_key.clone() } diff --git a/lez/sequencer/service/src/service.rs b/lez/sequencer/service/src/service.rs index a84e2270..60be5fdd 100644 --- a/lez/sequencer/service/src/service.rs +++ b/lez/sequencer/service/src/service.rs @@ -74,6 +74,20 @@ impl sequencer_service_rpc::RpcServer ) })?; + // Sequencer-only programs (the cross-zone inbox) are injected by the + // watcher; a user must not invoke them top-level, or anyone could forge + // an inbound cross-zone delivery. Chained user calls are already rejected + // by the inbox guest's caller-is-none assertion. + if let LeeTransaction::Public(public_tx) = &authenticated_tx + && sequencer_core::is_sequencer_only_program(public_tx.message().program_id) + { + return Err(ErrorObjectOwned::owned( + ErrorCode::InvalidParams.code(), + "Program is sequencer-only and cannot be invoked by a user transaction".to_owned(), + None::<()>, + )); + } + self.mempool_handle .push((TransactionOrigin::User, authenticated_tx)) .await diff --git a/lez/testnet_initial_state/Cargo.toml b/lez/testnet_initial_state/Cargo.toml index f06d5f51..c0899ea9 100644 --- a/lez/testnet_initial_state/Cargo.toml +++ b/lez/testnet_initial_state/Cargo.toml @@ -10,6 +10,7 @@ lee.workspace = true lee_core.workspace = true system_accounts.workspace = true programs.workspace = true +wrapped_token_core.workspace = true serde.workspace = true diff --git a/lez/testnet_initial_state/src/lib.rs b/lez/testnet_initial_state/src/lib.rs index 3296b953..c92371cd 100644 --- a/lez/testnet_initial_state/src/lib.rs +++ b/lez/testnet_initial_state/src/lib.rs @@ -258,9 +258,30 @@ fn initial_public_accounts() -> HashMap { .into_iter() .map(|clock_id| (clock_id, system_accounts::clock_account())), ) + .chain(std::iter::once(wrapped_token_config_account())) .collect() } +/// The wrapped-token config account. +/// +/// Seeded so the `wrapped_token` guest can pin its authorized minter (the +/// cross-zone inbox) without importing the inbox id. Fixed for every zone, so it +/// lives in the shared initial state. +fn wrapped_token_config_account() -> (AccountId, Account) { + let wrapped_token_id = programs::wrapped_token().id(); + ( + 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() + .try_into() + .expect("minter id fits in account data"), + ..Default::default() + }, + ) +} + fn initial_programs() -> Vec { vec![ programs::authenticated_transfer(), @@ -271,6 +292,12 @@ fn initial_programs() -> Vec { programs::vault(), programs::faucet(), programs::bridge(), + programs::cross_zone_outbox(), + programs::cross_zone_inbox(), + programs::ping_sender(), + programs::ping_receiver(), + programs::bridge_lock(), + programs::wrapped_token(), ] } 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/cross_zone_inbox/core/Cargo.toml b/programs/cross_zone_inbox/core/Cargo.toml new file mode 100644 index 00000000..374a287d --- /dev/null +++ b/programs/cross_zone_inbox/core/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "cross_zone_inbox_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 +borsh.workspace = true +lee = { workspace = true, optional = true } +ping_core = { workspace = true, optional = true } +bridge_lock_core = { workspace = true, optional = true } + +[features] +# Host-only transaction builder and emission extractor; pull `lee` and the +# emitter cores, so the risc0 guest builds without them. +host = ["dep:lee", "dep:ping_core", "dep:bridge_lock_core"] diff --git a/programs/cross_zone_inbox/core/src/lib.rs b/programs/cross_zone_inbox/core/src/lib.rs new file mode 100644 index 00000000..edb90424 --- /dev/null +++ b/programs/cross_zone_inbox/core/src/lib.rs @@ -0,0 +1,378 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use borsh::{BorshDeserialize, BorshSerialize}; +use lee_core::{ + account::AccountId, + program::{PdaSeed, ProgramId}, +}; +use serde::{Deserialize, Serialize}; + +/// Raw 32-byte zone (channel) id; the host maps it to the zone-sdk `ChannelId`. +pub type ZoneId = [u8; 32]; + +/// Block-signing public key pinned per peer zone. +pub type ExpectedPubkey = [u8; 32]; + +/// Content-addressed replay key for a delivered message. +pub type MessageKey = [u8; 32]; + +/// Source blocks per seen-set shard, so no single seen account grows without bound. +pub const EPOCH_BLOCKS: u64 = 10_000; + +const MESSAGE_KEY_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneMsgKey/00000/"; +const INBOX_CONFIG_SEED: [u8; 32] = *b"/LEZ/v0.3/CrossZoneInboxCfg/000/"; +const INBOX_SEEN_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneInboxSeen/00/"; + +/// A peer zone whose outbox a zone watches for inbound cross-zone messages. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct CrossZonePeer { + /// The peer's Bedrock channel; its 32 bytes double as the peer's zone id. + pub channel_id: ZoneId, + /// Programs on the local zone a message from this peer is allowed to target. + pub allowed_targets: Vec, + /// The peer's block-signing public key, pinned to reject blocks inscribed by + /// anyone other than that zone's sequencer. `None` skips the check (the + /// channel signer is still authenticated by the zone-sdk). + #[serde(default)] + pub expected_block_signing_pubkey: Option<[u8; 32]>, +} + +/// Cross-zone configuration shared by a zone's sequencer (watcher) and indexer +/// (verifier): the peers it reads from Bedrock and, per peer, the local programs +/// they may deliver to. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct CrossZoneConfig { + pub peers: Vec, +} + +/// A finalized outbound message observed on a peer zone, addressed to a program +/// on this zone. The watcher fills it from the peer's block; it is never +/// self-reported by a user. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CrossZoneMessage { + pub src_zone: ZoneId, + pub src_block_id: u64, + pub src_tx_index: u32, + pub src_program_id: ProgramId, + pub target_program_id: ProgramId, + pub payload: Vec, + /// Reserved for a future source-state proof; MUST be `None` in v1. + pub l1_inclusion_witness: Option>, +} + +/// Peer and per-peer target allowlists, plus this inbox's own zone id. +#[derive( + Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, BorshSerialize, BorshDeserialize, +)] +pub struct InboxConfig { + pub self_zone: ZoneId, + pub allowed_peers: BTreeMap, + pub allowed_targets: BTreeMap>, +} + +impl InboxConfig { + /// Borsh-encoded form stored in the inbox config account. + #[must_use] + pub fn to_bytes(&self) -> Vec { + borsh::to_vec(self).expect("InboxConfig serializes") + } + + /// Decodes an [`InboxConfig`] from account data. + pub fn from_bytes(bytes: &[u8]) -> borsh::io::Result { + borsh::from_slice(bytes) + } +} + +/// The replay keys seen for one `(src_zone, epoch)` shard. +#[derive(Clone, Debug, Default, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +pub struct SeenShard(pub BTreeSet); + +impl SeenShard { + /// Decodes a shard from account data; empty data is an empty shard. + pub fn from_bytes(bytes: &[u8]) -> borsh::io::Result { + if bytes.is_empty() { + return Ok(Self::default()); + } + borsh::from_slice(bytes) + } + + #[must_use] + pub fn to_bytes(&self) -> Vec { + borsh::to_vec(self).expect("SeenShard serializes") + } + + #[must_use] + pub fn contains(&self, key: &MessageKey) -> bool { + self.0.contains(key) + } + + /// Inserts a key; returns true if it was newly inserted. + pub fn insert(&mut self, key: MessageKey) -> bool { + self.0.insert(key) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Instruction { + /// Delivers a finalized peer message to its target program. + Dispatch(CrossZoneMessage), +} + +/// Content-addressed replay key: `(src_zone, src_block_id, src_tx_index)` hashed +/// under a domain separator. Watcher-independent and immune to proof +/// malleability, since it keys on block id plus index rather than a tx hash. +#[must_use] +pub fn message_key(src_zone: &ZoneId, src_block_id: u64, src_tx_index: u32) -> MessageKey { + use risc0_zkvm::sha::{Impl, Sha256 as _}; + + let mut bytes = Vec::with_capacity(MESSAGE_KEY_DOMAIN.len() + 32 + 8 + 4); + bytes.extend_from_slice(&MESSAGE_KEY_DOMAIN); + bytes.extend_from_slice(src_zone); + bytes.extend_from_slice(&src_block_id.to_le_bytes()); + bytes.extend_from_slice(&src_tx_index.to_le_bytes()); + + Impl::hash_bytes(&bytes) + .as_bytes() + .try_into() + .unwrap_or_else(|_| unreachable!()) +} + +/// The config account holding the allowlists. +#[must_use] +pub fn inbox_config_account_id(inbox_id: ProgramId) -> AccountId { + AccountId::for_public_pda(&inbox_id, &PdaSeed::new(INBOX_CONFIG_SEED)) +} + +/// The seen-set shard for the `(src_zone, epoch)` the message falls in. +#[must_use] +pub fn inbox_seen_shard_account_id( + inbox_id: ProgramId, + src_zone: &ZoneId, + src_block_id: u64, +) -> AccountId { + AccountId::for_public_pda(&inbox_id, &inbox_seen_shard_seed(src_zone, src_block_id)) +} + +/// Seed of the seen-shard PDA, exposed so the guest can claim the account. +#[must_use] +pub fn inbox_seen_shard_seed(src_zone: &ZoneId, src_block_id: u64) -> PdaSeed { + use risc0_zkvm::sha::{Impl, Sha256 as _}; + + let src_epoch = src_block_id / EPOCH_BLOCKS; + let mut bytes = Vec::with_capacity(INBOX_SEEN_SEED_DOMAIN.len() + 32 + 8); + bytes.extend_from_slice(&INBOX_SEEN_SEED_DOMAIN); + bytes.extend_from_slice(src_zone); + bytes.extend_from_slice(&src_epoch.to_le_bytes()); + + let seed: [u8; 32] = Impl::hash_bytes(&bytes) + .as_bytes() + .try_into() + .unwrap_or_else(|_| unreachable!()); + PdaSeed::new(seed) +} + +/// Builds the sequencer-origin dispatch transaction. Pure, so the watcher's +/// injected tx and the indexer's re-derived tx are byte-identical for the same +/// inputs (the basis of the Option B check). `target_account_ids` are the +/// inbox's chained-call targets; deriving them is target-specific. +#[cfg(feature = "host")] +#[must_use] +pub fn build_inbox_dispatch_tx( + inbox_id: ProgramId, + msg: &CrossZoneMessage, + target_account_ids: Vec, +) -> lee::PublicTransaction { + let mut account_ids = Vec::with_capacity(2 + target_account_ids.len()); + 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, + )); + account_ids.extend(target_account_ids); + + let message = lee::public_transaction::Message::try_new( + inbox_id, + account_ids, + vec![], + Instruction::Dispatch(msg.clone()), + ) + .expect("inbox dispatch instruction must serialize"); + + lee::PublicTransaction::new( + message, + lee::public_transaction::WitnessSet::from_raw_parts(vec![]), + ) +} + +/// The cross-zone emission fields a watcher or verifier reads off a source +/// transaction, common to every emitter program. +#[cfg(feature = "host")] +pub struct Emission { + pub target_zone: ZoneId, + pub target_program_id: ProgramId, + pub target_accounts: Vec<[u8; 32]>, + pub payload: Vec, +} + +/// Extracts the cross-zone emission from a source transaction, recognizing the +/// known emitter programs. Returns `None` for any other program. The watcher and +/// verifier both use this so they agree on what a given source tx emits. +/// +/// Option A: each emitter is decoded explicitly. The principled alternative is to +/// read the outbox PDA write, which would need re-execution of the source tx. +#[cfg(feature = "host")] +#[must_use] +pub fn extract_emission( + program_id: ProgramId, + instruction_data: &[u32], + ping_sender_id: ProgramId, + bridge_lock_id: ProgramId, +) -> Option { + if program_id == ping_sender_id { + let ping_core::SenderInstruction::Send { + target_zone, + target_program_id, + target_accounts, + payload, + .. + } = risc0_zkvm::serde::from_slice(instruction_data).ok()?; + Some(Emission { + target_zone, + target_program_id, + target_accounts, + payload, + }) + } else if program_id == bridge_lock_id { + let bridge_lock_core::Instruction::Lock { + target_zone, + target_program_id, + target_accounts, + payload, + .. + } = risc0_zkvm::serde::from_slice(instruction_data).ok()?; + Some(Emission { + target_zone, + target_program_id, + target_accounts, + payload, + }) + } else { + None + } +} + +/// Builds the dispatch transaction for one peer emission. Both the sequencer's +/// watcher and the indexer's verifier go through this so their transactions are +/// byte-identical for the same emission (the basis of the Option B check). +#[cfg(feature = "host")] +#[must_use] +pub fn build_dispatch_from_emission( + inbox_id: ProgramId, + src_zone: ZoneId, + src_block_id: u64, + src_tx_index: u32, + src_program_id: ProgramId, + target_program_id: ProgramId, + target_accounts: &[[u8; 32]], + payload: Vec, +) -> lee::PublicTransaction { + let msg = CrossZoneMessage { + src_zone, + src_block_id, + src_tx_index, + src_program_id, + target_program_id, + payload, + l1_inclusion_witness: None, + }; + let target_ids = target_accounts + .iter() + .copied() + .map(AccountId::new) + .collect(); + build_inbox_dispatch_tx(inbox_id, &msg, target_ids) +} + +/// Builds the inbox config account a zone seeds into genesis state so the inbox +/// guest can authorize inbound peer messages. The sequencer and indexer seed the +/// same account from the same config, keeping their replayed state consistent. +#[cfg(feature = "host")] +#[must_use] +pub fn build_inbox_config_account( + self_zone: ZoneId, + cross_zone: &CrossZoneConfig, +) -> (AccountId, lee_core::account::Account) { + let inbox_id = lee::program::Program::cross_zone_inbox().id(); + + let mut allowed_targets = BTreeMap::new(); + for peer in &cross_zone.peers { + allowed_targets.insert(peer.channel_id, peer.allowed_targets.clone()); + } + let config = InboxConfig { + self_zone, + allowed_peers: BTreeMap::new(), + allowed_targets, + }; + + let account = lee_core::account::Account { + program_owner: inbox_id, + balance: 0, + data: config + .to_bytes() + .try_into() + .expect("inbox config fits in account data"), + nonce: 0_u128.into(), + }; + (inbox_config_account_id(inbox_id), account) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn zone(b: u8) -> ZoneId { + [b; 32] + } + + #[test] + fn message_key_is_stable_and_content_addressed() { + assert_eq!(message_key(&zone(1), 7, 3), message_key(&zone(1), 7, 3)); + assert_ne!(message_key(&zone(1), 7, 3), message_key(&zone(2), 7, 3)); + assert_ne!(message_key(&zone(1), 7, 3), message_key(&zone(1), 8, 3)); + assert_ne!(message_key(&zone(1), 7, 3), message_key(&zone(1), 7, 4)); + } + + #[test] + fn seen_shards_split_on_epoch_boundary() { + let id: ProgramId = [9; 8]; + assert_eq!( + inbox_seen_shard_account_id(id, &zone(1), 0), + inbox_seen_shard_account_id(id, &zone(1), EPOCH_BLOCKS - 1), + ); + assert_ne!( + inbox_seen_shard_account_id(id, &zone(1), EPOCH_BLOCKS - 1), + inbox_seen_shard_account_id(id, &zone(1), EPOCH_BLOCKS), + ); + } + + #[cfg(feature = "host")] + #[test] + fn build_inbox_dispatch_tx_is_deterministic() { + let inbox: ProgramId = [5; 8]; + let msg = CrossZoneMessage { + src_zone: zone(1), + src_block_id: 42, + src_tx_index: 2, + src_program_id: [6; 8], + target_program_id: [7; 8], + payload: vec![1, 2, 3, 4], + l1_inclusion_witness: None, + }; + let targets = vec![AccountId::new([8; 32]), AccountId::new([9; 32])]; + + let tx1 = build_inbox_dispatch_tx(inbox, &msg, targets.clone()); + let tx2 = build_inbox_dispatch_tx(inbox, &msg, targets); + assert_eq!(tx1, tx2); + } +} diff --git a/programs/cross_zone_outbox/core/Cargo.toml b/programs/cross_zone_outbox/core/Cargo.toml new file mode 100644 index 00000000..c7876286 --- /dev/null +++ b/programs/cross_zone_outbox/core/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "cross_zone_outbox_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 +borsh.workspace = true diff --git a/programs/cross_zone_outbox/core/src/lib.rs b/programs/cross_zone_outbox/core/src/lib.rs new file mode 100644 index 00000000..b3449598 --- /dev/null +++ b/programs/cross_zone_outbox/core/src/lib.rs @@ -0,0 +1,93 @@ +use borsh::{BorshDeserialize, BorshSerialize}; +use lee_core::{ + account::AccountId, + program::{PdaSeed, ProgramId}, +}; +use serde::{Deserialize, Serialize}; + +/// Raw 32-byte zone (channel) id; the host maps it to the zone-sdk `ChannelId`. +pub type ZoneId = [u8; 32]; + +const OUTBOX_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneOutbox/00000/"; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Instruction { + /// Records an outbound cross-zone message as a write to a self-owned PDA. + /// + /// Required accounts (1): + /// - Outbox PDA account + Emit { + target_zone: ZoneId, + target_program_id: ProgramId, + /// Accounts the destination inbox must hand to the target program's + /// chained call. The emitter specifies them; the watcher forwards them + /// verbatim so the inbox stays target-agnostic. + target_accounts: Vec<[u8; 32]>, + payload: Vec, + ordinal: u32, + }, +} + +/// 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. +#[derive(Clone, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +pub struct OutboxRecord { + pub target_zone: ZoneId, + pub target_program_id: ProgramId, + pub target_accounts: Vec<[u8; 32]>, + pub payload: Vec, +} + +impl OutboxRecord { + /// Borsh-encoded form stored in the outbox PDA's account data. + #[must_use] + pub fn to_bytes(&self) -> Vec { + borsh::to_vec(self).expect("OutboxRecord serializes") + } + + /// Decodes an [`OutboxRecord`] from account data. + pub fn from_bytes(bytes: &[u8]) -> borsh::io::Result { + borsh::from_slice(bytes) + } +} + +/// PDA holding one emitted message, keyed by destination zone and a per-zone +/// ordinal. +#[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)) +} + +/// 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 { + use risc0_zkvm::sha::{Impl, Sha256 as _}; + + let mut bytes = Vec::with_capacity(OUTBOX_SEED_DOMAIN.len() + target_zone.len() + 4); + bytes.extend_from_slice(&OUTBOX_SEED_DOMAIN); + bytes.extend_from_slice(target_zone); + bytes.extend_from_slice(&ordinal.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 super::*; + + #[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)); + } +} diff --git a/programs/ping/core/Cargo.toml b/programs/ping/core/Cargo.toml new file mode 100644 index 00000000..29870630 --- /dev/null +++ b/programs/ping/core/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "ping_core" +version = "0.1.0" +edition = "2024" +license = { workspace = true } + +[lints] +workspace = true + +[dependencies] +lee_core.workspace = true +serde = { workspace = true, features = ["alloc"] } diff --git a/programs/ping/core/src/lib.rs b/programs/ping/core/src/lib.rs new file mode 100644 index 00000000..268e5b98 --- /dev/null +++ b/programs/ping/core/src/lib.rs @@ -0,0 +1,38 @@ +use lee_core::{ + account::AccountId, + program::{PdaSeed, ProgramId}, +}; +use serde::{Deserialize, Serialize}; + +const PING_RECORD_SEED: [u8; 32] = *b"/LEZ/v0.3/PingRecord/0000000000/"; + +/// Instruction delivered to `ping_receiver` by the inbox: record the payload. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum ReceiverInstruction { + Record { payload: Vec }, +} + +/// Instruction to `ping_sender`: forwarded verbatim into `cross_zone_outbox::Instruction::Emit`. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum SenderInstruction { + Send { + outbox_program_id: ProgramId, + target_zone: [u8; 32], + target_program_id: ProgramId, + target_accounts: Vec<[u8; 32]>, + payload: Vec, + ordinal: u32, + }, +} + +/// The account a `ping_receiver` records the latest delivered payload into. +#[must_use] +pub fn ping_record_pda(receiver_id: ProgramId) -> AccountId { + AccountId::for_public_pda(&receiver_id, &ping_record_seed()) +} + +/// Seed of the record PDA, exposed so the guest can claim the account. +#[must_use] +pub fn ping_record_seed() -> PdaSeed { + PdaSeed::new(PING_RECORD_SEED) +} 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..ea4ffd97 --- /dev/null +++ b/programs/wrapped_token/core/src/lib.rs @@ -0,0 +1,121 @@ +//! 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]) + ); + } +} diff --git a/test_fixtures/fixtures/prebuilt_sequencer_db.dump b/test_fixtures/fixtures/prebuilt_sequencer_db.dump index 2528b798..fcaeac6f 100644 Binary files a/test_fixtures/fixtures/prebuilt_sequencer_db.dump and b/test_fixtures/fixtures/prebuilt_sequencer_db.dump differ diff --git a/test_fixtures/src/bin/regenerate_test_fixture.rs b/test_fixtures/src/bin/regenerate_test_fixture.rs index 4405277a..80da8fdc 100644 --- a/test_fixtures/src/bin/regenerate_test_fixture.rs +++ b/test_fixtures/src/bin/regenerate_test_fixture.rs @@ -53,6 +53,8 @@ async fn generate_prebuilt_fixture(dest: &Path) -> Result<()> { config::SequencerPartialConfig::default(), bedrock_addr, genesis, + config::bedrock_channel_id(), + None, ) .await .context("Failed to setup Sequencer for fixture generation")?; diff --git a/test_fixtures/src/config.rs b/test_fixtures/src/config.rs index 044871f1..960f349b 100644 --- a/test_fixtures/src/config.rs +++ b/test_fixtures/src/config.rs @@ -6,7 +6,7 @@ use indexer_service::{ChannelId, ClientConfig, IndexerConfig}; use key_protocol::key_management::{KeyChain, secret_holders::SeedHolder}; use lee::{AccountId, PrivateKey, PublicKey}; use lee_core::Identifier; -use sequencer_core::config::{BedrockConfig, GenesisAction, SequencerConfig}; +use sequencer_core::config::{BedrockConfig, CrossZoneConfig, GenesisAction, SequencerConfig}; use url::Url; use wallet::config::WalletConfig; @@ -80,6 +80,8 @@ pub fn sequencer_config( home: PathBuf, bedrock_addr: SocketAddr, genesis_transactions: Vec, + channel_id: ChannelId, + cross_zone: Option, ) -> Result { let SequencerPartialConfig { max_num_tx_in_block, @@ -98,11 +100,12 @@ pub fn sequencer_config( genesis: genesis_transactions, signing_key: SEQUENCER_SIGNING_KEY, bedrock_config: BedrockConfig { - channel_id: bedrock_channel_id(), + channel_id, node_url: addr_to_url(UrlProtocol::Http, bedrock_addr) .context("Failed to convert bedrock addr to URL")?, auth: None, }, + cross_zone, }) } @@ -201,7 +204,11 @@ pub fn wallet_config(sequencer_addr: SocketAddr) -> Result { }) } -pub fn indexer_config(bedrock_addr: SocketAddr) -> Result { +pub fn indexer_config( + bedrock_addr: SocketAddr, + channel_id: ChannelId, + cross_zone: Option, +) -> Result { Ok(IndexerConfig { consensus_info_polling_interval: Duration::from_secs(1), bedrock_config: ClientConfig { @@ -209,7 +216,9 @@ pub fn indexer_config(bedrock_addr: SocketAddr) -> Result { .context("Failed to convert bedrock addr to URL")?, auth: None, }, - channel_id: bedrock_channel_id(), + channel_id, + cross_zone, + bridge_lock_holdings: Vec::new(), allow_chain_reset: false, }) } @@ -235,3 +244,14 @@ pub fn bedrock_channel_id() -> ChannelId { .unwrap_or_else(|_| unreachable!()); ChannelId::from(channel_id) } + +/// A second zone's channel id, distinct from [`bedrock_channel_id`] so two zones +/// settle independently on one shared Bedrock node in the cross-zone tests. +#[must_use] +pub fn bedrock_channel_id_b() -> ChannelId { + let channel_id: [u8; 32] = [0_u8, 2] + .repeat(16) + .try_into() + .unwrap_or_else(|_| unreachable!()); + ChannelId::from(channel_id) +} diff --git a/test_fixtures/src/lib.rs b/test_fixtures/src/lib.rs index adbb07cb..ff87c8e9 100644 --- a/test_fixtures/src/lib.rs +++ b/test_fixtures/src/lib.rs @@ -336,9 +336,10 @@ impl TestContextBuilder { .context("Failed to setup Bedrock node")?; let indexer_components = if enable_indexer { - let (indexer_handle, temp_indexer_dir) = setup_indexer(bedrock_addr) - .await - .context("Failed to setup Indexer")?; + let (indexer_handle, temp_indexer_dir) = + setup_indexer(bedrock_addr, config::bedrock_channel_id(), None) + .await + .context("Failed to setup Indexer")?; let indexer_url = config::addr_to_url(config::UrlProtocol::Ws, indexer_handle.addr()) .context("Failed to convert indexer addr to URL")?; let indexer_client = IndexerClient::new(&indexer_url) @@ -375,9 +376,15 @@ impl TestContextBuilder { } None => wallet_genesis, }; - setup_sequencer(partial_config, bedrock_addr, genesis) - .await - .context("Failed to setup Sequencer")? + setup_sequencer( + partial_config, + bedrock_addr, + genesis, + config::bedrock_channel_id(), + None, + ) + .await + .context("Failed to setup Sequencer")? }; let (mut wallet, temp_wallet_dir, wallet_password) = setup_wallet( diff --git a/test_fixtures/src/setup.rs b/test_fixtures/src/setup.rs index 2827ff2c..325e1628 100644 --- a/test_fixtures/src/setup.rs +++ b/test_fixtures/src/setup.rs @@ -1,7 +1,7 @@ use std::{net::SocketAddr, path::PathBuf}; use anyhow::{Context as _, Result, bail}; -use indexer_service::IndexerHandle; +use indexer_service::{ChannelId, IndexerHandle}; use lee::{AccountId, PrivateKey, PublicKey}; use log::{debug, warn}; use sequencer_core::block_store::{DbDump, SequencerStore}; @@ -112,7 +112,11 @@ pub async fn setup_bedrock_node() -> Result<(DockerCompose, SocketAddr)> { Ok((compose, addr)) } -pub async fn setup_indexer(bedrock_addr: SocketAddr) -> Result<(IndexerHandle, TempDir)> { +pub async fn setup_indexer( + bedrock_addr: SocketAddr, + channel_id: ChannelId, + cross_zone: Option, +) -> Result<(IndexerHandle, TempDir)> { let temp_indexer_dir = tempfile::tempdir().context("Failed to create temp dir for indexer home")?; @@ -121,8 +125,8 @@ pub async fn setup_indexer(bedrock_addr: SocketAddr) -> Result<(IndexerHandle, T temp_indexer_dir.path().display() ); - let indexer_config = - config::indexer_config(bedrock_addr).context("Failed to create Indexer config")?; + let indexer_config = config::indexer_config(bedrock_addr, channel_id, cross_zone) + .context("Failed to create Indexer config")?; indexer_service::run_server( indexer_config, @@ -139,11 +143,15 @@ pub async fn setup_sequencer( partial: config::SequencerPartialConfig, bedrock_addr: SocketAddr, genesis_transactions: Vec, + channel_id: ChannelId, + cross_zone: Option, ) -> Result<(SequencerHandle, TempDir)> { setup_sequencer_inner( partial, bedrock_addr, SequencerInit::Genesis(genesis_transactions), + channel_id, + cross_zone, ) .await } @@ -155,13 +163,22 @@ pub async fn setup_sequencer_from_prebuilt( bedrock_addr: SocketAddr, ) -> Result<(SequencerHandle, TempDir)> { let dump = load_prebuilt_dump()?; - setup_sequencer_inner(partial, bedrock_addr, SequencerInit::Prebuilt(&dump)).await + setup_sequencer_inner( + partial, + bedrock_addr, + SequencerInit::Prebuilt(&dump), + config::bedrock_channel_id(), + None, + ) + .await } async fn setup_sequencer_inner( partial: config::SequencerPartialConfig, bedrock_addr: SocketAddr, init: SequencerInit<'_>, + channel_id: ChannelId, + cross_zone: Option, ) -> Result<(SequencerHandle, TempDir)> { let temp_sequencer_dir = tempfile::tempdir().context("Failed to create temp dir for sequencer home")?; @@ -187,6 +204,8 @@ async fn setup_sequencer_inner( temp_sequencer_dir.path().to_owned(), bedrock_addr, genesis_transactions, + channel_id, + cross_zone, ) .context("Failed to create Sequencer config")?; diff --git a/tools/cross_zone_chat/Cargo.toml b/tools/cross_zone_chat/Cargo.toml new file mode 100644 index 00000000..2c74e6df --- /dev/null +++ b/tools/cross_zone_chat/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "cross_zone_chat" +version = "0.1.0" +edition = "2024" +license = { workspace = true } + +[lints] +workspace = true + +[dependencies] +test_fixtures.workspace = true +sequencer_service_rpc = { workspace = true, features = ["client"] } +programs.workspace = true +ping_core.workspace = true +cross_zone_outbox_core.workspace = true +cross_zone_inbox_core.workspace = true +lee.workspace = true +common.workspace = true +risc0-zkvm.workspace = true +tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } +anyhow.workspace = true +serde = { workspace = true, features = ["derive"] } +axum.workspace = true +log.workspace = true +env_logger.workspace = true diff --git a/tools/cross_zone_chat/src/index.html b/tools/cross_zone_chat/src/index.html new file mode 100644 index 00000000..8dc5e82a --- /dev/null +++ b/tools/cross_zone_chat/src/index.html @@ -0,0 +1,234 @@ + + + + + +Cross-zone chat + + + +
+ Cross-zone chat — type in a column; each message shows its journey: sent into a source block, that block reaching Bedrock finality, then delivery into the other zone. The finality wait is the bulk of the latency. +
+
+
+

Zone A

+
+
+ + +
+
+
+

Zone B

+
+
+ + +
+
+
+ + + diff --git a/tools/cross_zone_chat/src/main.rs b/tools/cross_zone_chat/src/main.rs new file mode 100644 index 00000000..5a066e9e --- /dev/null +++ b/tools/cross_zone_chat/src/main.rs @@ -0,0 +1,533 @@ +//! Interactive cross-zone chat demo. +//! +//! Boots one Bedrock node hosting two zones (A and B), each running a sequencer +//! whose cross-zone watcher points at the other zone, then serves a local +//! two-column web UI. Type a message in one column and watch it cross Bedrock +//! and appear in the other; two people can chat across the zones. +//! +//! This is the `cross_zone_ping` integration-test topology made interactive: +//! a send builds a `ping_sender::Send` on the sender zone targeting the other +//! zone's `ping_receiver`, the other zone's watcher reads the finalized source +//! block and injects the inbox dispatch, and a per-zone block tailer decodes the +//! delivered `cross_zone_inbox::Dispatch` payloads back into chat text. +//! +//! To make the cross-zone machinery visible, each message is tracked through its +//! pipeline stages and surfaced to the page: submitted into a source block on the +//! sender zone, that block's Bedrock finality (pending -> finalized), and final +//! delivery into a block on the receiver zone. The Bedrock-finality wait is what +//! dominates the latency, so it is shown live. +//! +//! Prerequisite: a working local Docker daemon (Bedrock comes up via +//! `bedrock/docker-compose.yml`). Run with `RISC0_DEV_MODE=1` for fast, +//! proving-free latency; on macOS run under the `just cross-zone-chat` recipe so +//! the DYLD framework shim is set. + +#![allow( + clippy::arithmetic_side_effects, + clippy::print_stdout, + clippy::print_stderr, + 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 \ + 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." +)] + +use std::{ + collections::BTreeSet, + net::SocketAddr, + sync::{ + Arc, Mutex, + atomic::{AtomicU32, AtomicU64, Ordering}, + }, + time::{Duration, Instant}, +}; + +use anyhow::{Context as _, Result}; +use axum::{ + Json, Router, + extract::State, + http::StatusCode, + response::Html, + routing::{get, post}, +}; +use common::{block::BedrockStatus, transaction::LeeTransaction}; +use cross_zone_inbox_core::{CrossZoneConfig, CrossZonePeer, Instruction, ZoneId}; +use cross_zone_outbox_core::outbox_pda; +use lee::{ + ProgramId, PublicTransaction, + public_transaction::{Message, WitnessSet}, +}; +use log::{info, warn}; +use ping_core::{ReceiverInstruction, SenderInstruction, ping_record_pda}; +use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder}; +use serde::{Deserialize, Serialize}; +use test_fixtures::{ + config::{self, SequencerPartialConfig, UrlProtocol, bedrock_channel_id, bedrock_channel_id_b}, + setup::{setup_bedrock_node, setup_sequencer}, +}; + +const HTTP_PORT: u16 = 8088; +const POLL_INTERVAL: Duration = Duration::from_secs(1); + +/// One chat message tracked through its cross-zone pipeline. Displayed in the +/// receiving zone's column; the stage fields drive the on-page timeline. +struct TrackedMessage { + id: u64, + /// Label ("A" / "B") of the sender zone. + source_label: &'static str, + /// Label of the receiver zone; the column this message shows in. + dest_label: &'static str, + text: String, + /// Outbox ordinal of this send, used to match the source-zone block. + ordinal: u32, + /// Block on the sender zone that included the `ping_sender` tx. + source_block: Option, + /// Whether `source_block` has reached Bedrock finality. + finalized: bool, + /// Block on the receiver zone that delivered the inbox dispatch. + delivered_block: Option, + created: Instant, + /// Wall-clock seconds from submit to delivery, set once delivered. + delivered_secs: Option, +} + +/// A single send to perform. +#[derive(Deserialize)] +struct SendRequest { + zone: String, + text: String, +} + +/// The id assigned to a freshly submitted message. +#[derive(Serialize)] +struct SendResponse { + id: u64, +} + +/// One message's current pipeline state, as served to the page. +#[derive(Serialize)] +struct TimelineEntry { + id: u64, + /// Receiver column the message belongs to. + zone: &'static str, + source_zone: &'static str, + text: String, + source_block: Option, + finalized: bool, + delivered_block: Option, + /// Seconds elapsed (live until delivered, then frozen at delivery time). + elapsed: u64, +} + +/// Everything one zone needs to send to its peer. +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. + ordinal: AtomicU32, +} + +struct AppState { + zone_a: ZoneRuntime, + zone_b: ZoneRuntime, + /// Monotonic id assigned to every message. + next_id: AtomicU64, + messages: Mutex>, +} + +impl AppState { + fn zone(&self, label: &str) -> Option<&ZoneRuntime> { + match label { + "A" => Some(&self.zone_a), + "B" => Some(&self.zone_b), + _ => None, + } + } + + /// Records a freshly submitted outbound message and returns its id. + fn record_outbound( + &self, + source_label: &'static str, + dest_label: &'static str, + ordinal: u32, + text: String, + ) -> u64 { + let id = self.next_id.fetch_add(1, Ordering::SeqCst); + self.messages + .lock() + .expect("messages mutex poisoned") + .push(TrackedMessage { + id, + source_label, + dest_label, + text, + ordinal, + source_block: None, + finalized: false, + delivered_block: None, + created: Instant::now(), + delivered_secs: None, + }); + info!("[stage] msg {id} submitted {source_label}->{dest_label}"); + id + } + + /// Marks the source-zone block that included a send, matched by ordinal. + fn mark_source_block(&self, source_label: &'static str, ordinal: u32, block_id: u64) { + let mut messages = self.messages.lock().expect("messages mutex poisoned"); + if let Some(message) = messages.iter_mut().find(|m| { + m.source_label == source_label && m.ordinal == ordinal && m.source_block.is_none() + }) { + message.source_block = Some(block_id); + info!( + "[stage] msg {} in source block {source_label}#{block_id} (+{}s)", + message.id, + message.created.elapsed().as_secs() + ); + } + } + + /// Marks delivery on the receiver zone. Prefers the oldest undelivered + /// message whose text matches; falls back to the oldest undelivered for the + /// zone (delivery order is preserved end to end). + fn mark_delivered(&self, dest_label: &'static str, text: &str, block_id: u64) { + let mut messages = self.messages.lock().expect("messages mutex poisoned"); + let undelivered = + |m: &TrackedMessage| m.dest_label == dest_label && m.delivered_block.is_none(); + let index = messages + .iter() + .position(|m| undelivered(m) && m.text == text) + .or_else(|| messages.iter().position(undelivered)); + if let Some(index) = index { + let message = &mut messages[index]; + message.delivered_block = Some(block_id); + let secs = message.created.elapsed().as_secs(); + message.delivered_secs = Some(secs); + info!( + "[stage] msg {} delivered {dest_label}#{block_id} (+{secs}s total)", + message.id + ); + } + } + + /// Snapshot of (source zone, block) pairs whose finality is still unknown. + fn pending_finality(&self) -> BTreeSet<(&'static str, u64)> { + self.messages + .lock() + .expect("messages mutex poisoned") + .iter() + .filter(|m| !m.finalized) + .filter_map(|m| m.source_block.map(|block| (m.source_label, block))) + .collect() + } + + /// Marks every message whose source block has reached Bedrock finality. + fn mark_finalized(&self, source_label: &'static str, block_id: u64) { + for message in self + .messages + .lock() + .expect("messages mutex poisoned") + .iter_mut() + { + if message.source_label == source_label + && message.source_block == Some(block_id) + && !message.finalized + { + message.finalized = true; + info!( + "[stage] msg {} source block {source_label}#{block_id} finalized on Bedrock (+{}s)", + message.id, + message.created.elapsed().as_secs() + ); + } + } + } + + fn timeline(&self) -> Vec { + self.messages + .lock() + .expect("messages mutex poisoned") + .iter() + .map(|m| TimelineEntry { + id: m.id, + zone: m.dest_label, + source_zone: m.source_label, + text: m.text.clone(), + source_block: m.source_block, + finalized: m.finalized, + delivered_block: m.delivered_block, + elapsed: m + .delivered_secs + .unwrap_or_else(|| m.created.elapsed().as_secs()), + }) + .collect() + } +} + +#[tokio::main] +async fn main() -> Result<()> { + env_logger::init(); + + // Declared first so Bedrock 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 channel_a = bedrock_channel_id(); + let channel_b = bedrock_channel_id_b(); + let zone_a: ZoneId = *channel_a.as_ref(); + let zone_b: ZoneId = *channel_b.as_ref(); + let receiver_id = programs::ping_receiver().id(); + + // Each zone watches the other and may deliver only to ping_receiver. + let cross_zone_a = watch_peer(zone_b, receiver_id); + let cross_zone_b = watch_peer(zone_a, receiver_id); + + let partial = SequencerPartialConfig::default(); + let (seq_a, _home_a) = + setup_sequencer(partial, bedrock_addr, vec![], channel_a, Some(cross_zone_a)) + .await + .context("Failed to set up zone A sequencer")?; + let (seq_b, _home_b) = + setup_sequencer(partial, bedrock_addr, vec![], channel_b, Some(cross_zone_b)) + .await + .context("Failed to set up zone B sequencer")?; + + let state = Arc::new(AppState { + zone_a: ZoneRuntime { + other_zone: zone_b, + client: sequencer_client(seq_a.addr())?, + ordinal: AtomicU32::new(0), + }, + zone_b: ZoneRuntime { + other_zone: zone_a, + client: sequencer_client(seq_b.addr())?, + ordinal: AtomicU32::new(0), + }, + next_id: AtomicU64::new(1), + messages: Mutex::new(Vec::new()), + }); + + tokio::spawn(scan_zone(Arc::clone(&state), "A")); + tokio::spawn(scan_zone(Arc::clone(&state), "B")); + tokio::spawn(poll_finality(Arc::clone(&state))); + + let app = Router::new() + .route("/", get(index)) + .route("/send", post(send_handler)) + .route("/timeline", get(timeline_handler)) + .with_state(state); + + let addr = SocketAddr::from(([127, 0, 0, 1], HTTP_PORT)); + let listener = tokio::net::TcpListener::bind(addr) + .await + .with_context(|| format!("Failed to bind {addr}"))?; + + println!("\n 💬 Cross-zone chat ready — open http://127.0.0.1:{HTTP_PORT} in your browser\n"); + + axum::serve(listener, app) + .await + .context("HTTP server error")?; + Ok(()) +} + +/// A cross-zone config whose single peer is `peer`, allowed to deliver only to +/// `receiver_id`. +fn watch_peer(peer: ZoneId, receiver_id: ProgramId) -> CrossZoneConfig { + CrossZoneConfig { + peers: vec![CrossZonePeer { + channel_id: peer, + allowed_targets: vec![receiver_id], + expected_block_signing_pubkey: None, + }], + } +} + +fn sequencer_client(addr: SocketAddr) -> Result { + let url = + config::addr_to_url(UrlProtocol::Http, addr).context("Failed to build sequencer URL")?; + SequencerClientBuilder::default() + .build(url) + .context("Failed to build sequencer client") +} + +/// 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. +async fn scan_zone(state: Arc, label: &'static str) { + let inbox_id = programs::cross_zone_inbox().id(); + let sender_id = programs::ping_sender().id(); + let client = &state.zone(label).expect("zone runtime exists").client; + + // Start from the current tip so genesis/boot blocks are skipped. + let mut cursor = client.get_last_block_id().await.unwrap_or(0); + + loop { + tokio::time::sleep(POLL_INTERVAL).await; + + let last = match client.get_last_block_id().await { + Ok(last) => last, + Err(err) => { + warn!("[{label}] get_last_block_id failed: {err:#}"); + continue; + } + }; + + while cursor < last { + let next = cursor.saturating_add(1); + match client.get_block(next).await { + Ok(Some(block)) => { + for tx in &block.body.transactions { + let LeeTransaction::Public(public) = tx else { + continue; + }; + let program_id = public.message.program_id; + let data = &public.message.instruction_data; + // A tx targets at most one of these programs; check both + // independently rather than chaining (avoids an empty else). + if program_id == inbox_id + && let Some(text) = decode_inbox_text(data) + { + state.mark_delivered(label, &text, next); + } + if program_id == sender_id + && let Some(ordinal) = decode_send_ordinal(data) + { + state.mark_source_block(label, ordinal, next); + } + } + } + Ok(None) => {} + Err(err) => { + warn!("[{label}] get_block({next}) failed: {err:#}"); + break; + } + } + cursor = next; + } + } +} + +/// Polls each tracked source block's Bedrock finality until it is finalized. +async fn poll_finality(state: Arc) { + loop { + tokio::time::sleep(POLL_INTERVAL).await; + for (label, block_id) in state.pending_finality() { + let client = &state.zone(label).expect("zone runtime exists").client; + match client.get_block(block_id).await { + Ok(Some(block)) => { + if matches!(block.bedrock_status, BedrockStatus::Finalized) { + state.mark_finalized(label, block_id); + } + } + Ok(None) => {} + Err(err) => warn!("[{label}] finality get_block({block_id}) failed: {err:#}"), + } + } + } +} + +/// Recovers the chat text from an inbox dispatch tx's instruction data. +fn decode_inbox_text(instruction_data: &[u32]) -> Option { + let instruction: Instruction = + risc0_zkvm::serde::from_slice::(instruction_data).ok()?; + let Instruction::Dispatch(message) = instruction; + decode_payload(&message.payload) +} + +/// Recovers the outbox ordinal from a `ping_sender::Send` tx's instruction data. +fn decode_send_ordinal(instruction_data: &[u32]) -> Option { + let instruction: SenderInstruction = + risc0_zkvm::serde::from_slice::(instruction_data).ok()?; + let SenderInstruction::Send { ordinal, .. } = instruction; + Some(ordinal) +} + +/// Decodes a `ping_receiver::Record` payload (risc0 words in LE bytes) to text. +fn decode_payload(payload: &[u8]) -> Option { + let chunks = payload.chunks_exact(4); + if !chunks.remainder().is_empty() { + return None; + } + let words: Vec = chunks + .map(|chunk| u32::from_le_bytes(chunk.try_into().expect("chunks_exact(4) yields 4 bytes"))) + .collect(); + let instruction: ReceiverInstruction = + risc0_zkvm::serde::from_slice::(&words).ok()?; + let ReceiverInstruction::Record { payload: bytes } = instruction; + Some(String::from_utf8_lossy(&bytes).into_owned()) +} + +/// Builds the unsigned `ping_sender::Send` that carries `text` to `other_zone`, +/// mirroring `integration_tests/tests/cross_zone_ping.rs`. +fn build_send_tx(other_zone: ZoneId, ordinal: u32, text: &str) -> LeeTransaction { + let receiver_id = programs::ping_receiver().id(); + let outbox_id = programs::cross_zone_outbox().id(); + + let words = risc0_zkvm::serde::to_vec(&ReceiverInstruction::Record { + payload: text.as_bytes().to_vec(), + }) + .expect("serialize record instruction"); + 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()], + payload, + ordinal, + }; + + let outbox_account = outbox_pda(outbox_id, &other_zone, ordinal); + let message = Message::try_new( + programs::ping_sender().id(), + vec![outbox_account], + vec![], + send, + ) + .expect("build ping_sender message"); + + LeeTransaction::Public(PublicTransaction::new( + message, + WitnessSet::from_raw_parts(vec![]), + )) +} + +async fn index() -> Html<&'static str> { + Html(include_str!("index.html")) +} + +async fn send_handler( + State(state): State>, + Json(request): Json, +) -> Result, (StatusCode, String)> { + let text = request.text.trim(); + if text.is_empty() { + return Err((StatusCode::BAD_REQUEST, "empty message".to_owned())); + } + let (source_label, dest_label) = match request.zone.as_str() { + "A" => ("A", "B"), + "B" => ("B", "A"), + other => return Err((StatusCode::BAD_REQUEST, format!("unknown zone {other}"))), + }; + let zone = state.zone(source_label).expect("zone runtime exists"); + + let ordinal = zone.ordinal.fetch_add(1, Ordering::SeqCst); + let tx = build_send_tx(zone.other_zone, ordinal, text); + let id = state.record_outbound(source_label, dest_label, ordinal, text.to_owned()); + zone.client + .send_transaction(tx) + .await + .map_err(|err| (StatusCode::BAD_GATEWAY, format!("submit failed: {err:#}")))?; + Ok(Json(SendResponse { id })) +} + +async fn timeline_handler(State(state): State>) -> Json> { + Json(state.timeline()) +}