Merge pull request #542 from logos-blockchain/moudy/feat-cross-zone-messaging

feat!(cross-zone): async LEZ to LEZ cross-zone messaging
This commit is contained in:
Moudy 2026-07-11 02:24:34 +02:00 committed by GitHub
commit cac4921581
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
87 changed files with 5288 additions and 42 deletions

151
Cargo.lock generated
View File

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

View File

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

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

View File

@ -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<u8> = 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<SequencerClient> {
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<u128> {
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::<u128, anyhow::Error>(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")?
}

View File

@ -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<SequencerClient> {
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")
}

View File

@ -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<u8> = 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<SequencerClient> {
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<Vec<u8>> {
let wait = async {
loop {
let account = client.get_account(record_id).await?;
let data = account.data.into_inner();
if !data.is_empty() {
return Ok::<Vec<u8>, 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")?
}

View File

@ -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<u8> {
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<u8> = 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");
}
}

View File

@ -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<u8> = 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<SequencerClient> {
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<Vec<u8>> {
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::<Vec<u8>, 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")?
}

View File

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

View File

@ -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<u64> {
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::<u64, anyhow::Error>(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:?}"))?
}

View File

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

View File

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

17
lez/cross_zone/Cargo.toml Normal file
View File

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

189
lez/cross_zone/src/lib.rs Normal file
View File

@ -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<u8>,
}
/// 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<Emission> {
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<AccountId>,
) -> 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<u8>,
) -> 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)
}

View File

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

View File

@ -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<Self> {
pub fn open_db(location: &Path, genesis_seed: Vec<(AccountId, Account)>) -> Result<Self> {
#[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;

View File

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

View File

@ -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<CrossZoneConfig>,
/// 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<BridgeLockHolding>,
/// 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<Self> {
let file = File::open(config_path).with_context(|| {

View File

@ -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<RwLock<HashMap<ZoneId, HashMap<u64, Block>>>>,
}
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<Block> {
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<u64> {
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<ZoneId, PublicKey>,
peers: PeerBlocks,
seen: Arc<RwLock<HashSet<MessageKey>>>,
}
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<Self> {
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<CrossZoneMessage> {
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::<InboxInstruction, _>(
&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<lee::PublicTransaction> {
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<Block> {
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<NodeHttpClient>,
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::<Block>(&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<ZoneId, PublicKey>) -> 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}"
);
}
}

View File

@ -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<ArcSwap<IndexerSyncStatus>>,
/// Option B cross-zone verifier; `None` when cross-zone messaging is disabled.
pub verifier: Option<CrossZoneVerifier>,
}
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();

View File

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

View File

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

View File

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

View File

@ -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<u8>,
outbox_program_id: ProgramId,
ordinal: u32,
},
}
/// PDA accumulating all locked balance on this zone.
#[must_use]
pub fn escrow_account_id(bridge_lock_id: ProgramId) -> AccountId {
AccountId::for_public_pda(&bridge_lock_id, &escrow_seed())
}
#[must_use]
pub 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));
}
}

View File

