From b83a3c7556646a8dade544aa1fc02aee53f9f81a Mon Sep 17 00:00:00 2001 From: moudyellaz Date: Tue, 23 Jun 2026 10:42:30 +0200 Subject: [PATCH] test(cross-zone): add two-zone ping round-trip integration test --- Cargo.lock | 3 + integration_tests/Cargo.toml | 3 + integration_tests/tests/cross_zone_ping.rs | 138 +++++++++++++++++++++ 3 files changed, 144 insertions(+) create mode 100644 integration_tests/tests/cross_zone_ping.rs diff --git a/Cargo.lock b/Cargo.lock index e09f6a21..ca99945c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3986,6 +3986,7 @@ dependencies = [ "bridge_core", "bytesize", "common", + "cross_zone_outbox_core", "faucet_core", "futures", "hex", @@ -8865,6 +8866,7 @@ dependencies = [ "bytesize", "chrono", "common", + "cross_zone_inbox_core", "faucet_core", "futures", "hex", @@ -8878,6 +8880,7 @@ dependencies = [ "logos-blockchain-zone-sdk", "mempool", "num-bigint 0.4.6", + "ping_core", "rand 0.8.6", "risc0-zkvm", "serde", diff --git a/integration_tests/Cargo.toml b/integration_tests/Cargo.toml index 3b1731d8..c460b881 100644 --- a/integration_tests/Cargo.toml +++ b/integration_tests/Cargo.toml @@ -23,6 +23,9 @@ ata_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 +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_ping.rs b/integration_tests/tests/cross_zone_ping.rs new file mode 100644 index 00000000..3afeeb39 --- /dev/null +++ b/integration_tests/tests/cross_zone_ping.rs @@ -0,0 +1,138 @@ +#![expect( + clippy::tests_outside_test_module, + clippy::arithmetic_side_effects, + reason = "We don't care about these in 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, program::Program, 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 = Program::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], + }], + }; + + 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 = Program::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(Program::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")? +}