mirror of
https://github.com/logos-blockchain/logos-execution-zone.git
synced 2026-07-12 00:39:29 +00:00
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:
commit
cac4921581
151
Cargo.lock
generated
151
Cargo.lock
generated
@ -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"
|
||||
|
||||
15
Cargo.toml
15
Cargo.toml
@ -43,6 +43,13 @@ members = [
|
||||
"lez/programs/pinata_token",
|
||||
"lez/programs/token",
|
||||
"lez/programs/vault",
|
||||
"lez/programs/cross_zone_inbox",
|
||||
"lez/programs/cross_zone_outbox",
|
||||
"lez/programs/bridge_lock",
|
||||
"lez/programs/wrapped_token",
|
||||
"lez/programs/ping_sender",
|
||||
"lez/programs/ping_receiver",
|
||||
"lez/cross_zone",
|
||||
|
||||
"test_programs",
|
||||
"test_programs/guest",
|
||||
@ -58,6 +65,7 @@ members = [
|
||||
"tools/cycle_bench",
|
||||
"tools/crypto_primitives_bench",
|
||||
"tools/integration_bench",
|
||||
"tools/cross_zone_chat",
|
||||
]
|
||||
|
||||
[workspace.dependencies]
|
||||
@ -94,6 +102,12 @@ authenticated_transfer_core = { path = "lez/programs/authenticated_transfer/core
|
||||
faucet_core = { path = "lez/programs/faucet/core" }
|
||||
bridge_core = { path = "lez/programs/bridge/core" }
|
||||
vault_core = { path = "lez/programs/vault/core" }
|
||||
cross_zone_inbox_core = { path = "lez/programs/cross_zone_inbox/core" }
|
||||
cross_zone_outbox_core = { path = "lez/programs/cross_zone_outbox/core" }
|
||||
bridge_lock_core = { path = "lez/programs/bridge_lock/core" }
|
||||
wrapped_token_core = { path = "lez/programs/wrapped_token/core" }
|
||||
ping_core = { path = "lez/programs/ping_core" }
|
||||
cross_zone = { path = "lez/cross_zone" }
|
||||
build_utils = { path = "build_utils" }
|
||||
test_programs = { path = "test_programs" }
|
||||
testnet_initial_state = { path = "lez/testnet_initial_state" }
|
||||
@ -182,6 +196,7 @@ elliptic-curve = { version = "0.13.8", features = ["arithmetic"] }
|
||||
actix-web = { version = "4.13.0", default-features = false, features = [
|
||||
"macros",
|
||||
] }
|
||||
axum = "0.8.4"
|
||||
clap = { version = "4.5.42", features = ["derive", "env"] }
|
||||
reqwest = { version = "0.12", features = ["json", "rustls-tls", "stream"] }
|
||||
pyo3 = { version = "0.29", features = ["auto-initialize"] }
|
||||
|
||||
25
Justfile
25
Justfile
@ -6,6 +6,11 @@ default:
|
||||
# ---- Configuration ----
|
||||
ARTIFACTS := "artifacts"
|
||||
|
||||
# On macOS the integration-test binary links pyo3 against the CommandLineTools
|
||||
# Python framework with no embedded rpath, so it needs this to launch. Empty on
|
||||
# Linux/CI, which is unaffected.
|
||||
DEMO_ENV := if os() == "macos" { "DYLD_FALLBACK_FRAMEWORK_PATH=/Library/Developer/CommandLineTools/Library/Frameworks" } else { "" }
|
||||
|
||||
# Build risc0 program artifacts.
|
||||
build-artifacts:
|
||||
@echo "🔨 Building artifacts"
|
||||
@ -99,6 +104,26 @@ wallet-import-test-accounts:
|
||||
|
||||
just run-wallet account list
|
||||
|
||||
# Demo: cross-zone ping. Boots two zones on one Bedrock and sends a message from
|
||||
# zone A to zone B, where the indexer re-derives and verifies it (Option B)
|
||||
# before ping_receiver records it. Dev mode, no proving.
|
||||
demo-cross-zone-ping:
|
||||
@echo "📡 Cross-zone ping demo (message A → B, indexer-verified)"
|
||||
{{DEMO_ENV}} RISC0_DEV_MODE=1 cargo test -p integration_tests --release --test cross_zone_verified -- --nocapture
|
||||
|
||||
# Demo: cross-zone wrapped-token bridge. Locks a balance on zone A and mints the
|
||||
# wrapped token to a recipient on zone B over the same verified spine.
|
||||
demo-cross-zone-bridge:
|
||||
@echo "🌉 Cross-zone bridge demo (lock on A, mint on B)"
|
||||
{{DEMO_ENV}} RISC0_DEV_MODE=1 cargo test -p integration_tests --release --test cross_zone_bridge -- --nocapture
|
||||
|
||||
# Demo: interactive cross-zone chat. Boots two zones on one Bedrock and serves a
|
||||
# local two-column web UI; type in one zone and watch the message cross into the
|
||||
# other. Two people can chat across the zones. Dev mode, no proving.
|
||||
cross-zone-chat:
|
||||
@echo "💬 Cross-zone chat demo — open the printed localhost URL"
|
||||
{{DEMO_ENV}} RISC0_DEV_MODE=1 cargo run -p cross_zone_chat --release
|
||||
|
||||
# Clean runtime data
|
||||
clean:
|
||||
@echo "🧹 Cleaning run artifacts"
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
artifacts/lez/programs/bridge_lock.bin
Normal file
BIN
artifacts/lez/programs/bridge_lock.bin
Normal file
Binary file not shown.
Binary file not shown.
BIN
artifacts/lez/programs/cross_zone_inbox.bin
Normal file
BIN
artifacts/lez/programs/cross_zone_inbox.bin
Normal file
Binary file not shown.
BIN
artifacts/lez/programs/cross_zone_outbox.bin
Normal file
BIN
artifacts/lez/programs/cross_zone_outbox.bin
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
artifacts/lez/programs/ping_receiver.bin
Normal file
BIN
artifacts/lez/programs/ping_receiver.bin
Normal file
Binary file not shown.
BIN
artifacts/lez/programs/ping_sender.bin
Normal file
BIN
artifacts/lez/programs/ping_sender.bin
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
artifacts/lez/programs/wrapped_token.bin
Normal file
BIN
artifacts/lez/programs/wrapped_token.bin
Normal file
Binary file not shown.
@ -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
|
||||
|
||||
197
integration_tests/tests/cross_zone_bridge.rs
Normal file
197
integration_tests/tests/cross_zone_bridge.rs
Normal 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")?
|
||||
}
|
||||
80
integration_tests/tests/cross_zone_ingress_guard.rs
Normal file
80
integration_tests/tests/cross_zone_ingress_guard.rs
Normal 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")
|
||||
}
|
||||
143
integration_tests/tests/cross_zone_ping.rs
Normal file
143
integration_tests/tests/cross_zone_ping.rs
Normal 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")?
|
||||
}
|
||||
373
integration_tests/tests/cross_zone_state_machine.rs
Normal file
373
integration_tests/tests/cross_zone_state_machine.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
162
integration_tests/tests/cross_zone_verified.rs
Normal file
162
integration_tests/tests/cross_zone_verified.rs
Normal 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")?
|
||||
}
|
||||
@ -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");
|
||||
|
||||
110
integration_tests/tests/two_zone.rs
Normal file
110
integration_tests/tests/two_zone.rs
Normal 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:?}"))?
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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
17
lez/cross_zone/Cargo.toml
Normal 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
189
lez/cross_zone/src/lib.rs
Normal 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)
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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(|| {
|
||||
|
||||
500
lez/indexer/core/src/cross_zone_verifier.rs
Normal file
500
lez/indexer/core/src/cross_zone_verifier.rs
Normal 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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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 }
|
||||
|
||||
15
lez/programs/bridge_lock/Cargo.toml
Normal file
15
lez/programs/bridge_lock/Cargo.toml
Normal 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
|
||||
12
lez/programs/bridge_lock/core/Cargo.toml
Normal file
12
lez/programs/bridge_lock/core/Cargo.toml
Normal 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"] }
|
||||
71
lez/programs/bridge_lock/core/src/lib.rs
Normal file
71
lez/programs/bridge_lock/core/src/lib.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
121
lez/programs/bridge_lock/src/main.rs
Normal file
121
lez/programs/bridge_lock/src/main.rs
Normal 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")
|
||||
}
|
||||
12
lez/programs/cross_zone_inbox/Cargo.toml
Normal file
12
lez/programs/cross_zone_inbox/Cargo.toml
Normal 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
|
||||
14
lez/programs/cross_zone_inbox/core/Cargo.toml
Normal file
14
lez/programs/cross_zone_inbox/core/Cargo.toml
Normal 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
|
||||
203
lez/programs/cross_zone_inbox/core/src/lib.rs
Normal file
203
lez/programs/cross_zone_inbox/core/src/lib.rs
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
125
lez/programs/cross_zone_inbox/src/main.rs
Normal file
125
lez/programs/cross_zone_inbox/src/main.rs
Normal 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();
|
||||
}
|
||||
12
lez/programs/cross_zone_outbox/Cargo.toml
Normal file
12
lez/programs/cross_zone_outbox/Cargo.toml
Normal 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
|
||||
14
lez/programs/cross_zone_outbox/core/Cargo.toml
Normal file
14
lez/programs/cross_zone_outbox/core/Cargo.toml
Normal 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
|
||||
93
lez/programs/cross_zone_outbox/core/src/lib.rs
Normal file
93
lez/programs/cross_zone_outbox/core/src/lib.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
72
lez/programs/cross_zone_outbox/src/main.rs
Normal file
72
lez/programs/cross_zone_outbox/src/main.rs
Normal 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();
|
||||
}
|
||||
12
lez/programs/ping_core/Cargo.toml
Normal file
12
lez/programs/ping_core/Cargo.toml
Normal 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"] }
|
||||
38
lez/programs/ping_core/src/lib.rs
Normal file
38
lez/programs/ping_core/src/lib.rs
Normal 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)
|
||||
}
|
||||
12
lez/programs/ping_receiver/Cargo.toml
Normal file
12
lez/programs/ping_receiver/Cargo.toml
Normal 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
|
||||
48
lez/programs/ping_receiver/src/main.rs
Normal file
48
lez/programs/ping_receiver/src/main.rs
Normal 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();
|
||||
}
|
||||
13
lez/programs/ping_sender/Cargo.toml
Normal file
13
lez/programs/ping_sender/Cargo.toml
Normal 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
|
||||
59
lez/programs/ping_sender/src/main.rs
Normal file
59
lez/programs/ping_sender/src/main.rs
Normal 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();
|
||||
}
|
||||
@ -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();
|
||||
|
||||
12
lez/programs/wrapped_token/Cargo.toml
Normal file
12
lez/programs/wrapped_token/Cargo.toml
Normal 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
|
||||
13
lez/programs/wrapped_token/core/Cargo.toml
Normal file
13
lez/programs/wrapped_token/core/Cargo.toml
Normal 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
|
||||
121
lez/programs/wrapped_token/core/src/lib.rs
Normal file
121
lez/programs/wrapped_token/core/src/lib.rs
Normal 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])
|
||||
);
|
||||
}
|
||||
}
|
||||
70
lez/programs/wrapped_token/src/main.rs
Normal file
70
lez/programs/wrapped_token/src/main.rs
Normal 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();
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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)]
|
||||
|
||||
185
lez/sequencer/core/src/cross_zone_watcher.rs
Normal file
185
lez/sequencer/core/src/cross_zone_watcher.rs
Normal 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}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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(),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
17
programs/bridge_lock/core/Cargo.toml
Normal file
17
programs/bridge_lock/core/Cargo.toml
Normal 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"]
|
||||
92
programs/bridge_lock/core/src/lib.rs
Normal file
92
programs/bridge_lock/core/src/lib.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
22
programs/cross_zone_inbox/core/Cargo.toml
Normal file
22
programs/cross_zone_inbox/core/Cargo.toml
Normal 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"]
|
||||
378
programs/cross_zone_inbox/core/src/lib.rs
Normal file
378
programs/cross_zone_inbox/core/src/lib.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
14
programs/cross_zone_outbox/core/Cargo.toml
Normal file
14
programs/cross_zone_outbox/core/Cargo.toml
Normal 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
|
||||
93
programs/cross_zone_outbox/core/src/lib.rs
Normal file
93
programs/cross_zone_outbox/core/src/lib.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
12
programs/ping/core/Cargo.toml
Normal file
12
programs/ping/core/Cargo.toml
Normal 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"] }
|
||||
38
programs/ping/core/src/lib.rs
Normal file
38
programs/ping/core/src/lib.rs
Normal 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)
|
||||
}
|
||||
13
programs/wrapped_token/core/Cargo.toml
Normal file
13
programs/wrapped_token/core/Cargo.toml
Normal 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
|
||||
121
programs/wrapped_token/core/src/lib.rs
Normal file
121
programs/wrapped_token/core/src/lib.rs
Normal 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])
|
||||
);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@ -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")?;
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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")?;
|
||||
|
||||
|
||||
25
tools/cross_zone_chat/Cargo.toml
Normal file
25
tools/cross_zone_chat/Cargo.toml
Normal 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
|
||||
234
tools/cross_zone_chat/src/index.html
Normal file
234
tools/cross_zone_chat/src/index.html
Normal 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>
|
||||
533
tools/cross_zone_chat/src/main.rs
Normal file
533
tools/cross_zone_chat/src/main.rs
Normal 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())
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user