mirror of
https://github.com/logos-blockchain/logos-execution-zone.git
synced 2026-07-16 02:40:04 +00:00
540 lines
19 KiB
Rust
540 lines
19 KiB
Rust
//! 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::{SequencerSetup, setup_bedrock_node},
|
|
};
|
|
|
|
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) = SequencerSetup::new(partial, bedrock_addr)
|
|
.with_genesis(vec![])
|
|
.with_channel_id(channel_a)
|
|
.with_cross_zone(cross_zone_a)
|
|
.setup()
|
|
.await
|
|
.context("Failed to set up zone A sequencer")?;
|
|
let (seq_b, _home_b) = SequencerSetup::new(partial, bedrock_addr)
|
|
.with_genesis(vec![])
|
|
.with_channel_id(channel_b)
|
|
.with_cross_zone(cross_zone_b)
|
|
.setup()
|
|
.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())
|
|
}
|