@ -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::<Instruction>();
assert!(
caller_program_id.is_none(),
"bridge_lock is only invoked as a top-level user transaction"
);
let Instruction::Lock {
amount,
target_zone,
target_program_id,
target_accounts,
payload,
outbox_program_id,
ordinal,
} = instruction;
// Value conservation: the forwarded payload must mint exactly what is locked.
let WrappedInstruction::Mint {
amount: mint_amount,
..
} = decode_mint(&payload);
assert_eq!(
mint_amount, amount,
"locked amount must equal the wrapped mint amount"
);
// pre_states: [holder holding (authorized), escrow PDA, outbox PDA].
let [holder, escrow, outbox] = <[AccountWithMetadata; 3]>::try_from(pre_states)
.expect("Lock requires holder, escrow, and outbox accounts");
assert!(holder.is_authorized, "holder must authorize the lock");
assert_eq!(
holder.account.program_owner, self_program_id,
"holder account must be a bridge_lock holding"
);
assert_eq!(
escrow.account_id,
escrow_account_id(self_program_id),
"second account must be the escrow PDA"
);
let holder_new = read_balance(&holder.account.data.clone().into_inner())
.checked_sub(amount)
.expect("insufficient balance to lock");
let escrow_new = read_balance(&escrow.account.data.clone().into_inner())
.checked_add(amount)
.expect("escrow balance overflow");
let mut holder_account = holder.account.clone();
holder_account.data = balance_bytes(holder_new)
.to_vec()
.try_into()
.expect("balance fits in account data");
let holder_post = AccountPostState::new(holder_account);
let mut escrow_account = escrow.account.clone();
escrow_account.data = balance_bytes(escrow_new)
.to_vec()
.try_into()
.expect("balance fits in account data");
let escrow_post =
AccountPostState::new_claimed_if_default(escrow_account, Claim::Pda(escrow_seed()));
let call = ChainedCall::new(
outbox_program_id,
vec![outbox.clone()],
&OutboxInstruction::Emit {
target_zone,
target_program_id,
target_accounts,
payload,
ordinal,
},
);
ProgramOutput::new(
self_program_id,
caller_program_id,
instruction_words,
vec![holder, escrow, outbox.clone()],
vec![
holder_post,
escrow_post,
AccountPostState::new(outbox.account),
],
)
.with_chained_calls(vec![call])
.write();
}
/// Decodes the cross-zone payload (risc0 words, little-endian bytes) into the
/// wrapped-token instruction it carries.
fn decode_mint(payload: &[u8]) -> WrappedInstruction {
assert!(
payload.len().is_multiple_of(4),
"payload must be u32-aligned instruction words"
);
let words: Vec<u32> = 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")
}

View File

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

View File

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

View File

@ -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<ProgramId>,
/// 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<CrossZonePeer>,
}
/// 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<u8>,
/// Reserved for a future source-state proof; MUST be `None` in v1.
pub l1_inclusion_witness: Option<Vec<u8>>,
}
/// 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<ZoneId, ExpectedPubkey>,
pub allowed_targets: BTreeMap<ZoneId, Vec<ProgramId>>,
}
impl InboxConfig {
/// Borsh-encoded form stored in the inbox config account.
#[must_use]
pub fn to_bytes(&self) -> Vec<u8> {
borsh::to_vec(self).expect("InboxConfig serializes")
}
/// Decodes an [`InboxConfig`] from account data.
pub fn from_bytes(bytes: &[u8]) -> borsh::io::Result<Self> {
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<MessageKey>);
impl SeenShard {
/// Decodes a shard from account data; empty data is an empty shard.
pub fn from_bytes(bytes: &[u8]) -> borsh::io::Result<Self> {
if bytes.is_empty() {
return Ok(Self::default());
}
borsh::from_slice(bytes)
}
#[must_use]
pub fn to_bytes(&self) -> Vec<u8> {
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),
);
}
}

View File

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

View File

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

View File

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

View File

@ -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<u8>,
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<u8>,
}
impl OutboxRecord {
/// Borsh-encoded form stored in the outbox PDA's account data.
#[must_use]
pub fn to_bytes(&self) -> Vec<u8> {
borsh::to_vec(self).expect("OutboxRecord serializes")
}
/// Decodes an [`OutboxRecord`] from account data.
pub fn from_bytes(bytes: &[u8]) -> borsh::io::Result<Self> {
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));
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,13 @@
[package]
name = "wrapped_token_core"
version = "0.1.0"
edition = "2024"
license = { workspace = true }
[lints]
workspace = true
[dependencies]
lee_core.workspace = true
serde = { workspace = true, features = ["alloc"] }
risc0-zkvm.workspace = true

View File

