mirror of
https://github.com/logos-blockchain/logos-execution-zone.git
synced 2026-07-14 01:39:58 +00:00
Merge branch 'dev' into erhant/lez-two-tip-chain-state
This commit is contained in:
commit
3c288f998a
151
Cargo.lock
generated
151
Cargo.lock
generated
@ -1154,6 +1154,25 @@ dependencies = [
|
|||||||
"serde",
|
"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]]
|
[[package]]
|
||||||
name = "bridge_program"
|
name = "bridge_program"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@ -1848,6 +1867,76 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
|
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]]
|
[[package]]
|
||||||
name = "crossbeam-channel"
|
name = "crossbeam-channel"
|
||||||
version = "0.5.15"
|
version = "0.5.15"
|
||||||
@ -3867,13 +3956,19 @@ dependencies = [
|
|||||||
"borsh",
|
"borsh",
|
||||||
"chain_state",
|
"chain_state",
|
||||||
"common",
|
"common",
|
||||||
|
"cross_zone",
|
||||||
|
"cross_zone_inbox_core",
|
||||||
"futures",
|
"futures",
|
||||||
|
"hex",
|
||||||
"humantime-serde",
|
"humantime-serde",
|
||||||
"lee",
|
"lee",
|
||||||
"lee_core",
|
"lee_core",
|
||||||
"log",
|
"log",
|
||||||
"logos-blockchain-core",
|
"logos-blockchain-core",
|
||||||
"logos-blockchain-zone-sdk",
|
"logos-blockchain-zone-sdk",
|
||||||
|
"ping_core",
|
||||||
|
"programs",
|
||||||
|
"risc0-zkvm",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"storage",
|
"storage",
|
||||||
@ -4027,8 +4122,11 @@ dependencies = [
|
|||||||
"authenticated_transfer_core",
|
"authenticated_transfer_core",
|
||||||
"borsh",
|
"borsh",
|
||||||
"bridge_core",
|
"bridge_core",
|
||||||
|
"bridge_lock_core",
|
||||||
"bytesize",
|
"bytesize",
|
||||||
"common",
|
"common",
|
||||||
|
"cross_zone_inbox_core",
|
||||||
|
"cross_zone_outbox_core",
|
||||||
"faucet_core",
|
"faucet_core",
|
||||||
"futures",
|
"futures",
|
||||||
"hex",
|
"hex",
|
||||||
@ -4044,8 +4142,10 @@ dependencies = [
|
|||||||
"logos-blockchain-key-management-system-service",
|
"logos-blockchain-key-management-system-service",
|
||||||
"logos-blockchain-zone-sdk",
|
"logos-blockchain-zone-sdk",
|
||||||
"num-bigint 0.4.6",
|
"num-bigint 0.4.6",
|
||||||
|
"ping_core",
|
||||||
"programs",
|
"programs",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
"risc0-zkvm",
|
||||||
"sequencer_core",
|
"sequencer_core",
|
||||||
"sequencer_service_rpc",
|
"sequencer_service_rpc",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@ -4058,6 +4158,7 @@ dependencies = [
|
|||||||
"vault_core",
|
"vault_core",
|
||||||
"wallet",
|
"wallet",
|
||||||
"wallet-ffi",
|
"wallet-ffi",
|
||||||
|
"wrapped_token_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -7190,6 +7291,31 @@ dependencies = [
|
|||||||
"token_core",
|
"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]]
|
[[package]]
|
||||||
name = "pkcs1"
|
name = "pkcs1"
|
||||||
version = "0.7.5"
|
version = "0.7.5"
|
||||||
@ -7462,15 +7588,20 @@ dependencies = [
|
|||||||
"associated_token_account_program",
|
"associated_token_account_program",
|
||||||
"authenticated_transfer_core",
|
"authenticated_transfer_core",
|
||||||
"bridge_core",
|
"bridge_core",
|
||||||
|
"bridge_lock_core",
|
||||||
"build_utils",
|
"build_utils",
|
||||||
"clock_core",
|
"clock_core",
|
||||||
|
"cross_zone_inbox_core",
|
||||||
|
"cross_zone_outbox_core",
|
||||||
"faucet_core",
|
"faucet_core",
|
||||||
"lee",
|
"lee",
|
||||||
"lee_core",
|
"lee_core",
|
||||||
|
"ping_core",
|
||||||
"risc0-zkvm",
|
"risc0-zkvm",
|
||||||
"token_core",
|
"token_core",
|
||||||
"token_program",
|
"token_program",
|
||||||
"vault_core",
|
"vault_core",
|
||||||
|
"wrapped_token_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -8910,6 +9041,8 @@ dependencies = [
|
|||||||
"chain_state",
|
"chain_state",
|
||||||
"chrono",
|
"chrono",
|
||||||
"common",
|
"common",
|
||||||
|
"cross_zone",
|
||||||
|
"cross_zone_inbox_core",
|
||||||
"faucet_core",
|
"faucet_core",
|
||||||
"futures",
|
"futures",
|
||||||
"hex",
|
"hex",
|
||||||
@ -9859,6 +9992,7 @@ dependencies = [
|
|||||||
"programs",
|
"programs",
|
||||||
"serde",
|
"serde",
|
||||||
"system_accounts",
|
"system_accounts",
|
||||||
|
"wrapped_token_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -11568,6 +11702,23 @@ dependencies = [
|
|||||||
"wasmparser",
|
"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]]
|
[[package]]
|
||||||
name = "writeable"
|
name = "writeable"
|
||||||
version = "0.6.3"
|
version = "0.6.3"
|
||||||
|
|||||||
15
Cargo.toml
15
Cargo.toml
@ -44,6 +44,13 @@ members = [
|
|||||||
"lez/programs/pinata_token",
|
"lez/programs/pinata_token",
|
||||||
"lez/programs/token",
|
"lez/programs/token",
|
||||||
"lez/programs/vault",
|
"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",
|
||||||
"test_programs/guest",
|
"test_programs/guest",
|
||||||
@ -59,6 +66,7 @@ members = [
|
|||||||
"tools/cycle_bench",
|
"tools/cycle_bench",
|
||||||
"tools/crypto_primitives_bench",
|
"tools/crypto_primitives_bench",
|
||||||
"tools/integration_bench",
|
"tools/integration_bench",
|
||||||
|
"tools/cross_zone_chat",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
@ -96,6 +104,12 @@ authenticated_transfer_core = { path = "lez/programs/authenticated_transfer/core
|
|||||||
faucet_core = { path = "lez/programs/faucet/core" }
|
faucet_core = { path = "lez/programs/faucet/core" }
|
||||||
bridge_core = { path = "lez/programs/bridge/core" }
|
bridge_core = { path = "lez/programs/bridge/core" }
|
||||||
vault_core = { path = "lez/programs/vault/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" }
|
build_utils = { path = "build_utils" }
|
||||||
test_programs = { path = "test_programs" }
|
test_programs = { path = "test_programs" }
|
||||||
testnet_initial_state = { path = "lez/testnet_initial_state" }
|
testnet_initial_state = { path = "lez/testnet_initial_state" }
|
||||||
@ -184,6 +198,7 @@ elliptic-curve = { version = "0.13.8", features = ["arithmetic"] }
|
|||||||
actix-web = { version = "4.13.0", default-features = false, features = [
|
actix-web = { version = "4.13.0", default-features = false, features = [
|
||||||
"macros",
|
"macros",
|
||||||
] }
|
] }
|
||||||
|
axum = "0.8.4"
|
||||||
clap = { version = "4.5.42", features = ["derive", "env"] }
|
clap = { version = "4.5.42", features = ["derive", "env"] }
|
||||||
reqwest = { version = "0.12", features = ["json", "rustls-tls", "stream"] }
|
reqwest = { version = "0.12", features = ["json", "rustls-tls", "stream"] }
|
||||||
pyo3 = { version = "0.29", features = ["auto-initialize"] }
|
pyo3 = { version = "0.29", features = ["auto-initialize"] }
|
||||||
|
|||||||
25
Justfile
25
Justfile
@ -6,6 +6,11 @@ default:
|
|||||||
# ---- Configuration ----
|
# ---- Configuration ----
|
||||||
ARTIFACTS := "artifacts"
|
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 risc0 program artifacts.
|
||||||
build-artifacts:
|
build-artifacts:
|
||||||
@echo "🔨 Building artifacts"
|
@echo "🔨 Building artifacts"
|
||||||
@ -99,6 +104,26 @@ wallet-import-test-accounts:
|
|||||||
|
|
||||||
just run-wallet account list
|
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 runtime data
|
||||||
clean:
|
clean:
|
||||||
@echo "🧹 Cleaning run artifacts"
|
@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
|
vault_core.workspace = true
|
||||||
faucet_core.workspace = true
|
faucet_core.workspace = true
|
||||||
bridge_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"] }
|
indexer_service_rpc = { workspace = true, features = ["client"] }
|
||||||
sequencer_service_rpc = { workspace = true, features = ["client"] }
|
sequencer_service_rpc = { workspace = true, features = ["client"] }
|
||||||
wallet-ffi.workspace = true
|
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()
|
temp_indexer_dir.path().display()
|
||||||
);
|
);
|
||||||
|
|
||||||
let indexer_config = integration_tests::config::indexer_config(bedrock_addr)
|
let indexer_config = integration_tests::config::indexer_config(
|
||||||
.context("Failed to create 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_json = serde_json::to_vec(&indexer_config)?;
|
||||||
let config_path = temp_indexer_dir.path().join("indexer_config.json");
|
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);
|
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) {
|
pub fn apply_state_diff(&mut self, diff: ValidatedStateDiff) {
|
||||||
let StateDiff {
|
let StateDiff {
|
||||||
signer_account_ids,
|
signer_account_ids,
|
||||||
|
|||||||
@ -68,6 +68,16 @@ impl Block {
|
|||||||
}
|
}
|
||||||
.compute_hash()
|
.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 {
|
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)
|
||||||
|
}
|
||||||
@ -17,6 +17,9 @@ common.workspace = true
|
|||||||
logos-blockchain-zone-sdk.workspace = true
|
logos-blockchain-zone-sdk.workspace = true
|
||||||
lee.workspace = true
|
lee.workspace = true
|
||||||
lee_core.workspace = true
|
lee_core.workspace = true
|
||||||
|
cross_zone.workspace = true
|
||||||
|
cross_zone_inbox_core.workspace = true
|
||||||
|
programs.workspace = true
|
||||||
storage.workspace = true
|
storage.workspace = true
|
||||||
testnet_initial_state.workspace = true
|
testnet_initial_state.workspace = true
|
||||||
|
|
||||||
@ -32,6 +35,9 @@ logos-blockchain-core.workspace = true
|
|||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
async-stream.workspace = true
|
async-stream.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
|
risc0-zkvm.workspace = true
|
||||||
|
hex.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
|
ping_core.workspace = true
|
||||||
|
|||||||
@ -23,13 +23,19 @@ pub struct IndexerStore {
|
|||||||
impl IndexerStore {
|
impl IndexerStore {
|
||||||
/// Starting database at the start of new chain.
|
/// Starting database at the start of new chain.
|
||||||
/// Creates files if necessary.
|
/// 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"))]
|
#[cfg(not(feature = "testnet"))]
|
||||||
let initial_state = testnet_initial_state::initial_state();
|
let mut initial_state = testnet_initial_state::initial_state();
|
||||||
|
|
||||||
#[cfg(feature = "testnet")]
|
#[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 dbio = RocksDBIO::open_or_create(location, &initial_state)?;
|
||||||
|
|
||||||
let current_state = dbio.final_state()?;
|
let current_state = dbio.final_state()?;
|
||||||
@ -266,7 +272,7 @@ mod stall_reason_tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn stall_reason_roundtrips_and_clears() {
|
async fn stall_reason_roundtrips_and_clears() {
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
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());
|
assert!(store.get_stall_reason().expect("get").is_none());
|
||||||
|
|
||||||
@ -313,7 +319,7 @@ mod tests {
|
|||||||
fn correct_startup() {
|
fn correct_startup() {
|
||||||
let home = tempdir().unwrap();
|
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();
|
let final_id = storage.get_last_block_id().unwrap();
|
||||||
|
|
||||||
@ -323,7 +329,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn accept_block_applies_transfers_and_advances_tip() {
|
async fn accept_block_applies_transfers_and_advances_tip() {
|
||||||
let home = tempdir().unwrap();
|
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 initial_accounts = initial_pub_accounts_private_keys();
|
||||||
let from = initial_accounts[0].account_id;
|
let from = initial_accounts[0].account_id;
|
||||||
@ -365,7 +371,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn account_state_at_block_reflects_history() {
|
async fn account_state_at_block_reflects_history() {
|
||||||
let home = tempdir().unwrap();
|
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 initial_accounts = initial_pub_accounts_private_keys();
|
||||||
let from = initial_accounts[0].account_id;
|
let from = initial_accounts[0].account_id;
|
||||||
@ -430,7 +436,7 @@ mod accept_tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn non_genesis_first_block_parks_with_unexpected_id() {
|
async fn non_genesis_first_block_parks_with_unexpected_id() {
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
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 block = valid_hash_block(2, HashType([0_u8; 32]));
|
||||||
let outcome = store
|
let outcome = store
|
||||||
@ -453,7 +459,7 @@ mod accept_tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn hash_mismatch_parks() {
|
async fn hash_mismatch_parks() {
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
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]));
|
let mut block = valid_hash_block(1, HashType([0_u8; 32]));
|
||||||
block.header.timestamp = 999; // invalidates the stored hash
|
block.header.timestamp = 999; // invalidates the stored hash
|
||||||
@ -471,7 +477,7 @@ mod accept_tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn second_break_bumps_orphan_count_and_keeps_first() {
|
async fn second_break_bumps_orphan_count_and_keeps_first() {
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
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]));
|
let first = valid_hash_block(2, HashType([0_u8; 32]));
|
||||||
store
|
store
|
||||||
@ -492,7 +498,7 @@ mod accept_tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn deserialize_break_records_stall_without_header() {
|
async fn deserialize_break_records_stall_without_header() {
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
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
|
store
|
||||||
.record_stall(
|
.record_stall(
|
||||||
@ -510,7 +516,7 @@ mod accept_tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn parks_then_recovers_on_valid_continuation() {
|
async fn parks_then_recovers_on_valid_continuation() {
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
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.
|
// Genesis (block 1, clock-only) applies and advances the tip.
|
||||||
let genesis = produce_dummy_block(1, None, vec![]);
|
let genesis = produce_dummy_block(1, None, vec![]);
|
||||||
@ -558,7 +564,7 @@ mod accept_tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn accept_block_records_tip_inscription_slot() {
|
async fn accept_block_records_tip_inscription_slot() {
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
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);
|
assert_eq!(store.get_tip_slot().expect("get"), None);
|
||||||
|
|
||||||
@ -600,7 +606,7 @@ mod accept_tests {
|
|||||||
use testnet_initial_state::initial_pub_accounts_private_keys;
|
use testnet_initial_state::initial_pub_accounts_private_keys;
|
||||||
|
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
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 accounts = initial_pub_accounts_private_keys();
|
||||||
let from = accounts[0].account_id;
|
let from = accounts[0].account_id;
|
||||||
@ -650,7 +656,7 @@ mod accept_tests {
|
|||||||
use testnet_initial_state::initial_pub_accounts_private_keys;
|
use testnet_initial_state::initial_pub_accounts_private_keys;
|
||||||
|
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
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 accounts = initial_pub_accounts_private_keys();
|
||||||
let from = accounts[0].account_id;
|
let from = accounts[0].account_id;
|
||||||
@ -710,7 +716,7 @@ mod accept_tests {
|
|||||||
use testnet_initial_state::initial_pub_accounts_private_keys;
|
use testnet_initial_state::initial_pub_accounts_private_keys;
|
||||||
|
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
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 accounts = initial_pub_accounts_private_keys();
|
||||||
let from = accounts[0].account_id;
|
let from = accounts[0].account_id;
|
||||||
@ -747,7 +753,7 @@ mod accept_tests {
|
|||||||
|
|
||||||
// The #605 restart: reopening past the boundary must work.
|
// The #605 restart: reopening past the boundary must work.
|
||||||
drop(store);
|
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));
|
assert_eq!(reopened.last_block().unwrap(), Some(101));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -756,7 +762,7 @@ mod accept_tests {
|
|||||||
use testnet_initial_state::initial_pub_accounts_private_keys;
|
use testnet_initial_state::initial_pub_accounts_private_keys;
|
||||||
|
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
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 accounts = initial_pub_accounts_private_keys();
|
||||||
let from = accounts[0].account_id;
|
let from = accounts[0].account_id;
|
||||||
|
|||||||
@ -318,6 +318,8 @@ mod tests {
|
|||||||
},
|
},
|
||||||
channel_id: ChannelId::from([1; 32]),
|
channel_id: ChannelId::from([1; 32]),
|
||||||
allow_chain_reset: false,
|
allow_chain_reset: false,
|
||||||
|
cross_zone: None,
|
||||||
|
bridge_lock_holdings: Vec::new(),
|
||||||
};
|
};
|
||||||
IndexerCore::open(config, dir).expect("open core")
|
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 anyhow::{Context as _, Result};
|
||||||
use common::config::BasicAuth;
|
use common::config::BasicAuth;
|
||||||
|
use cross_zone_inbox_core::CrossZoneConfig;
|
||||||
use humantime_serde;
|
use humantime_serde;
|
||||||
|
use lee::AccountId;
|
||||||
pub use logos_blockchain_core::mantle::ops::channel::ChannelId;
|
pub use logos_blockchain_core::mantle::ops::channel::ChannelId;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
@ -20,6 +22,14 @@ pub struct IndexerConfig {
|
|||||||
pub consensus_info_polling_interval: Duration,
|
pub consensus_info_polling_interval: Duration,
|
||||||
pub bedrock_config: ClientConfig,
|
pub bedrock_config: ClientConfig,
|
||||||
pub channel_id: ChannelId,
|
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
|
/// 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
|
/// chain-identity check finds the channel serving a different block than the one
|
||||||
/// stored at the same id.
|
/// stored at the same id.
|
||||||
@ -29,6 +39,14 @@ pub struct IndexerConfig {
|
|||||||
pub allow_chain_reset: bool,
|
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 {
|
impl IndexerConfig {
|
||||||
pub fn from_path(config_path: &Path) -> Result<Self> {
|
pub fn from_path(config_path: &Path) -> Result<Self> {
|
||||||
let file = File::open(config_path).with_context(|| {
|
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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -16,11 +16,14 @@ use crate::{
|
|||||||
block_store::IndexerStore,
|
block_store::IndexerStore,
|
||||||
chain_consistency::ChainConsistency,
|
chain_consistency::ChainConsistency,
|
||||||
config::IndexerConfig,
|
config::IndexerConfig,
|
||||||
|
cross_zone_verifier::CrossZoneVerifier,
|
||||||
status::{IndexerStatus, IndexerSyncStatus},
|
status::{IndexerStatus, IndexerSyncStatus},
|
||||||
};
|
};
|
||||||
pub mod block_store;
|
pub mod block_store;
|
||||||
pub mod chain_consistency;
|
pub mod chain_consistency;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod cross_zone_verifier;
|
||||||
|
pub mod ingest_error;
|
||||||
mod retry;
|
mod retry;
|
||||||
pub mod status;
|
pub mod status;
|
||||||
|
|
||||||
@ -36,6 +39,8 @@ pub struct IndexerCore {
|
|||||||
pub store: IndexerStore,
|
pub store: IndexerStore,
|
||||||
/// Live ingestion status; updated by the ingest stream, read by `status`.
|
/// Live ingestion status; updated by the ingest stream, read by `status`.
|
||||||
pub status: Arc<ArcSwap<IndexerSyncStatus>>,
|
pub status: Arc<ArcSwap<IndexerSyncStatus>>,
|
||||||
|
/// Option B cross-zone verifier; `None` when cross-zone messaging is disabled.
|
||||||
|
pub verifier: Option<CrossZoneVerifier>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IndexerCore {
|
impl IndexerCore {
|
||||||
@ -86,12 +91,35 @@ impl IndexerCore {
|
|||||||
);
|
);
|
||||||
let zone_indexer = ZoneIndexer::new(config.channel_id, node.clone());
|
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 {
|
Ok(Self {
|
||||||
zone_indexer: Arc::new(zone_indexer),
|
zone_indexer: Arc::new(zone_indexer),
|
||||||
|
store: IndexerStore::open_db(&home, genesis_seed)?,
|
||||||
node,
|
node,
|
||||||
config,
|
config,
|
||||||
store: IndexerStore::open_db(&home)?,
|
|
||||||
status: Arc::new(ArcSwap::from_pointee(IndexerSyncStatus::starting())),
|
status: Arc::new(ArcSwap::from_pointee(IndexerSyncStatus::starting())),
|
||||||
|
verifier,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -226,6 +254,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 {
|
match self.store.accept_block(&block, slot).await {
|
||||||
Ok(AcceptOutcome::Applied) => {
|
Ok(AcceptOutcome::Applied) => {
|
||||||
retry_gate.reset();
|
retry_gate.reset();
|
||||||
|
|||||||
@ -54,6 +54,36 @@ name = "vault"
|
|||||||
path = "vault/src/main.rs"
|
path = "vault/src/main.rs"
|
||||||
required-features = ["programs"]
|
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]
|
[features]
|
||||||
# TODO: Uncomment once https://github.com/risc0/risc0/issues/3772 is resolved.
|
# TODO: Uncomment once https://github.com/risc0/risc0/issues/3772 is resolved.
|
||||||
# default = ["artifacts"]
|
# default = ["artifacts"]
|
||||||
@ -78,6 +108,11 @@ programs = [
|
|||||||
"dep:faucet_core",
|
"dep:faucet_core",
|
||||||
"dep:token_core",
|
"dep:token_core",
|
||||||
"dep:vault_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]
|
[dependencies]
|
||||||
@ -92,6 +127,11 @@ clock_core = { workspace = true, optional = true }
|
|||||||
faucet_core = { workspace = true, optional = true }
|
faucet_core = { workspace = true, optional = true }
|
||||||
token_core = { workspace = true, optional = true }
|
token_core = { workspace = true, optional = true }
|
||||||
vault_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 }
|
amm_program = { path = "amm", optional = true }
|
||||||
associated_token_account_program = { path = "associated_token_account", 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::{
|
use guests::{
|
||||||
AMM_ELF, AMM_ID, ASSOCIATED_TOKEN_ACCOUNT_ELF, ASSOCIATED_TOKEN_ACCOUNT_ID,
|
AMM_ELF, AMM_ID, ASSOCIATED_TOKEN_ACCOUNT_ELF, ASSOCIATED_TOKEN_ACCOUNT_ID,
|
||||||
AUTHENTICATED_TRANSFER_ELF, AUTHENTICATED_TRANSFER_ID, BRIDGE_ELF, BRIDGE_ID, CLOCK_ELF,
|
AUTHENTICATED_TRANSFER_ELF, AUTHENTICATED_TRANSFER_ID, BRIDGE_ELF, BRIDGE_ID,
|
||||||
CLOCK_ID, FAUCET_ELF, FAUCET_ID, PINATA_ELF, PINATA_ID, PINATA_TOKEN_ELF, PINATA_TOKEN_ID,
|
BRIDGE_LOCK_ELF, BRIDGE_LOCK_ID, CLOCK_ELF, CLOCK_ID, CROSS_ZONE_INBOX_ELF,
|
||||||
TOKEN_ELF, TOKEN_ID, VAULT_ELF, VAULT_ID,
|
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;
|
use lee::program::Program;
|
||||||
|
|
||||||
@ -87,6 +90,42 @@ mod inner {
|
|||||||
Program::new_unchecked(BRIDGE_ID, Cow::Borrowed(BRIDGE_ELF))
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@ -127,6 +166,12 @@ mod inner {
|
|||||||
(PINATA_TOKEN_ELF, PINATA_TOKEN_ID),
|
(PINATA_TOKEN_ELF, PINATA_TOKEN_ID),
|
||||||
(TOKEN_ELF, TOKEN_ID),
|
(TOKEN_ELF, TOKEN_ID),
|
||||||
(VAULT_ELF, VAULT_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 {
|
for (elf, expected_id) in cases {
|
||||||
let program = Program::new((*elf).into()).unwrap();
|
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();
|
||||||
|
}
|
||||||
@ -21,6 +21,8 @@ bridge_core.workspace = true
|
|||||||
vault_core.workspace = true
|
vault_core.workspace = true
|
||||||
programs.workspace = true
|
programs.workspace = true
|
||||||
system_accounts.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-key-management-system-service.workspace = true
|
||||||
logos-blockchain-core.workspace = true
|
logos-blockchain-core.workspace = true
|
||||||
|
|||||||
@ -8,6 +8,7 @@ use std::{
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use bytesize::ByteSize;
|
use bytesize::ByteSize;
|
||||||
use common::config::BasicAuth;
|
use common::config::BasicAuth;
|
||||||
|
pub use cross_zone_inbox_core::{CrossZoneConfig, CrossZonePeer};
|
||||||
use humantime_serde;
|
use humantime_serde;
|
||||||
use lee::AccountId;
|
use lee::AccountId;
|
||||||
use logos_blockchain_core::mantle::ops::channel::ChannelId;
|
use logos_blockchain_core::mantle::ops::channel::ChannelId;
|
||||||
@ -25,6 +26,11 @@ pub enum GenesisAction {
|
|||||||
SupplyBridgeAccount {
|
SupplyBridgeAccount {
|
||||||
balance: u128,
|
balance: u128,
|
||||||
},
|
},
|
||||||
|
/// Seeds a bridge-lock holder's initial bridgeable balance into genesis state.
|
||||||
|
SupplyBridgeLockHolding {
|
||||||
|
holder: AccountId,
|
||||||
|
amount: u128,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Provide default values
|
// TODO: Provide default values
|
||||||
@ -53,6 +59,9 @@ pub struct SequencerConfig {
|
|||||||
/// Genesis configuration.
|
/// Genesis configuration.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub genesis: Vec<GenesisAction>,
|
pub genesis: Vec<GenesisAction>,
|
||||||
|
/// Cross-zone messaging configuration. `None` disables the watcher.
|
||||||
|
#[serde(default)]
|
||||||
|
pub cross_zone: Option<CrossZoneConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Serialize, Deserialize)]
|
#[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}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -40,6 +40,7 @@ use crate::{
|
|||||||
pub mod block_publisher;
|
pub mod block_publisher;
|
||||||
pub mod block_store;
|
pub mod block_store;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod cross_zone_watcher;
|
||||||
|
|
||||||
#[cfg(feature = "mock")]
|
#[cfg(feature = "mock")]
|
||||||
pub mod mock;
|
pub mod mock;
|
||||||
@ -199,6 +200,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 {
|
let sequencer_core = Self {
|
||||||
chain,
|
chain,
|
||||||
store,
|
store,
|
||||||
@ -752,14 +764,18 @@ fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec<LeeTrans
|
|||||||
let genesis_txs = config
|
let genesis_txs = config
|
||||||
.genesis
|
.genesis
|
||||||
.iter()
|
.iter()
|
||||||
.map(|genesis_tx| match genesis_tx {
|
.filter_map(|genesis_tx| match genesis_tx {
|
||||||
GenesisAction::SupplyAccount {
|
GenesisAction::SupplyAccount {
|
||||||
account_id,
|
account_id,
|
||||||
balance,
|
balance,
|
||||||
} => build_supply_account_genesis_transaction(account_id, *balance),
|
} => Some(build_supply_account_genesis_transaction(
|
||||||
|
account_id, *balance,
|
||||||
|
)),
|
||||||
GenesisAction::SupplyBridgeAccount { 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)))
|
.chain(std::iter::once(clock_invocation(0)))
|
||||||
.inspect(|tx| {
|
.inspect(|tx| {
|
||||||
@ -770,9 +786,36 @@ fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec<LeeTrans
|
|||||||
.map(LeeTransaction::Public)
|
.map(LeeTransaction::Public)
|
||||||
.collect();
|
.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)
|
(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(
|
fn build_supply_account_genesis_transaction(
|
||||||
account_id: &AccountId,
|
account_id: &AccountId,
|
||||||
balance: u128,
|
balance: u128,
|
||||||
|
|||||||
@ -33,6 +33,7 @@ use crate::{
|
|||||||
block_store::SequencerStore,
|
block_store::SequencerStore,
|
||||||
build_genesis_state,
|
build_genesis_state,
|
||||||
config::{BedrockConfig, SequencerConfig},
|
config::{BedrockConfig, SequencerConfig},
|
||||||
|
is_sequencer_only_program,
|
||||||
mock::SequencerCoreWithMockClients,
|
mock::SequencerCoreWithMockClients,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -59,9 +60,21 @@ fn setup_sequencer_config() -> SequencerConfig {
|
|||||||
},
|
},
|
||||||
retry_pending_blocks_timeout: Duration::from_mins(4),
|
retry_pending_blocks_timeout: Duration::from_mins(4),
|
||||||
genesis: vec![],
|
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 {
|
fn create_signing_key_for_account1() -> lee::PrivateKey {
|
||||||
initial_pub_accounts_private_keys()[0].pub_sign_key.clone()
|
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
|
self.mempool_handle
|
||||||
.push((TransactionOrigin::User, authenticated_tx))
|
.push((TransactionOrigin::User, authenticated_tx))
|
||||||
.await
|
.await
|
||||||
|
|||||||
@ -10,6 +10,7 @@ lee.workspace = true
|
|||||||
lee_core.workspace = true
|
lee_core.workspace = true
|
||||||
system_accounts.workspace = true
|
system_accounts.workspace = true
|
||||||
programs.workspace = true
|
programs.workspace = true
|
||||||
|
wrapped_token_core.workspace = true
|
||||||
|
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
|
|
||||||
|
|||||||
@ -258,9 +258,30 @@ fn initial_public_accounts() -> HashMap<AccountId, Account> {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|clock_id| (clock_id, system_accounts::clock_account())),
|
.map(|clock_id| (clock_id, system_accounts::clock_account())),
|
||||||
)
|
)
|
||||||
|
.chain(std::iter::once(wrapped_token_config_account()))
|
||||||
.collect()
|
.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> {
|
fn initial_programs() -> Vec<Program> {
|
||||||
vec![
|
vec![
|
||||||
programs::authenticated_transfer(),
|
programs::authenticated_transfer(),
|
||||||
@ -271,6 +292,12 @@ fn initial_programs() -> Vec<Program> {
|
|||||||
programs::vault(),
|
programs::vault(),
|
||||||
programs::faucet(),
|
programs::faucet(),
|
||||||
programs::bridge(),
|
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(),
|
config::SequencerPartialConfig::default(),
|
||||||
bedrock_addr,
|
bedrock_addr,
|
||||||
genesis,
|
genesis,
|
||||||
|
config::bedrock_channel_id(),
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.context("Failed to setup Sequencer for fixture generation")?;
|
.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 key_protocol::key_management::{KeyChain, secret_holders::SeedHolder};
|
||||||
use lee::{AccountId, PrivateKey, PublicKey};
|
use lee::{AccountId, PrivateKey, PublicKey};
|
||||||
use lee_core::Identifier;
|
use lee_core::Identifier;
|
||||||
use sequencer_core::config::{BedrockConfig, GenesisAction, SequencerConfig};
|
use sequencer_core::config::{BedrockConfig, CrossZoneConfig, GenesisAction, SequencerConfig};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
use wallet::config::WalletConfig;
|
use wallet::config::WalletConfig;
|
||||||
|
|
||||||
@ -80,6 +80,8 @@ pub fn sequencer_config(
|
|||||||
home: PathBuf,
|
home: PathBuf,
|
||||||
bedrock_addr: SocketAddr,
|
bedrock_addr: SocketAddr,
|
||||||
genesis_transactions: Vec<GenesisAction>,
|
genesis_transactions: Vec<GenesisAction>,
|
||||||
|
channel_id: ChannelId,
|
||||||
|
cross_zone: Option<CrossZoneConfig>,
|
||||||
) -> Result<SequencerConfig> {
|
) -> Result<SequencerConfig> {
|
||||||
let SequencerPartialConfig {
|
let SequencerPartialConfig {
|
||||||
max_num_tx_in_block,
|
max_num_tx_in_block,
|
||||||
@ -98,11 +100,12 @@ pub fn sequencer_config(
|
|||||||
genesis: genesis_transactions,
|
genesis: genesis_transactions,
|
||||||
signing_key: SEQUENCER_SIGNING_KEY,
|
signing_key: SEQUENCER_SIGNING_KEY,
|
||||||
bedrock_config: BedrockConfig {
|
bedrock_config: BedrockConfig {
|
||||||
channel_id: bedrock_channel_id(),
|
channel_id,
|
||||||
node_url: addr_to_url(UrlProtocol::Http, bedrock_addr)
|
node_url: addr_to_url(UrlProtocol::Http, bedrock_addr)
|
||||||
.context("Failed to convert bedrock addr to URL")?,
|
.context("Failed to convert bedrock addr to URL")?,
|
||||||
auth: None,
|
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 {
|
Ok(IndexerConfig {
|
||||||
consensus_info_polling_interval: Duration::from_secs(1),
|
consensus_info_polling_interval: Duration::from_secs(1),
|
||||||
bedrock_config: ClientConfig {
|
bedrock_config: ClientConfig {
|
||||||
@ -209,7 +216,9 @@ pub fn indexer_config(bedrock_addr: SocketAddr) -> Result<IndexerConfig> {
|
|||||||
.context("Failed to convert bedrock addr to URL")?,
|
.context("Failed to convert bedrock addr to URL")?,
|
||||||
auth: None,
|
auth: None,
|
||||||
},
|
},
|
||||||
channel_id: bedrock_channel_id(),
|
channel_id,
|
||||||
|
cross_zone,
|
||||||
|
bridge_lock_holdings: Vec::new(),
|
||||||
allow_chain_reset: false,
|
allow_chain_reset: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -235,3 +244,14 @@ pub fn bedrock_channel_id() -> ChannelId {
|
|||||||
.unwrap_or_else(|_| unreachable!());
|
.unwrap_or_else(|_| unreachable!());
|
||||||
ChannelId::from(channel_id)
|
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")?;
|
.context("Failed to setup Bedrock node")?;
|
||||||
|
|
||||||
let indexer_components = if enable_indexer {
|
let indexer_components = if enable_indexer {
|
||||||
let (indexer_handle, temp_indexer_dir) = setup_indexer(bedrock_addr)
|
let (indexer_handle, temp_indexer_dir) =
|
||||||
.await
|
setup_indexer(bedrock_addr, config::bedrock_channel_id(), None)
|
||||||
.context("Failed to setup Indexer")?;
|
.await
|
||||||
|
.context("Failed to setup Indexer")?;
|
||||||
let indexer_url = config::addr_to_url(config::UrlProtocol::Ws, indexer_handle.addr())
|
let indexer_url = config::addr_to_url(config::UrlProtocol::Ws, indexer_handle.addr())
|
||||||
.context("Failed to convert indexer addr to URL")?;
|
.context("Failed to convert indexer addr to URL")?;
|
||||||
let indexer_client = IndexerClient::new(&indexer_url)
|
let indexer_client = IndexerClient::new(&indexer_url)
|
||||||
@ -375,9 +376,15 @@ impl TestContextBuilder {
|
|||||||
}
|
}
|
||||||
None => wallet_genesis,
|
None => wallet_genesis,
|
||||||
};
|
};
|
||||||
setup_sequencer(partial_config, bedrock_addr, genesis)
|
setup_sequencer(
|
||||||
.await
|
partial_config,
|
||||||
.context("Failed to setup Sequencer")?
|
bedrock_addr,
|
||||||
|
genesis,
|
||||||
|
config::bedrock_channel_id(),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.context("Failed to setup Sequencer")?
|
||||||
};
|
};
|
||||||
|
|
||||||
let (mut wallet, temp_wallet_dir, wallet_password) = setup_wallet(
|
let (mut wallet, temp_wallet_dir, wallet_password) = setup_wallet(
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
use std::{net::SocketAddr, path::PathBuf};
|
use std::{net::SocketAddr, path::PathBuf};
|
||||||
|
|
||||||
use anyhow::{Context as _, Result, bail};
|
use anyhow::{Context as _, Result, bail};
|
||||||
use indexer_service::IndexerHandle;
|
use indexer_service::{ChannelId, IndexerHandle};
|
||||||
use lee::{AccountId, PrivateKey, PublicKey};
|
use lee::{AccountId, PrivateKey, PublicKey};
|
||||||
use log::{debug, warn};
|
use log::{debug, warn};
|
||||||
use sequencer_core::block_store::{DbDump, SequencerStore};
|
use sequencer_core::block_store::{DbDump, SequencerStore};
|
||||||
@ -112,7 +112,11 @@ pub async fn setup_bedrock_node() -> Result<(DockerCompose, SocketAddr)> {
|
|||||||
Ok((compose, addr))
|
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 =
|
let temp_indexer_dir =
|
||||||
tempfile::tempdir().context("Failed to create temp dir for indexer home")?;
|
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()
|
temp_indexer_dir.path().display()
|
||||||
);
|
);
|
||||||
|
|
||||||
let indexer_config =
|
let indexer_config = config::indexer_config(bedrock_addr, channel_id, cross_zone)
|
||||||
config::indexer_config(bedrock_addr).context("Failed to create Indexer config")?;
|
.context("Failed to create Indexer config")?;
|
||||||
|
|
||||||
indexer_service::run_server(
|
indexer_service::run_server(
|
||||||
indexer_config,
|
indexer_config,
|
||||||
@ -139,11 +143,15 @@ pub async fn setup_sequencer(
|
|||||||
partial: config::SequencerPartialConfig,
|
partial: config::SequencerPartialConfig,
|
||||||
bedrock_addr: SocketAddr,
|
bedrock_addr: SocketAddr,
|
||||||
genesis_transactions: Vec<GenesisAction>,
|
genesis_transactions: Vec<GenesisAction>,
|
||||||
|
channel_id: ChannelId,
|
||||||
|
cross_zone: Option<sequencer_core::config::CrossZoneConfig>,
|
||||||
) -> Result<(SequencerHandle, TempDir)> {
|
) -> Result<(SequencerHandle, TempDir)> {
|
||||||
setup_sequencer_inner(
|
setup_sequencer_inner(
|
||||||
partial,
|
partial,
|
||||||
bedrock_addr,
|
bedrock_addr,
|
||||||
SequencerInit::Genesis(genesis_transactions),
|
SequencerInit::Genesis(genesis_transactions),
|
||||||
|
channel_id,
|
||||||
|
cross_zone,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@ -155,13 +163,22 @@ pub async fn setup_sequencer_from_prebuilt(
|
|||||||
bedrock_addr: SocketAddr,
|
bedrock_addr: SocketAddr,
|
||||||
) -> Result<(SequencerHandle, TempDir)> {
|
) -> Result<(SequencerHandle, TempDir)> {
|
||||||
let dump = load_prebuilt_dump()?;
|
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(
|
async fn setup_sequencer_inner(
|
||||||
partial: config::SequencerPartialConfig,
|
partial: config::SequencerPartialConfig,
|
||||||
bedrock_addr: SocketAddr,
|
bedrock_addr: SocketAddr,
|
||||||
init: SequencerInit<'_>,
|
init: SequencerInit<'_>,
|
||||||
|
channel_id: ChannelId,
|
||||||
|
cross_zone: Option<sequencer_core::config::CrossZoneConfig>,
|
||||||
) -> Result<(SequencerHandle, TempDir)> {
|
) -> Result<(SequencerHandle, TempDir)> {
|
||||||
let temp_sequencer_dir =
|
let temp_sequencer_dir =
|
||||||
tempfile::tempdir().context("Failed to create temp dir for sequencer home")?;
|
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(),
|
temp_sequencer_dir.path().to_owned(),
|
||||||
bedrock_addr,
|
bedrock_addr,
|
||||||
genesis_transactions,
|
genesis_transactions,
|
||||||
|
channel_id,
|
||||||
|
cross_zone,
|
||||||
)
|
)
|
||||||
.context("Failed to create Sequencer config")?;
|
.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