@ -0,0 +1,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<ProgramId> {
if data.len() < 32 {
return None;
}
let mut minter = [0_u32; 8];
for (word, chunk) in minter.iter_mut().zip(data[..32].chunks_exact(4)) {
*word = u32::from_le_bytes(chunk.try_into().unwrap_or_else(|_| unreachable!()));
}
Some(minter)
}
/// Reads a wrapped-token balance from account data; empty data is a zero balance.
#[must_use]
pub fn read_balance(data: &[u8]) -> u128 {
if data.len() < 16 {
return 0;
}
u128::from_le_bytes(data[..16].try_into().unwrap_or_else(|_| unreachable!()))
}
#[must_use]
pub 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])
);
}
}

View File

@ -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::<Instruction>();
let Instruction::Mint { recipient, amount } = instruction;
// pre_states: [config PDA, recipient holding PDA].
let [config, holding] = <[AccountWithMetadata; 2]>::try_from(pre_states)
.expect("Mint requires the config and recipient holding accounts");
// The config PDA is genesis-seeded with the authorized minter (the cross-zone
// inbox). Pin the caller to it, since the guest cannot import the inbox id.
assert_eq!(
config.account_id,
config_account_id(self_program_id),
"first account must be the wrapped-token config PDA"
);
let minter = read_minter(&config.account.data.clone().into_inner())
.expect("config account holds an authorized minter id");
assert_eq!(
caller_program_id,
Some(minter),
"Mint is only callable by the authorized minter (the cross-zone inbox)"
);
assert_eq!(
holding.account_id,
holding_account_id(self_program_id, &recipient),
"second account must be the recipient holding PDA"
);
let new_balance = read_balance(&holding.account.data.clone().into_inner())
.checked_add(amount)
.expect("wrapped-token balance overflow");
let mut holding_account = holding.account.clone();
holding_account.data = balance_bytes(new_balance)
.to_vec()
.try_into()
.expect("balance fits in account data");
let holding_post = AccountPostState::new_claimed_if_default(
holding_account,
Claim::Pda(holding_seed(&recipient)),
);
let config_post = AccountPostState::new(config.account.clone());
ProgramOutput::new(
self_program_id,
caller_program_id,
instruction_words,
vec![config, holding],
vec![config_post, holding_post],
)
.write();
}

View File

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

View File

@ -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<GenesisAction>,
/// Cross-zone messaging configuration. `None` disables the watcher.
#[serde(default)]
pub cross_zone: Option<CrossZoneConfig>,
}
#[derive(Clone, Serialize, Deserialize)]

View File

@ -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<NodeHttpClient>,
peer_zone: [u8; 32],
allowed_targets: Vec<ProgramId>,
expected_pubkey: Option<PublicKey>,
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::<Block>(&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}"),
}
}
}

View File

@ -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<BP: BlockPublisherTrait> SequencerCore<BP> {
}
}
// 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<LeeTrans
let genesis_txs = config
.genesis
.iter()
.map(|genesis_tx| match genesis_tx {
.filter_map(|genesis_tx| match genesis_tx {
GenesisAction::SupplyAccount {
account_id,
balance,
} => 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<LeeTrans
.map(LeeTransaction::Public)
.collect();
// Seed bridge-lock holder balances directly: they are not produced by any tx.
for action in &config.genesis {
if let GenesisAction::SupplyBridgeLockHolding { holder, amount } = action {
let (holder_id, account) = cross_zone::build_holding_account(*holder, *amount);
state.insert_genesis_account(holder_id, account);
}
}
// Seed this zone's cross-zone inbox config so the inbox guest can authorize
// inbound peer messages (zone-specific config, not produced by any tx).
if let Some(cross_zone) = &config.cross_zone {
let self_zone = *config.bedrock_config.channel_id.as_ref();
let (config_id, config_account) =
cross_zone::build_inbox_config_account(self_zone, cross_zone);
state.insert_genesis_account(config_id, config_account);
}
(state, genesis_txs)
}
/// 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: lee::ProgramId) -> bool {
cross_zone::is_sequencer_only_program(program_id)
}
fn build_supply_account_genesis_transaction(
account_id: &AccountId,
balance: u128,

View File

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

View File

@ -74,6 +74,20 @@ impl<BC: BlockPublisherTrait + Send + 'static> 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

View File

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

View File

@ -258,9 +258,30 @@ fn initial_public_accounts() -> HashMap<AccountId, Account> {
.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<Program> {
vec![
programs::authenticated_transfer(),
@ -271,6 +292,12 @@ fn initial_programs() -> Vec<Program> {
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(),
]
}

View File

@ -0,0 +1,17 @@
[package]
name = "bridge_lock_core"
version = "0.1.0"
edition = "2024"
license = { workspace = true }
[lints]
workspace = true
[dependencies]
lee_core.workspace = true
serde = { workspace = true, features = ["alloc"] }
lee = { workspace = true, optional = true }
[features]
# Host-only genesis helper; pulls `lee`, so the risc0 guest builds without it.
host = ["dep:lee"]

View File

@ -0,0 +1,92 @@
//! Core types for the bridge-lock program, the source side of the cross-zone
//! token bridge. A holder locks part of their balance into an escrow and emits a
//! cross-zone message minting the wrapped token on the target zone.
use lee_core::{
account::AccountId,
program::{PdaSeed, ProgramId},
};
use serde::{Deserialize, Serialize};
const ESCROW_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/BridgeLockEscrow/0000/";
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Instruction {
/// Lock `amount` of the holder's balance and emit a cross-zone message
/// minting the wrapped token on `target_zone`. The emission fields mirror
/// `cross_zone_outbox::Instruction::Emit` so the watcher reads them directly.
///
/// Required accounts (3): holder holding (authorized), escrow PDA, outbox PDA.
Lock {
amount: u128,
target_zone: [u8; 32],
target_program_id: ProgramId,
target_accounts: Vec<[u8; 32]>,
payload: Vec<u8>,
outbox_program_id: ProgramId,
ordinal: u32,
},
}
/// PDA accumulating all locked balance on this zone.
#[must_use]
pub fn escrow_account_id(bridge_lock_id: ProgramId) -> AccountId {
AccountId::for_public_pda(&bridge_lock_id, &escrow_seed())
}
#[must_use]
pub fn escrow_seed() -> PdaSeed {
PdaSeed::new(ESCROW_SEED_DOMAIN)
}
/// Reads a bridgeable balance from account data; empty data is a zero balance.
#[must_use]
pub fn read_balance(data: &[u8]) -> u128 {
if data.len() < 16 {
return 0;
}
u128::from_le_bytes(data[..16].try_into().unwrap_or_else(|_| unreachable!()))
}
#[must_use]
pub fn balance_bytes(amount: u128) -> [u8; 16] {
amount.to_le_bytes()
}
/// Builds the genesis holding account funding a holder's bridgeable balance:
/// owned by bridge_lock, data is the LE balance, at the holder's account id. It
/// is not produced by any transaction, so the sequencer and the indexer both
/// seed it through this one builder to keep their genesis states identical.
#[cfg(feature = "host")]
#[must_use]
pub fn build_holding_account(
holder: AccountId,
amount: u128,
) -> (AccountId, lee_core::account::Account) {
let account = lee_core::account::Account {
program_owner: lee::program::Program::bridge_lock().id(),
data: balance_bytes(amount)
.to_vec()
.try_into()
.expect("balance fits in account data"),
..Default::default()
};
(holder, account)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn balance_round_trips() {
assert_eq!(read_balance(&balance_bytes(7)), 7);
assert_eq!(read_balance(&[]), 0);
}
#[test]
fn escrow_is_stable() {
let id: ProgramId = [4; 8];
assert_eq!(escrow_account_id(id), escrow_account_id(id));
}
}

View File

@ -0,0 +1,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"]

View File

@ -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<ProgramId>,
/// 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<CrossZonePeer>,
}
/// 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<u8>,
/// Reserved for a future source-state proof; MUST be `None` in v1.
pub l1_inclusion_witness: Option<Vec<u8>>,
}
/// 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<ZoneId, ExpectedPubkey>,
pub allowed_targets: BTreeMap<ZoneId, Vec<ProgramId>>,
}
impl InboxConfig {
/// Borsh-encoded form stored in the inbox config account.
#[must_use]
pub fn to_bytes(&self) -> Vec<u8> {
borsh::to_vec(self).expect("InboxConfig serializes")
}
/// Decodes an [`InboxConfig`] from account data.
pub fn from_bytes(bytes: &[u8]) -> borsh::io::Result<Self> {
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<MessageKey>);
impl SeenShard {
/// Decodes a shard from account data; empty data is an empty shard.
pub fn from_bytes(bytes: &[u8]) -> borsh::io::Result<Self> {
if bytes.is_empty() {
return Ok(Self::default());
}
borsh::from_slice(bytes)
}
#[must_use]
pub fn to_bytes(&self) -> Vec<u8> {
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<AccountId>,
) -> 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<u8>,
}
/// 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<Emission> {
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<u8>,
) -> 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);
}
}

View File

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

View File

@ -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<u8>,
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<u8>,
}
impl OutboxRecord {
/// Borsh-encoded form stored in the outbox PDA's account data.
#[must_use]
pub fn to_bytes(&self) -> Vec<u8> {
borsh::to_vec(self).expect("OutboxRecord serializes")
}
/// Decodes an [`OutboxRecord`] from account data.
pub fn from_bytes(bytes: &[u8]) -> borsh::io::Result<Self> {
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));
}
}

View File

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

View File

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

View File

@ -0,0 +1,13 @@
[package]
name = "wrapped_token_core"
version = "0.1.0"
edition = "2024"
license = { workspace = true }
[lints]
workspace = true
[dependencies]
lee_core.workspace = true
serde = { workspace = true, features = ["alloc"] }
risc0-zkvm.workspace = true

View File

@ -0,0 +1,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<ProgramId> {
if data.len() < 32 {
return None;
}
let mut minter = [0_u32; 8];
for (word, chunk) in minter.iter_mut().zip(data[..32].chunks_exact(4)) {
*word = u32::from_le_bytes(chunk.try_into().unwrap_or_else(|_| unreachable!()));
}
Some(minter)
}
/// Reads a wrapped-token balance from account data; empty data is a zero balance.
#[must_use]
pub fn read_balance(data: &[u8]) -> u128 {
if data.len() < 16 {
return 0;
}
u128::from_le_bytes(data[..16].try_into().unwrap_or_else(|_| unreachable!()))
}
#[must_use]
pub fn balance_bytes(amount: u128) -> [u8; 16] {
amount.to_le_bytes()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn minter_round_trips() {
let minter: ProgramId = [1, 2, 3, 4, 5, 6, 7, 8];
assert_eq!(read_minter(&minter_bytes(minter)), Some(minter));
}
#[test]
fn balance_round_trips() {
assert_eq!(read_balance(&balance_bytes(42)), 42);
assert_eq!(read_balance(&[]), 0);
}
#[test]
fn holding_is_unique_per_recipient() {
let id: ProgramId = [9; 8];
assert_ne!(
holding_account_id(id, &[1; 32]),
holding_account_id(id, &[2; 32])
);
assert_eq!(
holding_account_id(id, &[1; 32]),
holding_account_id(id, &[1; 32])
);
}
}

View File

@ -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")?;

View File

@ -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<GenesisAction>,
channel_id: ChannelId,
cross_zone: Option<CrossZoneConfig>,
) -> Result<SequencerConfig> {
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<WalletConfig> {
})
}
pub fn indexer_config(bedrock_addr: SocketAddr) -> Result<IndexerConfig> {
pub fn indexer_config(
bedrock_addr: SocketAddr,
channel_id: ChannelId,
cross_zone: Option<CrossZoneConfig>,
) -> Result<IndexerConfig> {
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<IndexerConfig> {
.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)
}

View File

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

View File

@ -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<sequencer_core::config::CrossZoneConfig>,
) -> 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<GenesisAction>,
channel_id: ChannelId,
cross_zone: Option<sequencer_core::config::CrossZoneConfig>,
) -> 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<sequencer_core::config::CrossZoneConfig>,
) -> 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")?;

View File

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

View File

@ -0,0 +1,234 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Cross-zone chat</title>
<style>
:root { color-scheme: light dark; }
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
background: #0e1116;
color: #e6edf3;
}
header {
padding: 14px 20px;
border-bottom: 1px solid #232a33;
font-size: 15px;
color: #9aa7b4;
}
header b { color: #e6edf3; }
.columns { display: flex; height: calc(100vh - 51px); }
.zone {
flex: 1 1 0;
display: flex;
flex-direction: column;
min-width: 0;
}
.zone + .zone { border-left: 1px solid #232a33; }
.zone h2 {
margin: 0;
padding: 12px 18px;
font-size: 14px;
letter-spacing: .04em;
text-transform: uppercase;
color: #9aa7b4;
border-bottom: 1px solid #232a33;
}
.zone.a h2 { color: #6cb6ff; }
.zone.b h2 { color: #6bd968; }
.log {
flex: 1 1 auto;
overflow-y: auto;
padding: 14px 18px;
display: flex;
flex-direction: column;
gap: 8px;
}
.msg {
background: #161b22;
border: 1px solid #232a33;
border-radius: 10px;
padding: 8px 12px;
max-width: 90%;
word-wrap: break-word;
white-space: pre-wrap;
}
.msg.inflight { border-color: #3a3320; }
.msg .text { margin-bottom: 6px; }
.stages {
display: flex;
flex-wrap: wrap;
gap: 6px;
font-size: 11px;
line-height: 1.4;
color: #9aa7b4;
}
.stage {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 1px 7px;
border-radius: 999px;
border: 1px solid #2d3640;
background: #0e1116;
white-space: nowrap;
}
.stage.todo { opacity: .4; }
.stage.wait { border-color: #6b5a1f; color: #e3b341; }
.stage.done { border-color: #2c5d33; color: #6bd968; }
.stage .blk { color: #6cb6ff; font-variant-numeric: tabular-nums; }
.elapsed { color: #7d8893; font-variant-numeric: tabular-nums; }
.composer {
display: flex;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid #232a33;
}
.composer input {
flex: 1 1 auto;
padding: 9px 12px;
border-radius: 8px;
border: 1px solid #2d3640;
background: #0e1116;
color: #e6edf3;
font-size: 14px;
}
.composer button {
padding: 9px 16px;
border-radius: 8px;
border: 0;
background: #2563eb;
color: #fff;
font-size: 14px;
cursor: pointer;
}
.composer button:disabled { opacity: .5; cursor: default; }
</style>
</head>
<body>
<header>
<b>Cross-zone chat</b> — type in a column; each message shows its journey: sent into a source block, that block reaching <span style="color:#e3b341">Bedrock finality</span>, then <span style="color:#6bd968">delivery</span> into the other zone. The finality wait is the bulk of the latency.
</header>
<div class="columns">
<section class="zone a" data-zone="A">
<h2>Zone A</h2>
<div class="log" id="log-A"></div>
<form class="composer" data-zone="A">
<input type="text" placeholder="Message from Zone A → B" autocomplete="off" />
<button type="submit">Send</button>
</form>
</section>
<section class="zone b" data-zone="B">
<h2>Zone B</h2>
<div class="log" id="log-B"></div>
<form class="composer" data-zone="B">
<input type="text" placeholder="Message from Zone B → A" autocomplete="off" />
<button type="submit">Send</button>
</form>
</section>
</div>
<script>
// id -> { el, textEl, stagesEl } for in-place updates.
const bubbles = new Map();
function stage(cls, label, blockZone, block) {
const span = '<span class="stage ' + cls + '">' + label +
(block != null ? ' <span class="blk">' + blockZone + '#' + block + '</span>' : '') +
'</span>';
return span;
}
function renderStages(m) {
const parts = [];
// 1. submitted into a source block on the sender zone
if (m.source_block != null) {
parts.push(stage('done', 'sent', m.source_zone, m.source_block));
} else {
parts.push(stage('wait', 'sending…'));
}
// 2. Bedrock finality of that source block
if (m.finalized) {
parts.push(stage('done', 'Bedrock ✓ finalized'));
} else if (m.source_block != null) {
parts.push(stage('wait', 'Bedrock ⏳ pending'));
} else {
parts.push(stage('todo', 'Bedrock'));
}
// 3. delivery on the receiver zone
if (m.delivered_block != null) {
parts.push(stage('done', 'delivered', m.zone, m.delivered_block));
} else {
parts.push(stage(m.finalized ? 'wait' : 'todo', 'delivering…'));
}
// elapsed
const t = m.delivered_block != null ? (m.elapsed + 's ✓') : (m.elapsed + 's');
parts.push('<span class="elapsed">' + t + '</span>');
return parts.join('');
}
function upsert(m) {
let b = bubbles.get(m.id);
if (!b) {
const log = document.getElementById('log-' + m.zone);
const el = document.createElement('div');
el.className = 'msg inflight';
const textEl = document.createElement('div');
textEl.className = 'text';
textEl.textContent = m.text;
const stagesEl = document.createElement('div');
stagesEl.className = 'stages';
el.appendChild(textEl);
el.appendChild(stagesEl);
log.appendChild(el);
log.scrollTop = log.scrollHeight;
b = { el, stagesEl };
bubbles.set(m.id, b);
}
b.el.className = 'msg' + (m.delivered_block != null ? '' : ' inflight');
b.stagesEl.innerHTML = renderStages(m);
}
async function poll() {
try {
const res = await fetch('/timeline');
if (!res.ok) return;
const entries = await res.json();
for (const m of entries) upsert(m);
} catch (_) { /* transient; retry next tick */ }
}
for (const form of document.querySelectorAll('form.composer')) {
form.addEventListener('submit', async (ev) => {
ev.preventDefault();
const zone = form.dataset.zone;
const input = form.querySelector('input');
const text = input.value.trim();
if (!text) return;
input.value = '';
try {
const res = await fetch('/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ zone, text }),
});
if (!res.ok) {
input.value = text;
input.placeholder = 'send failed: ' + (await res.text());
} else {
poll();
}
} catch (err) {
input.value = text;
input.placeholder = 'send error: ' + err;
}
});
}
setInterval(poll, 1000);
poll();
</script>
</body>
</html>

View File

@ -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<u64>,
/// Whether `source_block` has reached Bedrock finality.
finalized: bool,
/// Block on the receiver zone that delivered the inbox dispatch.
delivered_block: Option<u64>,
created: Instant,
/// Wall-clock seconds from submit to delivery, set once delivered.
delivered_secs: Option<u64>,
}
/// 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<u64>,
finalized: bool,
delivered_block: Option<u64>,
/// 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<Vec<TrackedMessage>>,
}
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<TimelineEntry> {
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<SequencerClient> {
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<AppState>, 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<AppState>) {
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<String> {
let instruction: Instruction =
risc0_zkvm::serde::from_slice::<Instruction, u32>(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<u32> {
let instruction: SenderInstruction =
risc0_zkvm::serde::from_slice::<SenderInstruction, u32>(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<String> {
let chunks = payload.chunks_exact(4);
if !chunks.remainder().is_empty() {
return None;
}
let words: Vec<u32> = 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::<ReceiverInstruction, u32>(&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<u8> = 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<Arc<AppState>>,
Json(request): Json<SendRequest>,
) -> Result<Json<SendResponse>, (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<Arc<AppState>>) -> Json<Vec<TimelineEntry>> {
Json(state.timeline())
}