Merge remote-tracking branch 'origin/dev' into artem/change-wallet-balance-fetch

This commit is contained in:
agureev 2026-07-13 15:19:06 +04:00
commit ae391612bd
136 changed files with 8446 additions and 599 deletions

View File

@ -135,7 +135,37 @@ jobs:
env:
RISC0_DEV_MODE: "1"
RUST_LOG: "info"
run: cargo nextest run --workspace --exclude integration_tests --all-features
run: cargo nextest run --workspace --exclude integration_tests --exclude test_fixtures --all-features
test-fixtures-tests:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v5
with:
ref: ${{ github.event.pull_request.head.sha || github.head_ref }}
- uses: ./.github/actions/install-system-deps
- uses: ./.github/actions/install-risc0
- name: Install active toolchain
run: rustup install
- name: Restore Rust cache
uses: Swatinem/rust-cache@v2
with:
shared-key: ci-rust-cache
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' }}
- name: Install nextest
run: cargo install --locked cargo-nextest
- name: Run test_fixtures tests
env:
RISC0_DEV_MODE: "1"
RUST_LOG: "info"
run: cargo nextest run -p test_fixtures
integration-tests-prebuild:
runs-on: ubuntu-latest
@ -193,7 +223,7 @@ jobs:
echo "Discovered integration targets: $targets_json"
integration-tests:
needs: integration-tests-prebuild
needs: [test-fixtures-tests, integration-tests-prebuild]
runs-on: ubuntu-latest
timeout-minutes: 90
strategy:

192
Cargo.lock generated
View File

@ -1154,6 +1154,25 @@ dependencies = [
"serde",
]
[[package]]
name = "bridge_lock_core"
version = "0.1.0"
dependencies = [
"lee_core",
"serde",
]
[[package]]
name = "bridge_lock_program"
version = "0.1.0"
dependencies = [
"bridge_lock_core",
"cross_zone_outbox_core",
"lee_core",
"risc0-zkvm",
"wrapped_token_core",
]
[[package]]
name = "bridge_program"
version = "0.1.0"
@ -1833,6 +1852,76 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
[[package]]
name = "cross_zone"
version = "0.1.0"
dependencies = [
"bridge_lock_core",
"cross_zone_inbox_core",
"lee",
"lee_core",
"ping_core",
"programs",
"risc0-zkvm",
]
[[package]]
name = "cross_zone_chat"
version = "0.1.0"
dependencies = [
"anyhow",
"axum 0.8.9",
"common",
"cross_zone_inbox_core",
"cross_zone_outbox_core",
"env_logger",
"lee",
"log",
"ping_core",
"programs",
"risc0-zkvm",
"sequencer_service_rpc",
"serde",
"test_fixtures",
"tokio",
]
[[package]]
name = "cross_zone_inbox_core"
version = "0.1.0"
dependencies = [
"borsh",
"lee_core",
"risc0-zkvm",
"serde",
]
[[package]]
name = "cross_zone_inbox_program"
version = "0.1.0"
dependencies = [
"cross_zone_inbox_core",
"lee_core",
]
[[package]]
name = "cross_zone_outbox_core"
version = "0.1.0"
dependencies = [
"borsh",
"lee_core",
"risc0-zkvm",
"serde",
]
[[package]]
name = "cross_zone_outbox_program"
version = "0.1.0"
dependencies = [
"cross_zone_outbox_core",
"lee_core",
]
[[package]]
name = "crossbeam-channel"
version = "0.5.15"
@ -1854,9 +1943,9 @@ dependencies = [
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
version = "0.9.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
dependencies = [
"crossbeam-utils",
]
@ -2076,7 +2165,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090"
dependencies = [
"data-encoding",
"syn 1.0.109",
"syn 2.0.117",
]
[[package]]
@ -3851,18 +3940,25 @@ dependencies = [
"async-stream",
"borsh",
"common",
"cross_zone",
"cross_zone_inbox_core",
"futures",
"hex",
"humantime-serde",
"lee",
"lee_core",
"log",
"logos-blockchain-core",
"logos-blockchain-zone-sdk",
"ping_core",
"programs",
"risc0-zkvm",
"serde",
"serde_json",
"storage",
"tempfile",
"testnet_initial_state",
"thiserror 2.0.18",
"tokio",
"url",
]
@ -3910,6 +4006,7 @@ dependencies = [
"base64",
"common",
"hex",
"indexer_core",
"lee",
"lee_core",
"schemars 1.2.1",
@ -4010,8 +4107,11 @@ dependencies = [
"authenticated_transfer_core",
"borsh",
"bridge_core",
"bridge_lock_core",
"bytesize",
"common",
"cross_zone_inbox_core",
"cross_zone_outbox_core",
"faucet_core",
"futures",
"hex",
@ -4027,8 +4127,10 @@ dependencies = [
"logos-blockchain-key-management-system-service",
"logos-blockchain-zone-sdk",
"num-bigint 0.4.6",
"ping_core",
"programs",
"reqwest",
"risc0-zkvm",
"sequencer_core",
"sequencer_service_rpc",
"serde_json",
@ -4041,6 +4143,7 @@ dependencies = [
"vault_core",
"wallet",
"wallet-ffi",
"wrapped_token_core",
]
[[package]]
@ -7173,6 +7276,31 @@ dependencies = [
"token_core",
]
[[package]]
name = "ping_core"
version = "0.1.0"
dependencies = [
"lee_core",
"serde",
]
[[package]]
name = "ping_receiver_program"
version = "0.1.0"
dependencies = [
"lee_core",
"ping_core",
]
[[package]]
name = "ping_sender_program"
version = "0.1.0"
dependencies = [
"cross_zone_outbox_core",
"lee_core",
"ping_core",
]
[[package]]
name = "pkcs1"
version = "0.7.5"
@ -7445,15 +7573,20 @@ dependencies = [
"associated_token_account_program",
"authenticated_transfer_core",
"bridge_core",
"bridge_lock_core",
"build_utils",
"clock_core",
"cross_zone_inbox_core",
"cross_zone_outbox_core",
"faucet_core",
"lee",
"lee_core",
"ping_core",
"risc0-zkvm",
"token_core",
"token_program",
"vault_core",
"wrapped_token_core",
]
[[package]]
@ -8892,10 +9025,13 @@ dependencies = [
"bytesize",
"chrono",
"common",
"cross_zone",
"cross_zone_inbox_core",
"faucet_core",
"futures",
"hex",
"humantime-serde",
"itertools 0.14.0",
"key_protocol",
"lee",
"lee_core",
@ -9457,11 +9593,13 @@ dependencies = [
"borsh",
"common",
"lee",
"log",
"programs",
"rocksdb",
"system_accounts",
"tempfile",
"thiserror 2.0.18",
"zstd",
]
[[package]]
@ -9735,6 +9873,7 @@ name = "test_fixtures"
version = "0.1.0"
dependencies = [
"anyhow",
"bip39",
"bytesize",
"common",
"env_logger",
@ -9754,6 +9893,7 @@ dependencies = [
"tempfile",
"testcontainers",
"tokio",
"tokio-util",
"url",
"wallet",
]
@ -9836,6 +9976,7 @@ dependencies = [
"programs",
"serde",
"system_accounts",
"wrapped_token_core",
]
[[package]]
@ -11545,6 +11686,23 @@ dependencies = [
"wasmparser",
]
[[package]]
name = "wrapped_token_core"
version = "0.1.0"
dependencies = [
"lee_core",
"risc0-zkvm",
"serde",
]
[[package]]
name = "wrapped_token_program"
version = "0.1.0"
dependencies = [
"lee_core",
"wrapped_token_core",
]
[[package]]
name = "writeable"
version = "0.6.3"
@ -11797,3 +11955,31 @@ dependencies = [
"log",
"simd-adler32",
]
[[package]]
name = "zstd"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
dependencies = [
"zstd-safe",
]
[[package]]
name = "zstd-safe"
version = "7.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
dependencies = [
"zstd-sys",
]
[[package]]
name = "zstd-sys"
version = "2.0.16+zstd.1.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
dependencies = [
"cc",
"pkg-config",
]

View File

@ -43,6 +43,13 @@ members = [
"lez/programs/pinata_token",
"lez/programs/token",
"lez/programs/vault",
"lez/programs/cross_zone_inbox",
"lez/programs/cross_zone_outbox",
"lez/programs/bridge_lock",
"lez/programs/wrapped_token",
"lez/programs/ping_sender",
"lez/programs/ping_receiver",
"lez/cross_zone",
"test_programs",
"test_programs/guest",
@ -58,6 +65,7 @@ members = [
"tools/cycle_bench",
"tools/crypto_primitives_bench",
"tools/integration_bench",
"tools/cross_zone_chat",
]
[workspace.dependencies]
@ -94,6 +102,12 @@ authenticated_transfer_core = { path = "lez/programs/authenticated_transfer/core
faucet_core = { path = "lez/programs/faucet/core" }
bridge_core = { path = "lez/programs/bridge/core" }
vault_core = { path = "lez/programs/vault/core" }
cross_zone_inbox_core = { path = "lez/programs/cross_zone_inbox/core" }
cross_zone_outbox_core = { path = "lez/programs/cross_zone_outbox/core" }
bridge_lock_core = { path = "lez/programs/bridge_lock/core" }
wrapped_token_core = { path = "lez/programs/wrapped_token/core" }
ping_core = { path = "lez/programs/ping_core" }
cross_zone = { path = "lez/cross_zone" }
build_utils = { path = "build_utils" }
test_programs = { path = "test_programs" }
testnet_initial_state = { path = "lez/testnet_initial_state" }
@ -147,6 +161,7 @@ bip39 = "2.2.0"
hmac-sha512 = "1.1.7"
chrono = "0.4.41"
borsh = "1.5.7"
zstd = "0.13"
base58 = "0.2.0"
itertools = "0.14.0"
num-bigint = "0.4.6"
@ -181,6 +196,7 @@ elliptic-curve = { version = "0.13.8", features = ["arithmetic"] }
actix-web = { version = "4.13.0", default-features = false, features = [
"macros",
] }
axum = "0.8.4"
clap = { version = "4.5.42", features = ["derive", "env"] }
reqwest = { version = "0.12", features = ["json", "rustls-tls", "stream"] }
pyo3 = { version = "0.29", features = ["auto-initialize"] }
@ -343,4 +359,4 @@ clippy.cargo = { level = "deny", priority = -1 }
clippy.cargo-common-metadata = "allow"
# Reason: hard to address right now and mostly comes from dependencies
# so the fix would be just a long list of exceptions.
clippy.multiple-crate-versions = "allow"
clippy.multiple-crate-versions = "allow"

View File

@ -6,6 +6,11 @@ default:
# ---- Configuration ----
ARTIFACTS := "artifacts"
# On macOS the integration-test binary links pyo3 against the CommandLineTools
# Python framework with no embedded rpath, so it needs this to launch. Empty on
# Linux/CI, which is unaffected.
DEMO_ENV := if os() == "macos" { "DYLD_FALLBACK_FRAMEWORK_PATH=/Library/Developer/CommandLineTools/Library/Frameworks" } else { "" }
# Build risc0 program artifacts.
build-artifacts:
@echo "🔨 Building artifacts"
@ -35,6 +40,11 @@ test:
@echo "🧪 Running tests"
RISC0_DEV_MODE=1 cargo nextest run --no-fail-fast
# Regenerate the prebuilt sequencer db dump for fast TestContext::new() (needs Docker; commit the dump).
regenerate-test-fixture:
@echo "🧪 Regenerating test fixtures"
RISC0_DEV_MODE=1 cargo run -p test_fixtures --bin regenerate_test_fixture
# Run criterion benches: fast crypto primitives, then the slow PPE verify (real proving setup).
bench:
@echo "📊 Running criterion benches"
@ -94,6 +104,26 @@ wallet-import-test-accounts:
just run-wallet account list
# Demo: cross-zone ping. Boots two zones on one Bedrock and sends a message from
# zone A to zone B, where the indexer re-derives and verifies it (Option B)
# before ping_receiver records it. Dev mode, no proving.
demo-cross-zone-ping:
@echo "📡 Cross-zone ping demo (message A → B, indexer-verified)"
{{DEMO_ENV}} RISC0_DEV_MODE=1 cargo test -p integration_tests --release --test cross_zone_verified -- --nocapture
# Demo: cross-zone wrapped-token bridge. Locks a balance on zone A and mints the
# wrapped token to a recipient on zone B over the same verified spine.
demo-cross-zone-bridge:
@echo "🌉 Cross-zone bridge demo (lock on A, mint on B)"
{{DEMO_ENV}} RISC0_DEV_MODE=1 cargo test -p integration_tests --release --test cross_zone_bridge -- --nocapture
# Demo: interactive cross-zone chat. Boots two zones on one Bedrock and serves a
# local two-column web UI; type in one zone and watch the message cross into the
# other. Two people can chat across the zones. Dev mode, no proving.
cross-zone-chat:
@echo "💬 Cross-zone chat demo — open the printed localhost URL"
{{DEMO_ENV}} RISC0_DEV_MODE=1 cargo run -p cross_zone_chat --release
# Clean runtime data
clean:
@echo "🧹 Cleaning run artifacts"

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -22,6 +22,12 @@ associated_token_account_core.workspace = true
vault_core.workspace = true
faucet_core.workspace = true
bridge_core.workspace = true
ping_core.workspace = true
cross_zone_outbox_core.workspace = true
cross_zone_inbox_core.workspace = true
bridge_lock_core.workspace = true
wrapped_token_core.workspace = true
risc0-zkvm.workspace = true
indexer_service_rpc = { workspace = true, features = ["client"] }
sequencer_service_rpc = { workspace = true, features = ["client"] }
wallet-ffi.workspace = true

View File

@ -69,8 +69,8 @@ async fn private_transfer_to_foreign_account() -> Result<()> {
});
let result = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
let SubcommandReturnValue::PrivacyPreservingTransfer { tx_hash } = result else {
anyhow::bail!("Expected PrivacyPreservingTransfer return value");
let SubcommandReturnValue::TransactionExecuted { tx_hash } = result else {
anyhow::bail!("Expected TransactionExecuted return value");
};
info!("Waiting for next block creation");
@ -158,8 +158,8 @@ async fn private_transfer_to_owned_account_using_claiming_path() -> Result<()> {
});
let sub_ret = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
let SubcommandReturnValue::PrivacyPreservingTransfer { tx_hash } = sub_ret else {
anyhow::bail!("Expected PrivacyPreservingTransfer return value");
let SubcommandReturnValue::TransactionExecuted { tx_hash } = sub_ret else {
anyhow::bail!("Expected TransactionExecuted return value");
};
let tx = fetch_privacy_preserving_tx(ctx.sequencer_client(), tx_hash).await;
@ -237,8 +237,8 @@ async fn shielded_transfer_to_foreign_account() -> Result<()> {
});
let result = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
let SubcommandReturnValue::PrivacyPreservingTransfer { tx_hash } = result else {
anyhow::bail!("Expected PrivacyPreservingTransfer return value");
let SubcommandReturnValue::TransactionExecuted { tx_hash } = result else {
anyhow::bail!("Expected TransactionExecuted return value");
};
info!("Waiting for next block creation");
@ -297,7 +297,7 @@ async fn private_transfer_to_owned_account_continuous_run_path() -> Result<()> {
});
let sub_ret = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
let SubcommandReturnValue::PrivacyPreservingTransfer { tx_hash } = sub_ret else {
let SubcommandReturnValue::TransactionExecuted { tx_hash } = sub_ret else {
anyhow::bail!("Failed to send transaction");
};

View File

@ -0,0 +1,197 @@
#![expect(
clippy::tests_outside_test_module,
reason = "top-level test functions are conventional for integration tests"
)]
//! Demo 2: a wrapped-token bridge over the cross-zone spine. A holder locks part
//! of their bridgeable balance on zone A; the watcher carries the emitted mint to
//! zone B, where the indexer re-derives and verifies it (Option B) before the
//! wrapped token is minted to the recipient. Reuses the M3/M4 spine unchanged;
//! only the source caller (`bridge_lock`) and target (`wrapped_token`) are new.
use std::{net::SocketAddr, time::Duration};
use anyhow::{Context as _, Result};
use common::transaction::LeeTransaction;
use cross_zone_outbox_core::outbox_pda;
use integration_tests::{
config::{self, SequencerPartialConfig},
indexer_client::IndexerClient,
setup::{setup_bedrock_node, setup_indexer, setup_sequencer},
};
use lee::{
AccountId, PrivateKey, PublicKey, PublicTransaction,
public_transaction::{Message, WitnessSet},
};
use sequencer_core::config::{CrossZoneConfig, CrossZonePeer, GenesisAction};
use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder};
use tokio::test;
const DELIVERY_TIMEOUT: Duration = Duration::from_secs(600);
const INITIAL_BALANCE: u128 = 100;
const LOCK_AMOUNT: u128 = 30;
const RECIPIENT: [u8; 32] = [9; 32];
#[test]
async fn lock_on_zone_a_mints_wrapped_token_on_zone_b() -> Result<()> {
// Declared first so it outlives both zones (drops run in reverse order).
let (_bedrock, bedrock_addr) = setup_bedrock_node()
.await
.context("Failed to set up shared Bedrock node")?;
let partial = SequencerPartialConfig::default();
let channel_a = config::bedrock_channel_id();
let channel_b = config::bedrock_channel_id_b();
let zone_b: [u8; 32] = *channel_b.as_ref();
let holder_key = PrivateKey::try_new([7; 32]).expect("valid key");
let holder_id = AccountId::from(&PublicKey::new_from_private_key(&holder_key));
let wrapped_token_id = programs::wrapped_token().id();
let cross_zone = CrossZoneConfig {
peers: vec![CrossZonePeer {
channel_id: *channel_a.as_ref(),
allowed_targets: vec![wrapped_token_id],
expected_block_signing_pubkey: None,
}],
};
// Zone A seeds the holder's bridgeable balance. Zone B runs the watcher on its
// sequencer and the verifier on its indexer.
let genesis_a = vec![GenesisAction::SupplyBridgeLockHolding {
holder: holder_id,
amount: INITIAL_BALANCE,
}];
let (seq_a, _seq_a_home) = setup_sequencer(partial, bedrock_addr, genesis_a, channel_a, None)
.await
.context("Failed to set up zone A sequencer")?;
let (_seq_b, _seq_b_home) = setup_sequencer(
partial,
bedrock_addr,
vec![],
channel_b,
Some(cross_zone.clone()),
)
.await
.context("Failed to set up zone B sequencer")?;
let (idx_b, _idx_b_home) = setup_indexer(bedrock_addr, channel_b, Some(cross_zone))
.await
.context("Failed to set up zone B indexer")?;
// Lock LOCK_AMOUNT on zone A, addressed to the recipient on zone B.
let lock = build_lock_tx(&holder_key, holder_id, zone_b);
sequencer_client(seq_a.addr())?
.send_transaction(lock)
.await
.context("Failed to submit lock on zone A")?;
// Wait until zone B's indexer reflects the verified mint.
let holding_id = wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT);
let indexer_url = config::addr_to_url(config::UrlProtocol::Ws, idx_b.addr())
.context("Failed to build indexer URL")?;
let indexer = IndexerClient::new(&indexer_url)
.await
.context("Failed to build indexer client")?;
let minted = wait_for_mint(&indexer, holding_id).await?;
assert_eq!(
minted, LOCK_AMOUNT,
"zone B must mint exactly the locked amount"
);
// Conservation: the mint on B must be backed by an equal lock on A. The lock
// has already landed (it preceded delivery), so zone A reflects the debit and
// escrow now.
let seq_a_client = sequencer_client(seq_a.addr())?;
let escrow_id = bridge_lock_core::escrow_account_id(programs::bridge_lock().id());
let escrowed = bridge_lock_core::read_balance(
&seq_a_client.get_account(escrow_id).await?.data.into_inner(),
);
assert_eq!(
escrowed, LOCK_AMOUNT,
"zone A escrow must hold the locked amount"
);
let remaining = bridge_lock_core::read_balance(
&seq_a_client.get_account(holder_id).await?.data.into_inner(),
);
assert_eq!(
remaining,
INITIAL_BALANCE - LOCK_AMOUNT,
"zone A holder must be debited by the locked amount"
);
Ok(())
}
/// Builds a signed `bridge_lock` Lock that forwards a wrapped-token Mint of the
/// locked amount to the recipient on the target zone.
fn build_lock_tx(
holder_key: &PrivateKey,
holder_id: AccountId,
target_zone: [u8; 32],
) -> LeeTransaction {
let bridge_lock_id = programs::bridge_lock().id();
let wrapped_token_id = programs::wrapped_token().id();
let outbox_id = programs::cross_zone_outbox().id();
let ordinal = 0;
let mint = wrapped_token_core::Instruction::Mint {
recipient: RECIPIENT,
amount: LOCK_AMOUNT,
};
let words = risc0_zkvm::serde::to_vec(&mint).expect("serialize mint");
let payload: Vec<u8> = words.iter().flat_map(|word| word.to_le_bytes()).collect();
let target_accounts = vec![
wrapped_token_core::config_account_id(wrapped_token_id).into_value(),
wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT).into_value(),
];
let lock = bridge_lock_core::Instruction::Lock {
amount: LOCK_AMOUNT,
target_zone,
target_program_id: wrapped_token_id,
target_accounts,
payload,
outbox_program_id: outbox_id,
ordinal,
};
let accounts = vec![
holder_id,
bridge_lock_core::escrow_account_id(bridge_lock_id),
outbox_pda(outbox_id, &target_zone, ordinal),
];
// One nonce per signature: the holder signs, at its genesis nonce 0.
let message = Message::try_new(bridge_lock_id, accounts, vec![0_u128.into()], lock)
.expect("build lock message");
let witness = WitnessSet::for_message(&message, &[holder_key]);
LeeTransaction::Public(PublicTransaction::new(message, witness))
}
fn sequencer_client(addr: SocketAddr) -> Result<SequencerClient> {
let url = config::addr_to_url(config::UrlProtocol::Http, addr)
.context("Failed to build sequencer URL")?;
SequencerClientBuilder::default()
.build(url)
.context("Failed to build sequencer client")
}
/// Polls zone B's indexer until the recipient's wrapped holding is non-zero.
async fn wait_for_mint(indexer: &IndexerClient, holding_id: AccountId) -> Result<u128> {
let account_id = indexer_service_protocol::AccountId {
value: holding_id.into_value(),
};
let wait = async {
loop {
let account =
indexer_service_rpc::RpcClient::get_account(&**indexer, account_id).await?;
let balance = wrapped_token_core::read_balance(&account.data.0);
if balance != 0 {
return Ok::<u128, anyhow::Error>(balance);
}
tokio::time::sleep(Duration::from_secs(3)).await;
}
};
tokio::time::timeout(DELIVERY_TIMEOUT, wait)
.await
.context("Zone B's indexer did not mint the wrapped token in time")?
}

View File

@ -0,0 +1,80 @@
#![expect(
clippy::tests_outside_test_module,
reason = "We don't care about these in tests"
)]
//! M6 ingress guard: the cross-zone inbox is sequencer-only. Only the watcher
//! injects inbox dispatches; a user must not be able to invoke the inbox through
//! the public RPC, or anyone could forge an inbound cross-zone delivery. The
//! inbox guest's caller-is-none assertion passes for a top-level user tx, so the
//! sequencer ingress guard is the only thing that stops this.
use std::net::SocketAddr;
use anyhow::{Context as _, Result};
use common::transaction::LeeTransaction;
use cross_zone_inbox_core::{
CrossZoneMessage, Instruction, inbox_config_account_id, inbox_seen_shard_account_id,
};
use integration_tests::{
config::{self, SequencerPartialConfig},
setup::{setup_bedrock_node, setup_sequencer},
};
use lee::{
PublicTransaction,
public_transaction::{Message, WitnessSet},
};
use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder};
use tokio::test;
#[test]
async fn user_origin_inbox_call_rejected() -> Result<()> {
let (_bedrock, bedrock_addr) = setup_bedrock_node()
.await
.context("Failed to set up Bedrock node")?;
let partial = SequencerPartialConfig::default();
let channel = config::bedrock_channel_id();
let (seq, _seq_home) = setup_sequencer(partial, bedrock_addr, vec![], channel, None)
.await
.context("Failed to set up sequencer")?;
// A user hand-builds a top-level inbox Dispatch and submits it via RPC.
let inbox_id = programs::cross_zone_inbox().id();
let msg = CrossZoneMessage {
src_zone: [2; 32],
src_block_id: 1,
src_tx_index: 0,
src_program_id: [9; 8],
target_program_id: programs::ping_receiver().id(),
payload: vec![],
l1_inclusion_witness: None,
};
let seen_id = inbox_seen_shard_account_id(inbox_id, &msg.src_zone, msg.src_block_id);
let message = Message::try_new(
inbox_id,
vec![inbox_config_account_id(inbox_id), seen_id],
vec![],
Instruction::Dispatch(msg),
)
.expect("build dispatch message");
let tx = LeeTransaction::Public(PublicTransaction::new(
message,
WitnessSet::from_raw_parts(vec![]),
));
let result = sequencer_client(seq.addr())?.send_transaction(tx).await;
let err = result.expect_err("the sequencer must reject a user-origin inbox call");
assert!(
err.to_string().contains("sequencer-only"),
"rejection should cite the sequencer-only guard, got: {err}"
);
Ok(())
}
fn sequencer_client(addr: SocketAddr) -> Result<SequencerClient> {
let url = config::addr_to_url(config::UrlProtocol::Http, addr)
.context("Failed to build sequencer URL")?;
SequencerClientBuilder::default()
.build(url)
.context("Failed to build sequencer client")
}

View File

@ -0,0 +1,143 @@
#![expect(
clippy::tests_outside_test_module,
reason = "top-level test functions are conventional for integration tests"
)]
//! End-to-end cross-zone round trip: a ping submitted on zone A is delivered by
//! zone B's watcher to `ping_receiver` on zone B, which records the payload.
//!
//! Two sequencers share one Bedrock node (no indexers): zone A publishes the
//! ping to Bedrock, zone B's watcher reads zone A's finalized blocks, injects the
//! inbox dispatch, and zone B's sequencer delivers it. This is the M3 milestone,
//! sequencer-trusted, with no indexer re-derivation (that is M4).
use std::{net::SocketAddr, time::Duration};
use anyhow::{Context as _, Result};
use common::transaction::LeeTransaction;
use cross_zone_outbox_core::outbox_pda;
use integration_tests::{
config::{self, SequencerPartialConfig},
setup::{setup_bedrock_node, setup_sequencer},
};
use lee::{AccountId, PublicTransaction, public_transaction::Message};
use lee_core::program::ProgramId;
use ping_core::{ReceiverInstruction, SenderInstruction, ping_record_pda};
use sequencer_core::config::{CrossZoneConfig, CrossZonePeer};
use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder};
use tokio::test;
const DELIVERY_TIMEOUT: Duration = Duration::from_secs(480);
const PING_PAYLOAD: &[u8] = b"hello-cross-zone";
#[test]
async fn ping_crosses_from_zone_a_to_zone_b() -> Result<()> {
// Declared first so it outlives both zones (drops run in reverse order).
let (_bedrock, bedrock_addr) = setup_bedrock_node()
.await
.context("Failed to set up shared Bedrock node")?;
let partial = SequencerPartialConfig::default();
let channel_a = config::bedrock_channel_id();
let channel_b = config::bedrock_channel_id_b();
let zone_a: [u8; 32] = *channel_a.as_ref();
let zone_b: [u8; 32] = *channel_b.as_ref();
let receiver_id = programs::ping_receiver().id();
// Zone B watches zone A and allows delivery only to ping_receiver.
let cross_zone = CrossZoneConfig {
peers: vec![CrossZonePeer {
channel_id: zone_a,
allowed_targets: vec![receiver_id],
expected_block_signing_pubkey: None,
}],
};
let (seq_a, _seq_a_home) = setup_sequencer(partial, bedrock_addr, vec![], channel_a, None)
.await
.context("Failed to set up zone A sequencer")?;
let (seq_b, _seq_b_home) =
setup_sequencer(partial, bedrock_addr, vec![], channel_b, Some(cross_zone))
.await
.context("Failed to set up zone B sequencer")?;
// Submit the ping on zone A, addressed to ping_receiver on zone B.
let ping = build_ping_tx(zone_b, receiver_id);
sequencer_client(seq_a.addr())?
.send_transaction(ping)
.await
.context("Failed to submit ping on zone A")?;
// Wait until zone B's sequencer records the delivered payload.
let record_id = ping_record_pda(receiver_id);
let delivered = wait_for_delivery(sequencer_client(seq_b.addr())?, record_id).await?;
assert_eq!(
delivered, PING_PAYLOAD,
"Zone B must record the payload delivered from zone A"
);
Ok(())
}
/// Builds a top-level `ping_sender` transaction that chains into the outbox to emit
/// a message carrying a `ping_receiver::Record` instruction for the target zone.
fn build_ping_tx(target_zone: [u8; 32], receiver_id: ProgramId) -> LeeTransaction {
let outbox_id = programs::cross_zone_outbox().id();
let ordinal = 0;
// The payload is the ping_receiver instruction, serialized as risc0 words in
// little-endian bytes (the contract the inbox reverses when forwarding).
let words = risc0_zkvm::serde::to_vec(&ReceiverInstruction::Record {
payload: PING_PAYLOAD.to_vec(),
})
.expect("serialize ping instruction");
let payload: Vec<u8> = words.iter().flat_map(|word| word.to_le_bytes()).collect();
let send = SenderInstruction::Send {
outbox_program_id: outbox_id,
target_zone,
target_program_id: receiver_id,
target_accounts: vec![ping_record_pda(receiver_id).into_value()],
payload,
ordinal,
};
let outbox_account = outbox_pda(outbox_id, &target_zone, ordinal);
let message = Message::try_new(
programs::ping_sender().id(),
vec![outbox_account],
vec![],
send,
)
.expect("build ping message");
LeeTransaction::Public(PublicTransaction::new(
message,
lee::public_transaction::WitnessSet::from_raw_parts(vec![]),
))
}
fn sequencer_client(addr: SocketAddr) -> Result<SequencerClient> {
let url = config::addr_to_url(config::UrlProtocol::Http, addr)
.context("Failed to build sequencer URL")?;
SequencerClientBuilder::default()
.build(url)
.context("Failed to build sequencer client")
}
/// Polls zone B's sequencer until the ping record PDA holds a payload.
async fn wait_for_delivery(client: SequencerClient, record_id: AccountId) -> Result<Vec<u8>> {
let wait = async {
loop {
let account = client.get_account(record_id).await?;
let data = account.data.into_inner();
if !data.is_empty() {
return Ok::<Vec<u8>, anyhow::Error>(data);
}
tokio::time::sleep(Duration::from_secs(3)).await;
}
};
tokio::time::timeout(DELIVERY_TIMEOUT, wait)
.await
.context("Zone B did not record the cross-zone payload in time")?
}

View File

@ -0,0 +1,373 @@
#![expect(
clippy::tests_outside_test_module,
reason = "top-level test functions are conventional for integration tests"
)]
//! Single-zone state-machine tests for cross-zone delivery (ping demo) and the
//! wrapped-token bridge (Demo 2). They drive the guests in isolation, no watcher
//! or Bedrock: a hand-built `cross_zone_inbox::Dispatch` (as the watcher would
//! inject) and the source `bridge_lock::Lock` (which escrows and chains
//! `outbox::Emit`). Fast, so they pin guest logic before the e2e exercises the
//! plumbing. Run with `RISC0_DEV_MODE=1`.
use std::collections::BTreeMap;
use cross_zone_inbox_core::{
CrossZoneMessage, InboxConfig, Instruction as InboxInstruction, SeenShard,
inbox_config_account_id, inbox_seen_shard_account_id, message_key,
};
use cross_zone_outbox_core::{OutboxRecord, outbox_pda};
use lee::{
AccountId, PrivateKey, PublicKey, PublicTransaction, V03State, ValidatedStateDiff,
public_transaction::{Message, WitnessSet},
};
use lee_core::account::Account;
use ping_core::{ReceiverInstruction, ping_record_pda};
const INITIAL_BALANCE: u128 = 100;
const LOCK_AMOUNT: u128 = 30;
const RECIPIENT: [u8; 32] = [9; 32];
/// State registering the cross-zone builtins these tests exercise.
fn base_state() -> V03State {
V03State::new().with_programs([
programs::cross_zone_inbox(),
programs::cross_zone_outbox(),
programs::ping_receiver(),
programs::bridge_lock(),
programs::wrapped_token(),
])
}
/// Seeds an inbox config (inbox-owned) allowing `src_zone -> target`.
fn seed_inbox_config(
state: &mut V03State,
self_zone: [u8; 32],
src_zone: [u8; 32],
target: lee_core::program::ProgramId,
) {
let inbox_id = programs::cross_zone_inbox().id();
let mut allowed_targets = BTreeMap::new();
allowed_targets.insert(src_zone, vec![target]);
let config = InboxConfig {
self_zone,
allowed_peers: BTreeMap::new(),
allowed_targets,
};
state.insert_genesis_account(
inbox_config_account_id(inbox_id),
Account {
program_owner: inbox_id,
balance: 0,
data: config
.to_bytes()
.try_into()
.expect("config fits in account data"),
nonce: 0_u128.into(),
},
);
}
/// Seeds the wrapped-token config account pinning the inbox as authorized minter,
/// matching what genesis seeds for a real zone.
fn seed_wrapped_config(state: &mut V03State) {
let wrapped_token_id = programs::wrapped_token().id();
state.insert_genesis_account(
wrapped_token_core::config_account_id(wrapped_token_id),
Account {
program_owner: wrapped_token_id,
data: wrapped_token_core::minter_bytes(programs::cross_zone_inbox().id())
.to_vec()
.try_into()
.expect("minter id fits in account data"),
..Default::default()
},
);
}
/// The wrapped-token `Mint` the bridge forwards, serialized as the cross-zone
/// payload (risc0 words, little-endian bytes).
fn mint_payload() -> Vec<u8> {
let mint = wrapped_token_core::Instruction::Mint {
recipient: RECIPIENT,
amount: LOCK_AMOUNT,
};
let words = risc0_zkvm::serde::to_vec(&mint).expect("serialize mint");
words.iter().flat_map(|word| word.to_le_bytes()).collect()
}
/// Drives `cross_zone_inbox::Dispatch` directly through the state machine
/// (no watcher) and asserts the message is delivered to `ping_receiver`, which
/// records the payload into its own PDA.
#[test]
fn inbox_dispatch_delivers_payload_to_ping_receiver() {
let inbox_id = programs::cross_zone_inbox().id();
let receiver_id = programs::ping_receiver().id();
let self_zone = [1_u8; 32];
let src_zone = [2_u8; 32];
let src_block_id = 5;
let mut state = base_state();
seed_inbox_config(&mut state, self_zone, src_zone, receiver_id);
// The payload is the ping_receiver instruction, serialized as risc0 words in
// little-endian bytes (the contract the inbox reverses when forwarding).
let inner = b"hello-cross-zone".to_vec();
let words = risc0_zkvm::serde::to_vec(&ReceiverInstruction::Record {
payload: inner.clone(),
})
.expect("serialize ping instruction");
let payload: Vec<u8> = words.iter().flat_map(|word| word.to_le_bytes()).collect();
let msg = CrossZoneMessage {
src_zone,
src_block_id,
src_tx_index: 0,
src_program_id: [9_u32; 8],
target_program_id: receiver_id,
payload,
l1_inclusion_witness: None,
};
let seen_id = inbox_seen_shard_account_id(inbox_id, &src_zone, src_block_id);
let record_id = ping_record_pda(receiver_id);
let message = Message::try_new(
inbox_id,
vec![inbox_config_account_id(inbox_id), seen_id, record_id],
vec![],
InboxInstruction::Dispatch(msg),
)
.expect("build dispatch message");
let tx = PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![]));
let diff = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0)
.expect("dispatch must validate and execute");
let record = diff
.public_diff()
.get(&record_id)
.expect("ping record account must change")
.clone();
assert_eq!(
record.data.into_inner(),
inner,
"ping_receiver must record the delivered payload"
);
}
/// Drives `bridge_lock::Lock` and asserts it debits the holder, credits the
/// escrow, and records the forwarded mint in the outbox PDA.
#[test]
fn lock_escrows_balance_and_emits_to_outbox() {
let bridge_lock_id = programs::bridge_lock().id();
let wrapped_token_id = programs::wrapped_token().id();
let outbox_id = programs::cross_zone_outbox().id();
let zone_b = [2_u8; 32];
let ordinal = 0;
let mut state = base_state();
let holder_key = PrivateKey::try_new([7; 32]).expect("valid key");
let holder_id = AccountId::from(&PublicKey::new_from_private_key(&holder_key));
state.insert_genesis_account(
holder_id,
Account {
program_owner: bridge_lock_id,
balance: 0,
data: bridge_lock_core::balance_bytes(INITIAL_BALANCE)
.to_vec()
.try_into()
.expect("balance fits in account data"),
nonce: 0_u128.into(),
},
);
let payload = mint_payload();
let target_accounts = vec![
wrapped_token_core::config_account_id(wrapped_token_id).into_value(),
wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT).into_value(),
];
let lock = bridge_lock_core::Instruction::Lock {
amount: LOCK_AMOUNT,
target_zone: zone_b,
target_program_id: wrapped_token_id,
target_accounts,
payload: payload.clone(),
outbox_program_id: outbox_id,
ordinal,
};
let escrow_id = bridge_lock_core::escrow_account_id(bridge_lock_id);
let outbox_record_id = outbox_pda(outbox_id, &zone_b, ordinal);
let message = Message::try_new(
bridge_lock_id,
vec![holder_id, escrow_id, outbox_record_id],
vec![0_u128.into()],
lock,
)
.expect("build lock message");
let witness = WitnessSet::for_message(&message, &[&holder_key]);
let tx = PublicTransaction::new(message, witness);
let diff = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0)
.expect("lock must validate and execute");
let public_diff = diff.public_diff();
let holder_after =
bridge_lock_core::read_balance(&public_diff[&holder_id].data.clone().into_inner());
assert_eq!(
holder_after,
INITIAL_BALANCE - LOCK_AMOUNT,
"holder debited"
);
let escrow_after =
bridge_lock_core::read_balance(&public_diff[&escrow_id].data.clone().into_inner());
assert_eq!(escrow_after, LOCK_AMOUNT, "escrow credited");
let record =
OutboxRecord::from_bytes(&public_diff[&outbox_record_id].data.clone().into_inner())
.expect("outbox PDA holds an OutboxRecord");
assert_eq!(record.target_zone, zone_b);
assert_eq!(record.target_program_id, wrapped_token_id);
assert_eq!(
record.payload, payload,
"emitted payload is the wrapped mint"
);
}
/// Drives a hand-built `cross_zone_inbox::Dispatch` (as the watcher would inject)
/// and asserts it chains into `wrapped_token::Mint`, crediting the recipient.
#[test]
fn inbox_dispatch_mints_wrapped_token() {
let inbox_id = programs::cross_zone_inbox().id();
let wrapped_token_id = programs::wrapped_token().id();
let self_zone = [1_u8; 32];
let src_zone = [2_u8; 32];
let src_block_id = 5;
let mut state = base_state();
seed_inbox_config(&mut state, self_zone, src_zone, wrapped_token_id);
seed_wrapped_config(&mut state);
let msg = CrossZoneMessage {
src_zone,
src_block_id,
src_tx_index: 0,
src_program_id: [9_u32; 8],
target_program_id: wrapped_token_id,
payload: mint_payload(),
l1_inclusion_witness: None,
};
let seen_id = inbox_seen_shard_account_id(inbox_id, &src_zone, src_block_id);
let wrapped_config_id = wrapped_token_core::config_account_id(wrapped_token_id);
let holding_id = wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT);
let message = Message::try_new(
inbox_id,
vec![
inbox_config_account_id(inbox_id),
seen_id,
wrapped_config_id,
holding_id,
],
vec![],
InboxInstruction::Dispatch(msg),
)
.expect("build dispatch message");
let tx = PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![]));
let diff = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0)
.expect("dispatch must validate and execute");
let minted = wrapped_token_core::read_balance(
&diff.public_diff()[&holding_id].data.clone().into_inner(),
);
assert_eq!(
minted, LOCK_AMOUNT,
"recipient holding minted the locked amount"
);
}
/// A dispatch whose message key is already in the seen-shard is an idempotent
/// no-op: the inbox makes no chained call, so the wrapped token is not minted a
/// second time. This is the bridge's replay defense.
#[test]
fn mint_replay_rejected() {
let inbox_id = programs::cross_zone_inbox().id();
let wrapped_token_id = programs::wrapped_token().id();
let self_zone = [1_u8; 32];
let src_zone = [2_u8; 32];
let src_block_id = 5;
let src_tx_index = 0;
let mut state = base_state();
seed_inbox_config(&mut state, self_zone, src_zone, wrapped_token_id);
seed_wrapped_config(&mut state);
// Seed the seen-shard as already containing this message's key, so the inbox
// takes the replay no-op branch. The shard is inbox-owned (claimed on a prior
// delivery), so the guest leaves it untouched.
let seen_id = inbox_seen_shard_account_id(inbox_id, &src_zone, src_block_id);
let mut shard = SeenShard::default();
shard.insert(message_key(&src_zone, src_block_id, src_tx_index));
state.insert_genesis_account(
seen_id,
Account {
program_owner: inbox_id,
balance: 0,
data: shard
.to_bytes()
.try_into()
.expect("shard fits in account data"),
nonce: 0_u128.into(),
},
);
let msg = CrossZoneMessage {
src_zone,
src_block_id,
src_tx_index,
src_program_id: [9_u32; 8],
target_program_id: wrapped_token_id,
payload: mint_payload(),
l1_inclusion_witness: None,
};
let wrapped_config_id = wrapped_token_core::config_account_id(wrapped_token_id);
let holding_id = wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT);
let message = Message::try_new(
inbox_id,
vec![
inbox_config_account_id(inbox_id),
seen_id,
wrapped_config_id,
holding_id,
],
vec![],
InboxInstruction::Dispatch(msg),
)
.expect("build dispatch message");
let tx = PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![]));
let diff = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0)
.expect("a replayed dispatch is a valid no-op, not an error");
let public_diff = diff.public_diff();
// No mint: the holding is never credited on replay.
let minted = public_diff.get(&holding_id).map_or(0, |account| {
wrapped_token_core::read_balance(&account.data.clone().into_inner())
});
assert_eq!(minted, 0, "a replayed message must not mint again");
// The seen-shard is untouched by the no-op.
if let Some(seen) = public_diff.get(&seen_id) {
let shard_after =
SeenShard::from_bytes(&seen.data.clone().into_inner()).expect("seen shard decodes");
assert_eq!(shard_after, shard, "replay must not modify the seen-shard");
}
}

View File

@ -0,0 +1,162 @@
#![expect(
clippy::tests_outside_test_module,
reason = "top-level test functions are conventional for integration tests"
)]
//! Cross-zone round trip with the indexer in the loop (Option B). A ping on zone
//! A is delivered to zone B, and zone B's indexer independently re-derives the
//! injected dispatch from zone A's finalized blocks before applying it. The
//! payload landing in the indexer's state proves verification passed; a forgery
//! would have halted the indexer instead.
use std::{net::SocketAddr, time::Duration};
use anyhow::{Context as _, Result};
use common::transaction::LeeTransaction;
use cross_zone_outbox_core::outbox_pda;
use integration_tests::{
config::{self, SequencerPartialConfig},
indexer_client::IndexerClient,
setup::{setup_bedrock_node, setup_indexer, setup_sequencer},
};
use lee::{AccountId, PublicTransaction, public_transaction::Message};
use lee_core::program::ProgramId;
use ping_core::{ReceiverInstruction, SenderInstruction, ping_record_pda};
use sequencer_core::config::{CrossZoneConfig, CrossZonePeer};
use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder};
use tokio::test;
const DELIVERY_TIMEOUT: Duration = Duration::from_secs(600);
const PING_PAYLOAD: &[u8] = b"hello-verified-zone";
#[test]
async fn indexer_verifies_and_delivers_cross_zone_ping() -> Result<()> {
// Declared first so it outlives both zones (drops run in reverse order).
let (_bedrock, bedrock_addr) = setup_bedrock_node()
.await
.context("Failed to set up shared Bedrock node")?;
let partial = SequencerPartialConfig::default();
let channel_a = config::bedrock_channel_id();
let channel_b = config::bedrock_channel_id_b();
let zone_a: [u8; 32] = *channel_a.as_ref();
let zone_b: [u8; 32] = *channel_b.as_ref();
let receiver_id = programs::ping_receiver().id();
let cross_zone = CrossZoneConfig {
peers: vec![CrossZonePeer {
channel_id: zone_a,
allowed_targets: vec![receiver_id],
expected_block_signing_pubkey: None,
}],
};
// Zone A: source. Zone B: destination, with the watcher on its sequencer and
// the verifier on its indexer.
let (seq_a, _seq_a_home) = setup_sequencer(partial, bedrock_addr, vec![], channel_a, None)
.await
.context("Failed to set up zone A sequencer")?;
let (_idx_a, _idx_a_home) = setup_indexer(bedrock_addr, channel_a, None)
.await
.context("Failed to set up zone A indexer")?;
let (_seq_b, _seq_b_home) = setup_sequencer(
partial,
bedrock_addr,
vec![],
channel_b,
Some(cross_zone.clone()),
)
.await
.context("Failed to set up zone B sequencer")?;
let (idx_b, _idx_b_home) = setup_indexer(bedrock_addr, channel_b, Some(cross_zone))
.await
.context("Failed to set up zone B indexer")?;
// Submit the ping on zone A, addressed to ping_receiver on zone B.
let ping = build_ping_tx(zone_b, receiver_id);
sequencer_client(seq_a.addr())?
.send_transaction(ping)
.await
.context("Failed to submit ping on zone A")?;
// Wait until zone B's indexer records the delivered payload. The indexer only
// applies the dispatch after re-deriving and verifying it.
let record_id = ping_record_pda(receiver_id);
let indexer_url = config::addr_to_url(config::UrlProtocol::Ws, idx_b.addr())
.context("Failed to build indexer URL")?;
let indexer = IndexerClient::new(&indexer_url)
.await
.context("Failed to build indexer client")?;
let delivered = wait_for_indexer_delivery(&indexer, record_id).await?;
assert_eq!(
delivered, PING_PAYLOAD,
"Zone B's indexer must record the verified cross-zone payload"
);
Ok(())
}
fn build_ping_tx(target_zone: [u8; 32], receiver_id: ProgramId) -> LeeTransaction {
let outbox_id = programs::cross_zone_outbox().id();
let ordinal = 0;
let words = risc0_zkvm::serde::to_vec(&ReceiverInstruction::Record {
payload: PING_PAYLOAD.to_vec(),
})
.expect("serialize ping instruction");
let payload: Vec<u8> = words.iter().flat_map(|word| word.to_le_bytes()).collect();
let send = SenderInstruction::Send {
outbox_program_id: outbox_id,
target_zone,
target_program_id: receiver_id,
target_accounts: vec![ping_record_pda(receiver_id).into_value()],
payload,
ordinal,
};
let outbox_account = outbox_pda(outbox_id, &target_zone, ordinal);
let message = Message::try_new(
programs::ping_sender().id(),
vec![outbox_account],
vec![],
send,
)
.expect("build ping message");
LeeTransaction::Public(PublicTransaction::new(
message,
lee::public_transaction::WitnessSet::from_raw_parts(vec![]),
))
}
fn sequencer_client(addr: SocketAddr) -> Result<SequencerClient> {
let url = config::addr_to_url(config::UrlProtocol::Http, addr)
.context("Failed to build sequencer URL")?;
SequencerClientBuilder::default()
.build(url)
.context("Failed to build sequencer client")
}
/// Polls zone B's indexer until the ping record PDA holds a payload.
async fn wait_for_indexer_delivery(
indexer: &IndexerClient,
record_id: AccountId,
) -> Result<Vec<u8>> {
let account_id = indexer_service_protocol::AccountId {
value: record_id.into_value(),
};
let wait = async {
loop {
let account =
indexer_service_rpc::RpcClient::get_account(&**indexer, account_id).await?;
let data = account.data.0;
if !data.is_empty() {
return Ok::<Vec<u8>, anyhow::Error>(data);
}
tokio::time::sleep(Duration::from_secs(3)).await;
}
};
tokio::time::timeout(DELIVERY_TIMEOUT, wait)
.await
.context("Zone B's indexer did not record the payload in time")?
}

View File

@ -50,8 +50,12 @@ pub fn setup_indexer_ffi(bedrock_addr: SocketAddr) -> Result<(IndexerServiceFFI,
temp_indexer_dir.path().display()
);
let indexer_config = integration_tests::config::indexer_config(bedrock_addr)
.context("Failed to create Indexer config")?;
let indexer_config = integration_tests::config::indexer_config(
bedrock_addr,
integration_tests::config::bedrock_channel_id(),
None,
)
.context("Failed to create Indexer config")?;
let config_json = serde_json::to_vec(&indexer_config)?;
let config_path = temp_indexer_dir.path().join("indexer_config.json");

View File

@ -0,0 +1,54 @@
#![expect(
clippy::tests_outside_test_module,
reason = "We don't care about these in tests"
)]
use std::time::Duration;
use anyhow::{Context as _, Result};
use indexer_service_protocol::IndexerSyncState;
use indexer_service_rpc::RpcClient as _;
use integration_tests::{TestContext, wait_for_indexer_to_catch_up};
use log::info;
const CAUGHT_UP_STATUS_TIMEOUT: Duration = Duration::from_secs(60);
/// Test that the indexer status RPC reports caught-up with no stall after a clean run.
///
/// The sequencer keeps producing blocks while we assert, so the status is polled until a
/// `CaughtUp` snapshot is observed and the indexed tip is checked as a lower bound.
///
/// TODO: Integration-level park testing (publishing a bad block to force a stall) is a follow-up
/// needing fault injection support in the test harness.
#[tokio::test]
async fn indexer_status_rpc_reports_caught_up_with_no_stall() -> Result<()> {
let ctx = TestContext::new().await?;
let indexer_tip = wait_for_indexer_to_catch_up(&ctx).await?;
let status = tokio::time::timeout(CAUGHT_UP_STATUS_TIMEOUT, async {
loop {
let status = ctx.indexer_client().get_status().await?;
if status.state == IndexerSyncState::CaughtUp {
return anyhow::Ok(status);
}
info!("Waiting for caught-up indexer status, got {status:?}");
tokio::time::sleep(Duration::from_millis(500)).await;
}
})
.await
.context("Timed out waiting for indexer status to report caught-up")??;
assert!(
status.stall_reason.is_none(),
"indexer should have no stall reason after a clean run, got {status:?}"
);
// test for >= here because the sequencer keeps producing blocks while we assert,
// so the indexed tip may be ahead of the tip we observed when we waited for caught-up.
assert!(
status.indexed_block_id >= Some(indexer_tip),
"status indexed_block_id should be at least the caught-up tip {indexer_tip}, got {status:?}"
);
Ok(())
}

View File

@ -57,8 +57,8 @@ async fn sync_private_account_with_non_zero_chain_index() -> Result<()> {
});
let sub_ret = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
let SubcommandReturnValue::PrivacyPreservingTransfer { tx_hash } = sub_ret else {
anyhow::bail!("Expected PrivacyPreservingTransfer return value");
let SubcommandReturnValue::TransactionExecuted { tx_hash } = sub_ret else {
anyhow::bail!("Expected TransactionExecuted return value");
};
let tx = fetch_privacy_preserving_tx(ctx.sequencer_client(), tx_hash).await;

View File

@ -162,8 +162,8 @@ async fn claim_pinata_to_existing_private_account() -> Result<()> {
let pinata_balance_pre = account_balance(&ctx, system_accounts::pinata_account_id()).await?;
let result = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
let SubcommandReturnValue::PrivacyPreservingTransfer { tx_hash: _ } = result else {
anyhow::bail!("Expected PrivacyPreservingTransfer return value");
let SubcommandReturnValue::TransactionExecuted { tx_hash: _ } = result else {
anyhow::bail!("Expected TransactionExecuted return value");
};
info!("Waiting for next block creation");

View File

@ -11,7 +11,7 @@ use integration_tests::{TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, get_account
use log::info;
use sequencer_service_rpc::RpcClient as _;
use tokio::test;
use wallet::cli::Command;
use wallet::{cli::Command, config::WalletConfigOverrides};
#[test]
async fn deploy_and_execute_program() -> Result<()> {
@ -29,9 +29,6 @@ async fn deploy_and_execute_program() -> Result<()> {
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
info!("Waiting for next block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
let account_id = new_account(&mut ctx, false, None).await?;
let nonces = ctx.wallet().get_accounts_nonces(vec![account_id]).await?;
@ -65,3 +62,37 @@ async fn deploy_and_execute_program() -> Result<()> {
Ok(())
}
#[test]
async fn deploy_invalid_program_fails() -> Result<()> {
// An invalid program bytecode is rejected by the sequencer during block production, so the
// deployment transaction is never included in a block. Shrink the wallet's polling window so
// the command gives up quickly instead of waiting for the full default timeout.
let mut ctx = TestContext::builder()
.with_wallet_config_overrides(WalletConfigOverrides {
seq_poll_timeout: Some(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)),
seq_tx_poll_max_blocks: Some(5),
seq_poll_max_retries: Some(2),
..WalletConfigOverrides::default()
})
.build()
.await?;
let mut tempfile = tempfile::NamedTempFile::new()?;
tempfile.write_all(b"this is not a valid program binary")?;
let command = Command::DeployProgram {
binary_filepath: tempfile.path().to_owned(),
};
let result = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await;
assert!(
result.is_err(),
"Deploying an invalid program should fail, but got: {result:?}"
);
info!("Deploying an invalid program failed as expected");
Ok(())
}

View File

@ -0,0 +1,110 @@
#![expect(
clippy::tests_outside_test_module,
reason = "top-level test functions are conventional for integration tests"
)]
//! Two zones (sequencer + indexer each, on separate channels) sharing one
//! Bedrock node, each producing and finalizing blocks independently.
use std::{net::SocketAddr, time::Duration};
use anyhow::{Context as _, Result};
use indexer_service_rpc::RpcClient as _;
use integration_tests::{
config::{self, SequencerPartialConfig},
indexer_client::IndexerClient,
setup::{setup_bedrock_node, setup_indexer, setup_sequencer},
};
use sequencer_service_rpc::{RpcClient as _, SequencerClientBuilder};
use tokio::test;
const ZONE_LIVE_TIMEOUT: Duration = Duration::from_secs(360);
// Genesis is block 1, so reaching 2 means a block was produced past it.
const MIN_BLOCK_ID: u64 = 2;
#[test]
async fn two_zones_share_one_bedrock_and_both_advance() -> Result<()> {
// Declared first so it outlives both zones (drops run in reverse order).
let (_bedrock, bedrock_addr) = setup_bedrock_node()
.await
.context("Failed to set up shared Bedrock node")?;
let partial = SequencerPartialConfig::default();
let channel_a = config::bedrock_channel_id();
let channel_b = config::bedrock_channel_id_b();
// Empty genesis is enough: the clock transaction drives block production.
let (seq_a, _seq_a_home) = setup_sequencer(partial, bedrock_addr, vec![], channel_a, None)
.await
.context("Failed to set up zone A sequencer")?;
let (idx_a, _idx_a_home) = setup_indexer(bedrock_addr, channel_a, None)
.await
.context("Failed to set up zone A indexer")?;
let (seq_b, _seq_b_home) = setup_sequencer(partial, bedrock_addr, vec![], channel_b, None)
.await
.context("Failed to set up zone B sequencer")?;
let (idx_b, _idx_b_home) = setup_indexer(bedrock_addr, channel_b, None)
.await
.context("Failed to set up zone B indexer")?;
let (height_a, height_b) = tokio::try_join!(
wait_until_zone_live("A", seq_a.addr(), idx_a.addr()),
wait_until_zone_live("B", seq_b.addr(), idx_b.addr()),
)?;
assert!(
height_a >= MIN_BLOCK_ID,
"Zone A indexer only reached block {height_a}, expected >= {MIN_BLOCK_ID}"
);
assert!(
height_b >= MIN_BLOCK_ID,
"Zone B indexer only reached block {height_b}, expected >= {MIN_BLOCK_ID}"
);
Ok(())
}
/// Wait for the sequencer to produce past genesis and the indexer to finalize up
/// to it. Returns the indexer's finalized block id.
async fn wait_until_zone_live(
label: &str,
sequencer_addr: SocketAddr,
indexer_addr: SocketAddr,
) -> Result<u64> {
let sequencer_url = config::addr_to_url(config::UrlProtocol::Http, sequencer_addr)
.context("Failed to build sequencer URL")?;
let sequencer = SequencerClientBuilder::default()
.build(sequencer_url)
.context("Failed to build sequencer client")?;
let indexer_url = config::addr_to_url(config::UrlProtocol::Ws, indexer_addr)
.context("Failed to build indexer URL")?;
let indexer = IndexerClient::new(&indexer_url)
.await
.context("Failed to build indexer client")?;
let wait = async {
loop {
if sequencer.get_last_block_id().await? >= MIN_BLOCK_ID {
break;
}
tokio::time::sleep(Duration::from_secs(2)).await;
}
let target = sequencer.get_last_block_id().await?;
loop {
let finalized = indexer.get_last_finalized_block_id().await?.unwrap_or(0);
if finalized >= target {
log::info!(
"Zone {label} live: sequencer at {target}, indexer finalized {finalized}"
);
return Ok::<u64, anyhow::Error>(finalized);
}
tokio::time::sleep(Duration::from_secs(2)).await;
}
};
tokio::time::timeout(ZONE_LIVE_TIMEOUT, wait)
.await
.with_context(|| format!("Zone {label} did not become live within {ZONE_LIVE_TIMEOUT:?}"))?
}

View File

@ -30,7 +30,7 @@ async fn public_transfer_and_public_claim() -> Result<()> {
.get_account_balance(recipient_vault_id)
.await?;
let transfer_result = wallet::cli::execute_subcommand(
wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Vault(VaultSubcommand::Transfer {
from: public_mention(sender),
@ -39,10 +39,6 @@ async fn public_transfer_and_public_claim() -> Result<()> {
}),
)
.await?;
assert!(
matches!(transfer_result, SubcommandReturnValue::Empty),
"Expected Empty return value for public vault transfer"
);
let sender_balance_after_transfer = ctx.sequencer_client().get_account_balance(sender).await?;
let recipient_balance_after_transfer = ctx
@ -64,7 +60,7 @@ async fn public_transfer_and_public_claim() -> Result<()> {
recipient_vault_balance_before + amount
);
let claim_result = wallet::cli::execute_subcommand(
wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Vault(VaultSubcommand::Claim {
account_id: public_mention(recipient),
@ -72,10 +68,6 @@ async fn public_transfer_and_public_claim() -> Result<()> {
}),
)
.await?;
assert!(
matches!(claim_result, SubcommandReturnValue::Empty),
"Expected Empty return value for public vault claim"
);
let sender_balance_after_claim = ctx.sequencer_client().get_account_balance(sender).await?;
let recipient_balance_after_claim = ctx
@ -138,9 +130,9 @@ async fn private_transfer_and_private_claim() -> Result<()> {
assert!(
matches!(
transfer_result,
SubcommandReturnValue::PrivacyPreservingTransfer { .. }
SubcommandReturnValue::TransactionExecuted { .. }
),
"Expected PrivacyPreservingTransfer return value for private vault transfer"
"Expected TransactionExecuted return value for private vault transfer"
);
let sender_balance_after_transfer = ctx
@ -179,9 +171,9 @@ async fn private_transfer_and_private_claim() -> Result<()> {
assert!(
matches!(
claim_result,
SubcommandReturnValue::PrivacyPreservingTransfer { .. }
SubcommandReturnValue::TransactionExecuted { .. }
),
"Expected PrivacyPreservingTransfer return value for private vault claim"
"Expected TransactionExecuted return value for private vault claim"
);
let sender_balance_after_claim = ctx

View File

@ -1,10 +1,18 @@
use borsh::{BorshDeserialize, BorshSerialize};
#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
#[derive(Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
pub struct Message {
pub(crate) bytecode: Vec<u8>,
}
impl std::fmt::Debug for Message {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Message")
.field("bytecode", &format_args!("<{} bytes>", self.bytecode.len()))
.finish()
}
}
impl Message {
#[must_use]
pub const fn new(bytecode: Vec<u8>) -> Self {

View File

@ -194,6 +194,14 @@ impl V03State {
self.programs.insert(program.id(), program);
}
/// Seeds a single genesis account that is not produced by any transaction
/// (e.g. the cross-zone inbox config or a bridge-lock holding). Lets the
/// sequencer and indexer seed identical zone-specific state after building
/// the shared initial state.
pub fn insert_genesis_account(&mut self, account_id: AccountId, account: Account) {
self.public_state.insert(account_id, account);
}
pub fn apply_state_diff(&mut self, diff: ValidatedStateDiff) {
let StateDiff {
signer_account_ids,

View File

@ -55,6 +55,31 @@ pub struct Block {
pub bedrock_status: BedrockStatus,
}
impl Block {
/// Recomputes the hash from this block's contents, for integrity verification
/// against the value stored in `header.hash`.
#[must_use]
pub fn recompute_hash(&self) -> BlockHash {
HashableBlockData {
block_id: self.header.block_id,
prev_block_hash: self.header.prev_block_hash,
timestamp: self.header.timestamp,
transactions: self.body.transactions.clone(),
}
.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 {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
crate::borsh_base64::serialize(self, serializer)
@ -76,11 +101,13 @@ pub struct HashableBlockData {
}
impl HashableBlockData {
/// Domain-separated hash of the block contents: `SHA256(PREFIX || borsh(self))`.
/// The single source of truth for both producing and verifying a block hash.
#[must_use]
pub fn into_pending_block(self, signing_key: &lee::PrivateKey) -> Block {
pub fn compute_hash(&self) -> BlockHash {
const PREFIX: &[u8; 32] = b"/LEE/v0.3/Message/Block/\x00\x00\x00\x00\x00\x00\x00\x00";
let data_bytes = borsh::to_vec(&self).unwrap();
let data_bytes = borsh::to_vec(self).unwrap();
let mut bytes = Vec::with_capacity(
PREFIX
.len()
@ -89,8 +116,12 @@ impl HashableBlockData {
);
bytes.extend_from_slice(PREFIX);
bytes.extend_from_slice(&data_bytes);
OwnHasher::hash(&bytes)
}
let hash = OwnHasher::hash(&bytes);
#[must_use]
pub fn into_pending_block(self, signing_key: &lee::PrivateKey) -> Block {
let hash = self.compute_hash();
let signature = lee::Signature::new(signing_key, &hash.0);
Block {
header: BlockHeader {
@ -132,4 +163,33 @@ mod tests {
let block_from_bytes = borsh::from_slice::<HashableBlockData>(&bytes).unwrap();
assert_eq!(hashable, block_from_bytes);
}
#[test]
fn recompute_hash_matches_header_for_well_formed_block() {
let key = lee::PrivateKey::try_new([7_u8; 32]).expect("valid key");
let block = HashableBlockData {
block_id: 5,
prev_block_hash: HashType([9_u8; 32]),
timestamp: 42,
transactions: vec![test_utils::produce_dummy_empty_transaction()],
}
.into_pending_block(&key);
assert_eq!(block.recompute_hash(), block.header.hash);
}
#[test]
fn recompute_hash_detects_tampering() {
let key = lee::PrivateKey::try_new([7_u8; 32]).expect("valid key");
let block = HashableBlockData {
block_id: 5,
prev_block_hash: HashType([9_u8; 32]),
timestamp: 42,
transactions: vec![test_utils::produce_dummy_empty_transaction()],
}
.into_pending_block(&key);
let mut tampered = block;
tampered.header.timestamp = 99; // header changed; stale hash no longer matches
assert_ne!(tampered.recompute_hash(), tampered.header.hash);
}
}

View File

@ -92,9 +92,8 @@ impl LeeTransaction {
Ok(diff)
}
/// Computes the validated state diff without enforcing the system-account
/// restriction. Shared by [`Self::validate_on_state`] and
/// [`Self::execute_without_system_accounts_check_on_state`].
/// Computes the validated state diff. Shared by [`Self::validate_on_state`]
/// (which adds the system-account guards) and [`Self::execute_on_state`].
fn compute_state_diff(
&self,
state: &V03State,
@ -129,16 +128,12 @@ impl LeeTransaction {
Ok(self)
}
/// Similar to [`Self::execute_check_on_state`], but skips the system-account guard.
/// Executes the transaction against the current state and applies the resulting diff,
/// without the system-account guards enforced by [`Self::execute_check_on_state`].
///
/// FIXME: HOT FIX (testnet v0.2): the indexer replays blocks the sequencer already
/// accepted, including sequencer-generated deposit transactions that
/// legitimately modify the bridge account. The `TransactionOrigin::Sequencer`
/// tag that lets the sequencer bypass the guard is not carried in the block,
/// so the indexer cannot yet distinguish deposit txs from user txs.
///
/// REMOVE ME when the indexer can authenticate deposit transactions.
pub fn execute_without_system_accounts_check_on_state(
/// The indexer replays blocks the sequencer already validated and inscribed on Bedrock,
/// so it trusts those inscriptions and re-derives state without re-validating them.
pub fn execute_on_state(
self,
state: &mut V03State,
block_id: BlockId,

View File

@ -3,5 +3,6 @@
"bedrock_config": {
"addr": "http://logos-blockchain-node-0:18080"
},
"channel_id": "0101010101010101010101010101010101010101010101010101010101010101"
"channel_id": "0101010101010101010101010101010101010101010101010101010101010101",
"allow_chain_reset": true
}

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

@ -0,0 +1,17 @@
[package]
name = "cross_zone"
version = "0.1.0"
edition = "2024"
license = { workspace = true }
[lints]
workspace = true
[dependencies]
lee.workspace = true
lee_core.workspace = true
programs.workspace = true
cross_zone_inbox_core.workspace = true
bridge_lock_core.workspace = true
ping_core.workspace = true
risc0-zkvm.workspace = true

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

@ -0,0 +1,189 @@
//! Host-side cross-zone helpers that need program ids (`programs`) or the state
//! machine (`lee`), kept out of the guest-pure cores. Mirrors `system_accounts`:
//! it resolves builtin program ids and bakes them into transactions and genesis
//! accounts for the watcher (sequencer) and verifier (indexer).
use std::collections::BTreeMap;
pub use cross_zone_inbox_core::{CrossZoneConfig, CrossZonePeer};
use cross_zone_inbox_core::{
CrossZoneMessage, InboxConfig, Instruction, ZoneId, inbox_config_account_id,
inbox_seen_shard_account_id,
};
use lee_core::{
account::{Account, AccountId},
program::ProgramId,
};
/// The cross-zone emission fields a watcher or verifier reads off a source
/// transaction, common to every emitter program.
pub struct Emission {
pub target_zone: ZoneId,
pub target_program_id: ProgramId,
pub target_accounts: Vec<[u8; 32]>,
pub payload: Vec<u8>,
}
/// Whether a program may only be invoked by sequencer-origin transactions.
///
/// The cross-zone inbox is injected solely by the watcher; a user-submitted call
/// must be rejected at ingress, since `TransactionOrigin` is not carried in the
/// block.
#[must_use]
pub fn is_sequencer_only_program(program_id: ProgramId) -> bool {
program_id == programs::cross_zone_inbox().id()
}
/// Extracts the cross-zone emission from a source transaction.
///
/// Recognizes the known emitter programs (`ping_sender`, `bridge_lock`). The
/// watcher and verifier both use this so they agree on what a given source tx
/// emits.
#[must_use]
pub fn extract_emission(program_id: ProgramId, instruction_data: &[u32]) -> Option<Emission> {
if program_id == programs::ping_sender().id() {
let ping_core::SenderInstruction::Send {
target_zone,
target_program_id,
target_accounts,
payload,
..
} = risc0_zkvm::serde::from_slice(instruction_data).ok()?;
Some(Emission {
target_zone,
target_program_id,
target_accounts,
payload,
})
} else if program_id == programs::bridge_lock().id() {
let bridge_lock_core::Instruction::Lock {
target_zone,
target_program_id,
target_accounts,
payload,
..
} = risc0_zkvm::serde::from_slice(instruction_data).ok()?;
Some(Emission {
target_zone,
target_program_id,
target_accounts,
payload,
})
} else {
None
}
}
/// Builds the sequencer-origin dispatch transaction. Pure for fixed inputs, so
/// the watcher's injected tx and the indexer's re-derived tx are byte-identical.
fn build_inbox_dispatch_tx(
inbox_id: ProgramId,
msg: &CrossZoneMessage,
target_account_ids: Vec<AccountId>,
) -> lee::PublicTransaction {
let mut account_ids = Vec::with_capacity(target_account_ids.len().saturating_add(2));
account_ids.push(inbox_config_account_id(inbox_id));
account_ids.push(inbox_seen_shard_account_id(
inbox_id,
&msg.src_zone,
msg.src_block_id,
));
account_ids.extend(target_account_ids);
let message = lee::public_transaction::Message::try_new(
inbox_id,
account_ids,
vec![],
Instruction::Dispatch(msg.clone()),
)
.expect("inbox dispatch instruction must serialize");
lee::PublicTransaction::new(
message,
lee::public_transaction::WitnessSet::from_raw_parts(vec![]),
)
}
/// Builds the dispatch transaction for one peer emission.
///
/// Both the sequencer's watcher and the indexer's verifier go through this so
/// their transactions are byte-identical for the same emission (the basis of the
/// Option B check).
#[must_use]
pub fn build_dispatch_from_emission(
src_zone: ZoneId,
src_block_id: u64,
src_tx_index: u32,
src_program_id: ProgramId,
target_program_id: ProgramId,
target_accounts: &[[u8; 32]],
payload: Vec<u8>,
) -> lee::PublicTransaction {
let msg = CrossZoneMessage {
src_zone,
src_block_id,
src_tx_index,
src_program_id,
target_program_id,
payload,
l1_inclusion_witness: None,
};
let target_ids = target_accounts
.iter()
.copied()
.map(AccountId::new)
.collect();
build_inbox_dispatch_tx(programs::cross_zone_inbox().id(), &msg, target_ids)
}
/// Builds the inbox config account a zone seeds into genesis state.
///
/// Lets the inbox guest authorize inbound peer messages. The sequencer and
/// indexer seed the same account from the same config, keeping their replayed
/// state consistent.
#[must_use]
pub fn build_inbox_config_account(
self_zone: ZoneId,
cross_zone: &CrossZoneConfig,
) -> (AccountId, Account) {
let inbox_id = programs::cross_zone_inbox().id();
let mut allowed_targets = BTreeMap::new();
for peer in &cross_zone.peers {
allowed_targets.insert(peer.channel_id, peer.allowed_targets.clone());
}
let config = InboxConfig {
self_zone,
allowed_peers: BTreeMap::new(),
allowed_targets,
};
let account = Account {
program_owner: inbox_id,
balance: 0,
data: config
.to_bytes()
.try_into()
.expect("inbox config fits in account data"),
nonce: 0_u128.into(),
};
(inbox_config_account_id(inbox_id), account)
}
/// Builds the genesis holding account funding a holder's bridgeable balance.
///
/// Owned by `bridge_lock`, data is the LE balance. Not produced by any
/// transaction, so the sequencer and indexer both seed it through this one
/// builder.
#[must_use]
pub fn build_holding_account(holder: AccountId, amount: u128) -> (AccountId, Account) {
let account = Account {
program_owner: programs::bridge_lock().id(),
data: bridge_lock_core::balance_bytes(amount)
.to_vec()
.try_into()
.expect("balance fits in account data"),
..Default::default()
};
(holder, account)
}

View File

@ -16,6 +16,9 @@ common.workspace = true
logos-blockchain-zone-sdk.workspace = true
lee.workspace = true
lee_core.workspace = true
cross_zone.workspace = true
cross_zone_inbox_core.workspace = true
programs.workspace = true
storage.workspace = true
testnet_initial_state.workspace = true
@ -29,8 +32,12 @@ futures.workspace = true
url.workspace = true
logos-blockchain-core.workspace = true
serde_json.workspace = true
thiserror.workspace = true
async-stream.workspace = true
tokio.workspace = true
risc0-zkvm.workspace = true
hex.workspace = true
[dev-dependencies]
tempfile.workspace = true
ping_core.workspace = true

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,550 @@
//! Startup check that the local store still belongs to the chain the
//! connected channel serves.
use anyhow::Result;
use common::{HashType, block::Block};
use futures::StreamExt as _;
use log::warn;
use logos_blockchain_zone_sdk::{Slot, ZoneMessage, adapter::Node as _};
use crate::IndexerCore;
/// Upper bound on the channel reads of the startup consistency check.
const CHANNEL_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
/// Result of comparing the indexer's stored chain against the channel.
pub enum ChainConsistency {
/// Channel still serves our anchor block (the stored tip position, or the
/// parked block while stalled).
Consistent,
/// We could not determine the outcome due to one of:
///
/// - cold store (no anchor to compare at)
/// - the channel served only blocks newer than the anchor
/// - or the channel read was inconclusive (timeout / error / empty stream)
///
/// NOTE: None of these prove a reset, so the caller proceeds.
/// A genuine divergence is still caught later when the ingest loop tries to apply and parks.
Inconclusive,
/// Positive evidence that the channel is a different chain than the store.
///
/// Details in [`ChainMismatch`], and impl's Display trait.
Inconsistent(ChainMismatch),
}
/// The evidence behind a [`ChainConsistency::Inconsistent`].
pub enum ChainMismatch {
/// The channel serves a different block at the anchor's id.
Block {
ours: (u64, HashType),
channel: (u64, HashType),
},
/// The channel serves a block at/below the anchor's id past the anchor
/// slot; on the same chain those ids live at earlier slots.
ReinscribedBlock {
channel: (u64, HashType),
slot: Slot,
anchor_slot: Slot,
},
/// The channel has content past the anchor slot but no longer the
/// inscription we anchored on.
AnchorSlotChanged { anchor_slot: Slot },
/// The channel does not exist on the connected chain.
ChannelMissing,
/// The channel's history ends before the anchor slot.
ChannelBehindAnchor { tip_slot: Slot, anchor_slot: Slot },
}
impl std::fmt::Display for ChainMismatch {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Block { ours, channel } => write!(
f,
"stored block {} {} != channel block {} {}",
ours.0, ours.1, channel.0, channel.1
),
Self::ReinscribedBlock {
channel,
slot,
anchor_slot,
} => write!(
f,
"channel re-serves block {} {} at slot {} past our anchor slot {}",
channel.0,
channel.1,
slot.into_inner(),
anchor_slot.into_inner()
),
Self::AnchorSlotChanged { anchor_slot } => write!(
f,
"channel content at slot {} no longer includes the inscription we parked on",
anchor_slot.into_inner()
),
Self::ChannelMissing => write!(f, "channel does not exist on the connected chain"),
Self::ChannelBehindAnchor {
tip_slot,
anchor_slot,
} => write!(
f,
"channel tip slot {} is behind our anchor slot {}",
tip_slot.into_inner(),
anchor_slot.into_inner()
),
}
}
}
/// A block that must still be inscribed at `slot` if the channel is the chain
/// the store was built from: the tip at the read cursor, or the recorded
/// parked block while stalled.
struct Anchor {
slot: Slot,
/// The anchor block's `(id, hash)`.
///
/// `None` when parked on an undeserializable inscription (no header was recorded).
block: Option<(u64, HashType)>,
}
impl Anchor {
/// Probes a channel message read at/after the anchor slot.
/// See [`IndexerCore::verify_chain_at_anchor`].
pub fn probe_anchor_slot(&self, msg: &ZoneMessage, slot: Slot) -> AnchorProbe {
if slot < self.slot {
return AnchorProbe::KeepLooking;
}
let Some((anchor_id, anchor_hash)) = self.block else {
// Anchored on an undeserializable inscription: any message still
// present at that slot means the history is intact.
return if slot == self.slot {
AnchorProbe::SameChain
} else {
AnchorProbe::Mismatch(ChainMismatch::AnchorSlotChanged {
anchor_slot: self.slot,
})
};
};
let ZoneMessage::Block(zone_block) = msg else {
return AnchorProbe::KeepLooking;
};
let Ok(block) = borsh::from_slice::<Block>(&zone_block.data) else {
return AnchorProbe::KeepLooking;
};
let (id, hash) = (block.header.block_id, block.header.hash);
if id == anchor_id {
return if hash == anchor_hash {
AnchorProbe::SameChain
} else {
AnchorProbe::Mismatch(ChainMismatch::Block {
ours: (anchor_id, anchor_hash),
channel: (id, hash),
})
};
}
if id > anchor_id {
return AnchorProbe::Bail;
}
if slot == self.slot {
// Older ids can share the anchor's slot on the same chain.
return AnchorProbe::KeepLooking;
}
// An id below the anchor served past the anchor slot is impossible on the
// same chain, even if the content is identical (deterministic genesis).
AnchorProbe::Mismatch(ChainMismatch::ReinscribedBlock {
channel: (id, hash),
slot,
anchor_slot: self.slot,
})
}
}
/// What a single channel message tells the anchored consistency check.
enum AnchorProbe {
/// The anchor is still in place: same chain.
SameChain,
Mismatch(ChainMismatch),
/// Only newer ids past the anchor: plausible on the same chain, so stop
/// scanning without a verdict.
Bail,
KeepLooking,
}
#[expect(
clippy::multiple_inherent_impl,
reason = "split for clarity & isolation of relevant code"
)]
impl IndexerCore {
/// Verifies whether the channel still serves the same chain the store was built from.
/// This may change frequently during development where we reset the chain from time to
/// time in devnet/testnet, but we do not expect [`ChainConsistency::Inconsistent`] in
/// production.
///
/// To compare the chains, we use an [`Anchor`] block that is either the parked L2 block
/// while stalled, or the tip L2 block at its own inscription L1 slot.
pub(crate) async fn verify_chain_consistency(&self) -> Result<ChainConsistency> {
let Some(anchor) = self.get_startup_anchor()? else {
// empty or cold store: nothing to compare
return Ok(ChainConsistency::Inconclusive);
};
self.verify_chain_at_anchor(&anchor).await
}
/// Builds the anchor for the startup check.
///
/// - If stalled, returns the recorded _parked_ block
/// - If not stalled, returns the validated tip at its _own_ inscription slot.
/// - If the store is empty, returns `None`.
fn get_startup_anchor(&self) -> Result<Option<Anchor>> {
if let Some(stall) = self.store.get_stall_reason()? {
return Ok(Some(Anchor {
slot: stall.l1_slot,
block: stall.block_id.zip(stall.block_hash),
}));
}
// not stalled, so anchor on the tip at its own inscription slot
let Some(slot) = self.store.get_tip_slot()?.or(self.store.get_zone_cursor()?) else {
return Ok(None);
};
let Some(tip_id) = self.store.get_last_block_id()? else {
return Ok(None);
};
let Some(tip) = self.store.get_block_at_id(tip_id)? else {
return Ok(None);
};
Ok(Some(Anchor {
slot,
block: Some((tip_id, tip.header.hash)),
}))
}
/// Verifies the channel still carries the anchor block at its slot.
///
/// The anchor was finalized at `anchor.slot`, so the same chain must still
/// serve it there, while a reset chain re-inscribes its content only at
/// later wall-clock slots.
///
/// Only positive evidence of a different chain yields `Inconsistent`.
/// Absence of data stays `Inconclusive`.
async fn verify_chain_at_anchor(&self, anchor: &Anchor) -> Result<ChainConsistency> {
match self.node.channel_state(self.config.channel_id).await {
Ok(state) => {
if let Some(mismatch) = frontier_verdict(anchor.slot, state.map(|s| s.tip_slot)) {
return Ok(ChainConsistency::Inconsistent(mismatch));
}
}
Err(err) => {
warn!("Failed to read channel state for the consistency check: {err:#}");
}
}
// `next_messages` is exclusive, so `slot - 1` includes the anchor slot.
let Some(from_slot) = anchor.slot.into_inner().checked_sub(1) else {
return Ok(ChainConsistency::Inconclusive);
};
let scan = async {
let stream = self
.zone_indexer
.next_messages(Some(Slot::from(from_slot)))
.await?;
let mut stream = std::pin::pin!(stream);
while let Some((msg, slot)) = stream.next().await {
match anchor.probe_anchor_slot(&msg, slot) {
AnchorProbe::SameChain => return Ok(Some(ChainConsistency::Consistent)),
AnchorProbe::Mismatch(mismatch) => {
return Ok(Some(ChainConsistency::Inconsistent(mismatch)));
}
AnchorProbe::Bail => return Ok(Some(ChainConsistency::Inconclusive)),
AnchorProbe::KeepLooking => { /* dont do anything */ }
}
}
Ok::<_, anyhow::Error>(None)
};
match tokio::time::timeout(CHANNEL_READ_TIMEOUT, scan).await {
Ok(Ok(Some(outcome))) => Ok(outcome),
Ok(Ok(None)) => Ok(ChainConsistency::Inconclusive),
Ok(Err(err)) => {
warn!(
"Failed to read the anchor slot for the consistency check; proceeding: {err:#}"
);
Ok(ChainConsistency::Inconclusive)
}
Err(_elapsed) => {
warn!("Timed out reading the anchor slot for the consistency check; proceeding");
Ok(ChainConsistency::Inconclusive)
}
}
}
}
/// Checks the channel frontier against the anchor slot.
///
/// The anchor block was finalized at `anchor_slot`, so on the same chain the
/// channel tip can never be behind it, and the channel must exist.
fn frontier_verdict(anchor_slot: Slot, channel_tip_slot: Option<Slot>) -> Option<ChainMismatch> {
match channel_tip_slot {
None => Some(ChainMismatch::ChannelMissing),
Some(tip_slot) if tip_slot < anchor_slot => Some(ChainMismatch::ChannelBehindAnchor {
tip_slot,
anchor_slot,
}),
Some(_) => None,
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use common::block::HashableBlockData;
use logos_blockchain_core::mantle::ops::channel::{MsgId, inscribe::Inscription};
use logos_blockchain_zone_sdk::ZoneBlock;
use super::*;
use crate::{
BlockIngestError,
block_store::AcceptOutcome,
config::{ChannelId, ClientConfig, IndexerConfig},
};
fn unreachable_core(dir: &std::path::Path) -> IndexerCore {
let config = IndexerConfig {
consensus_info_polling_interval: Duration::from_secs(1),
bedrock_config: ClientConfig {
addr: "http://localhost:1".parse().expect("url"),
auth: None,
},
channel_id: ChannelId::from([1; 32]),
allow_chain_reset: false,
cross_zone: None,
bridge_lock_holdings: Vec::new(),
};
IndexerCore::open(config, dir).expect("open core")
}
fn test_block(block_id: u64, timestamp: u64) -> Block {
HashableBlockData {
block_id,
prev_block_hash: HashType([0; 32]),
timestamp,
transactions: vec![],
}
.into_pending_block(&lee::PrivateKey::try_new([7; 32]).expect("valid key"))
}
fn block_msg(block: &Block) -> ZoneMessage {
let bytes = borsh::to_vec(block).expect("serialize");
ZoneMessage::Block(ZoneBlock {
id: MsgId::from([0_u8; 32]),
data: Inscription::try_from(bytes.as_slice()).expect("inscription"),
})
}
fn anchor_for(block: &Block, slot: Slot) -> Anchor {
Anchor {
slot,
block: Some((block.header.block_id, block.header.hash)),
}
}
#[tokio::test]
async fn cold_store_is_inconclusive() {
// An empty store has no cursor, so there is nothing to compare: the check
// must be Inconclusive (not Consistent), and it returns before any L1 read.
let dir = tempfile::tempdir().expect("tempdir");
let core = unreachable_core(dir.path());
assert!(matches!(
core.verify_chain_consistency().await.expect("verify"),
ChainConsistency::Inconclusive
));
}
#[tokio::test]
async fn parked_store_with_unreachable_node_is_inconclusive() {
// Network failure is not evidence of a reset: a parked store must stay
// parked (Inconclusive), not error out or trip the wipe path.
let dir = tempfile::tempdir().expect("tempdir");
let core = unreachable_core(dir.path());
let parked = test_block(5, 42);
core.store
.record_stall(
Some(&parked.header),
Slot::from(1_000),
BlockIngestError::EmptyBlock,
)
.expect("record stall");
assert!(matches!(
core.verify_chain_consistency().await.expect("verify"),
ChainConsistency::Inconclusive
));
}
#[tokio::test]
async fn caught_up_store_with_unreachable_node_is_inconclusive() {
let dir = tempfile::tempdir().expect("tempdir");
let core = unreachable_core(dir.path());
let genesis = common::test_utils::produce_dummy_block(1, None, vec![]);
assert!(matches!(
core.store
.accept_block(&genesis, Slot::from(1_000))
.await
.expect("accept"),
AcceptOutcome::Applied
));
core.store
.set_zone_cursor(&Slot::from(1_000))
.expect("set cursor");
assert!(matches!(
core.verify_chain_consistency().await.expect("verify"),
ChainConsistency::Inconclusive
));
}
#[tokio::test]
async fn startup_anchor_prefers_tip_slot_over_lagging_cursor() {
// Cursor persist failures are warn-only, so the read cursor can lag the
// tip by several blocks. The anchor must pair the tip with its own
// inscription slot; pairing it with the stale cursor would make the scan
// misread the chain's intermediate blocks as re-inscriptions.
let dir = tempfile::tempdir().expect("tempdir");
let core = unreachable_core(dir.path());
let genesis = common::test_utils::produce_dummy_block(1, None, vec![]);
core.store
.accept_block(&genesis, Slot::from(1_000))
.await
.expect("accept");
let block2 = common::test_utils::produce_dummy_block(2, Some(genesis.header.hash), vec![]);
core.store
.accept_block(&block2, Slot::from(1_005))
.await
.expect("accept");
let block3 = common::test_utils::produce_dummy_block(3, Some(block2.header.hash), vec![]);
core.store
.accept_block(&block3, Slot::from(1_010))
.await
.expect("accept");
// Cursor last persisted at the genesis slot: two blocks behind the tip.
core.store
.set_zone_cursor(&Slot::from(1_000))
.expect("set cursor");
let anchor = core.get_startup_anchor().expect("anchor").expect("present");
assert_eq!(anchor.slot, Slot::from(1_010));
assert_eq!(anchor.block, Some((3, block3.header.hash)));
}
#[test]
fn probe_finds_anchor_block_at_slot() {
let tip = test_block(5, 42);
let anchor = anchor_for(&tip, Slot::from(1_000));
assert!(matches!(
anchor.probe_anchor_slot(&block_msg(&tip), Slot::from(1_000)),
AnchorProbe::SameChain
));
}
#[test]
fn probe_flags_different_block_at_anchor_id() {
let tip = test_block(5, 42);
let anchor = anchor_for(&tip, Slot::from(1_000));
// Same id, different content (timestamp) => different hash.
let other = test_block(5, 43);
assert!(matches!(
anchor.probe_anchor_slot(&block_msg(&other), Slot::from(1_000)),
AnchorProbe::Mismatch(ChainMismatch::Block { .. })
));
}
#[test]
fn probe_flags_old_id_reinscribed_past_the_anchor_slot() {
// A reset chain re-inscribes from genesis at later slots. Even if the
// content is byte-identical (deterministic genesis), an id at/below
// the anchor past the anchor slot is impossible on the same chain.
let tip = test_block(5, 42);
let anchor = anchor_for(&tip, Slot::from(1_000));
let genesis = test_block(1, 0);
assert!(matches!(
anchor.probe_anchor_slot(&block_msg(&genesis), Slot::from(1_001)),
AnchorProbe::Mismatch(ChainMismatch::ReinscribedBlock { .. })
));
}
#[test]
fn probe_skips_older_blocks_sharing_the_anchor_slot() {
let tip = test_block(5, 42);
let anchor = anchor_for(&tip, Slot::from(1_000));
let earlier = test_block(4, 41);
assert!(matches!(
anchor.probe_anchor_slot(&block_msg(&earlier), Slot::from(1_000)),
AnchorProbe::KeepLooking
));
}
#[test]
fn probe_bails_on_newer_ids_past_the_anchor() {
// Blocks newer than the anchor are plausible on the same chain (e.g.
// published while we were down), so they must never count as evidence.
let tip = test_block(5, 42);
let anchor = anchor_for(&tip, Slot::from(1_000));
let newer = test_block(6, 43);
assert!(matches!(
anchor.probe_anchor_slot(&block_msg(&newer), Slot::from(1_001)),
AnchorProbe::Bail
));
}
#[test]
fn probe_skips_undeserializable_inscriptions() {
let tip = test_block(5, 42);
let anchor = anchor_for(&tip, Slot::from(1_000));
let garbage = ZoneMessage::Block(ZoneBlock {
id: MsgId::from([0_u8; 32]),
data: Inscription::try_from(&[1_u8, 2, 3][..]).expect("inscription"),
});
assert!(matches!(
anchor.probe_anchor_slot(&garbage, Slot::from(1_000)),
AnchorProbe::KeepLooking
));
}
#[test]
fn probe_accepts_any_message_for_a_headerless_anchor() {
// A deserialize park records no header: any message still present at
// the anchor slot means the history is intact.
let anchor = Anchor {
slot: Slot::from(1_000),
block: None,
};
let garbage = ZoneMessage::Block(ZoneBlock {
id: MsgId::from([0_u8; 32]),
data: Inscription::try_from(&[1_u8, 2, 3][..]).expect("inscription"),
});
assert!(matches!(
anchor.probe_anchor_slot(&garbage, Slot::from(1_000)),
AnchorProbe::SameChain
));
assert!(matches!(
anchor.probe_anchor_slot(&garbage, Slot::from(1_001)),
AnchorProbe::Mismatch(ChainMismatch::AnchorSlotChanged { .. })
));
}
#[test]
fn frontier_flags_missing_channel_and_short_history() {
assert!(matches!(
frontier_verdict(Slot::from(1_000), None),
Some(ChainMismatch::ChannelMissing)
));
assert!(matches!(
frontier_verdict(Slot::from(1_000), Some(Slot::from(999))),
Some(ChainMismatch::ChannelBehindAnchor { .. })
));
assert!(frontier_verdict(Slot::from(1_000), Some(Slot::from(1_000))).is_none());
assert!(frontier_verdict(Slot::from(1_000), Some(Slot::from(2_000))).is_none());
}
}

View File

@ -2,7 +2,9 @@ use std::{fs::File, io::BufReader, path::Path, time::Duration};
use anyhow::{Context as _, Result};
use common::config::BasicAuth;
use cross_zone_inbox_core::CrossZoneConfig;
use humantime_serde;
use lee::AccountId;
pub use logos_blockchain_core::mantle::ops::channel::ChannelId;
use serde::{Deserialize, Serialize};
use url::Url;
@ -20,6 +22,29 @@ pub struct IndexerConfig {
pub consensus_info_polling_interval: Duration,
pub bedrock_config: ClientConfig,
pub channel_id: ChannelId,
/// Cross-zone configuration. `None` disables the indexer's cross-zone handling.
#[serde(default)]
pub cross_zone: Option<CrossZoneConfig>,
/// Bridge-lock holdings to seed into genesis, mirroring the sequencer's
/// `SupplyBridgeLockHolding` actions. They are not produced by any
/// transaction, so the indexer must seed them to match the sequencer's state.
#[serde(default)]
pub bridge_lock_holdings: Vec<BridgeLockHolding>,
/// Whether to wipe the indexer store and re-index from scratch when the startup
/// chain-identity check finds the channel serving a different block than the one
/// stored at the same id.
///
/// Defaults to `false`: on mismatch the indexer refuses to start.
#[serde(default)]
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 {

View File

@ -0,0 +1,500 @@
use std::{
collections::{HashMap, HashSet},
sync::Arc,
time::Duration,
};
use anyhow::{Result, bail};
use common::{block::Block, transaction::LeeTransaction};
use cross_zone::{build_dispatch_from_emission, extract_emission};
use cross_zone_inbox_core::{
CrossZoneMessage, Instruction as InboxInstruction, MessageKey, ZoneId, message_key,
};
use futures::StreamExt as _;
use lee::PublicKey;
use log::{error, info};
use logos_blockchain_core::mantle::ops::channel::ChannelId;
use logos_blockchain_zone_sdk::{
CommonHttpClient, ZoneMessage, adapter::NodeHttpClient, indexer::ZoneIndexer,
};
use tokio::sync::RwLock;
use crate::config::IndexerConfig;
/// How often the verifier logs that it is still waiting on a lagging peer reader,
/// so a stuck wait is observable without rejecting a legitimate message.
const LAG_LOG_INTERVAL: Duration = Duration::from_secs(30);
/// Cache of finalized peer-zone blocks, filled by per-peer reader tasks and read
/// by the verifier to re-derive cross-zone dispatch transactions.
#[derive(Clone, Default)]
struct PeerBlocks {
chains: Arc<RwLock<HashMap<ZoneId, HashMap<u64, Block>>>>,
}
impl PeerBlocks {
async fn insert(&self, zone: ZoneId, block: Block) {
self.chains
.write()
.await
.entry(zone)
.or_default()
.insert(block.header.block_id, block);
}
async fn get(&self, zone: ZoneId, block_id: u64) -> Option<Block> {
self.chains
.read()
.await
.get(&zone)
.and_then(|chain| chain.get(&block_id).cloned())
}
/// The highest block id this reader has finalized for `zone`, or `None` if it
/// has read nothing yet. Used to tell forgery (we have read past the
/// referenced block and it is absent) from lag (we simply have not caught up).
async fn highest_seen(&self, zone: ZoneId) -> Option<u64> {
self.chains
.read()
.await
.get(&zone)
.and_then(|chain| chain.keys().copied().max())
}
}
/// The indexer-side Option B verifier.
///
/// For every cross-zone dispatch in a block it re-derives the transaction from
/// the peer's finalized block and rejects it if the bytes differ (a forgery) or
/// the message was already delivered (a replay), so delivery no longer relies on
/// trusting the sequencer.
#[derive(Clone)]
pub struct CrossZoneVerifier {
self_zone: ZoneId,
/// Pinned block-signing key per peer zone, enforced during re-derivation.
peer_pubkeys: HashMap<ZoneId, PublicKey>,
peers: PeerBlocks,
seen: Arc<RwLock<HashSet<MessageKey>>>,
}
impl CrossZoneVerifier {
/// Builds the verifier and spawns one peer reader per configured peer.
/// Returns `None` when cross-zone messaging is disabled.
pub fn start(config: &IndexerConfig) -> Option<Self> {
let cross_zone = config.cross_zone.as_ref()?;
let self_zone: ZoneId = *config.channel_id.as_ref();
let peers = PeerBlocks::default();
let mut peer_pubkeys = HashMap::new();
for peer in &cross_zone.peers {
let node = NodeHttpClient::new(
CommonHttpClient::new(config.bedrock_config.auth.clone().map(Into::into)),
config.bedrock_config.addr.clone(),
);
if let Some(bytes) = peer.expected_block_signing_pubkey {
let pubkey = PublicKey::try_new(bytes)
.expect("configured peer block-signing pubkey is a valid key");
peer_pubkeys.insert(peer.channel_id, pubkey);
}
tokio::spawn(read_peer(
ZoneIndexer::new(ChannelId::from(peer.channel_id), node),
peer.channel_id,
peers.clone(),
config.consensus_info_polling_interval,
));
}
Some(Self {
self_zone,
peer_pubkeys,
peers,
seen: Arc::new(RwLock::new(HashSet::new())),
})
}
/// Verifies every cross-zone dispatch in a block, returning `Err` on the
/// first forged or replayed dispatch. The caller halts ingestion on error.
pub async fn verify_block(&self, block: &Block) -> Result<()> {
for tx in &block.body.transactions {
let Some(msg) = Self::decode_dispatch(tx) else {
continue;
};
let key = message_key(&msg.src_zone, msg.src_block_id, msg.src_tx_index);
if self.seen.read().await.contains(&key) {
bail!(
"cross-zone replay: message {} re-delivered",
hex::encode(key)
);
}
let expected = self.rederive(&msg).await?;
if LeeTransaction::Public(expected) != *tx {
bail!(
"forged cross-zone dispatch from zone {} block {} tx {}: re-derivation mismatch",
hex::encode(msg.src_zone),
msg.src_block_id,
msg.src_tx_index
);
}
self.seen.write().await.insert(key);
info!(
"Verified cross-zone dispatch from zone {} block {} tx {}",
hex::encode(msg.src_zone),
msg.src_block_id,
msg.src_tx_index
);
}
Ok(())
}
/// Decodes a transaction into the cross-zone message it dispatches, or `None`
/// if it is not an inbox dispatch.
fn decode_dispatch(tx: &LeeTransaction) -> Option<CrossZoneMessage> {
let LeeTransaction::Public(public_tx) = tx else {
return None;
};
if public_tx.message().program_id != programs::cross_zone_inbox().id() {
return None;
}
match risc0_zkvm::serde::from_slice::<InboxInstruction, _>(
&public_tx.message().instruction_data,
) {
Ok(InboxInstruction::Dispatch(msg)) => Some(msg),
Err(_) => None,
}
}
/// Re-derives the dispatch transaction the watcher should have injected for
/// `msg`, reading the source emission from the peer's finalized block.
async fn rederive(&self, msg: &CrossZoneMessage) -> Result<lee::PublicTransaction> {
let peer_block = self
.wait_for_peer_block(msg.src_zone, msg.src_block_id)
.await?;
// Equivocation defense: the source block must be signed by the peer's
// pinned block-signing key, not merely inscribed on the channel.
if let Some(expected) = self.peer_pubkeys.get(&msg.src_zone)
&& !peer_block.is_signed_by(expected)
{
bail!(
"forged cross-zone dispatch: peer zone {} block {} is not signed by the pinned block-signing key",
hex::encode(msg.src_zone),
msg.src_block_id
);
}
let emission_tx = peer_block
.body
.transactions
.get(usize::try_from(msg.src_tx_index).expect("u32 index fits in usize"))
.ok_or_else(|| {
anyhow::anyhow!(
"src_tx_index {} out of range in peer block",
msg.src_tx_index
)
})?;
let LeeTransaction::Public(emission_tx) = emission_tx else {
bail!("peer emission transaction is not public");
};
let message = emission_tx.message();
let emission =
extract_emission(message.program_id, &message.instruction_data).ok_or_else(|| {
anyhow::anyhow!("peer transaction at src_tx_index is not a recognized emitter")
})?;
if emission.target_zone != self.self_zone {
bail!("peer emission targets a different zone");
}
Ok(build_dispatch_from_emission(
msg.src_zone,
msg.src_block_id,
msg.src_tx_index,
message.program_id,
emission.target_program_id,
&emission.target_accounts,
emission.payload,
))
}
/// Resolves the referenced peer block, distinguishing forgery from lag.
///
/// If the block is cached, return it. If our peer reader has already
/// finalized past `block_id` and we still do not have it, the reference is to
/// a block that does not exist on the peer chain, a forgery, so reject now.
/// Otherwise the reader simply has not caught up yet: keep waiting, since a
/// legitimate dispatch is only injected after its peer block finalized and
/// our reader of the same finalized chain will see it too. Rejecting on a
/// timeout here would turn a lagging reader into a permanent halt of an
/// honest message.
async fn wait_for_peer_block(&self, zone: ZoneId, block_id: u64) -> Result<Block> {
let mut waited = Duration::ZERO;
loop {
if let Some(block) = self.peers.get(zone, block_id).await {
return Ok(block);
}
if self
.peers
.highest_seen(zone)
.await
.is_some_and(|h| h >= block_id)
{
bail!(
"forged cross-zone reference: peer zone {} finalized past block {} but it is absent",
hex::encode(zone),
block_id
);
}
if !waited.is_zero() && waited.as_secs().is_multiple_of(LAG_LOG_INTERVAL.as_secs()) {
info!(
"Waiting for peer zone {} to finalize block {} ({}s); reader is behind",
hex::encode(zone),
block_id,
waited.as_secs()
);
}
tokio::time::sleep(Duration::from_secs(1)).await;
waited = waited.saturating_add(Duration::from_secs(1));
}
}
}
/// Reads a peer zone's finalized blocks from Bedrock into the shared cache.
#[expect(
clippy::infinite_loop,
reason = "the peer reader runs for the lifetime of the indexer process"
)]
async fn read_peer(
zone_indexer: ZoneIndexer<NodeHttpClient>,
peer_zone: ZoneId,
peers: PeerBlocks,
poll_interval: Duration,
) {
info!(
"Cross-zone peer reader started for {}",
hex::encode(peer_zone)
);
let mut cursor = None;
loop {
let stream = match zone_indexer.next_messages(cursor).await {
Ok(stream) => stream,
Err(err) => {
error!(
"Peer reader next_messages failed for {}: {err}",
hex::encode(peer_zone)
);
tokio::time::sleep(poll_interval).await;
continue;
}
};
let mut stream = std::pin::pin!(stream);
while let Some((msg, slot)) = stream.next().await {
if let ZoneMessage::Block(zone_block) = msg {
match borsh::from_slice::<Block>(&zone_block.data) {
Ok(block) => peers.insert(peer_zone, block).await,
Err(err) => error!("Peer reader failed to deserialize block: {err}"),
}
}
cursor = Some(slot);
}
tokio::time::sleep(poll_interval).await;
}
}
#[cfg(test)]
mod tests {
use common::test_utils::produce_dummy_block;
use lee::{
PrivateKey, PublicKey, PublicTransaction,
public_transaction::{Message, WitnessSet},
};
use ping_core::{SenderInstruction, ping_record_pda};
use super::*;
const SELF_ZONE: ZoneId = [1; 32];
const PEER_ZONE: ZoneId = [2; 32];
const PEER_BLOCK_ID: u64 = 5;
fn verifier() -> CrossZoneVerifier {
verifier_with_pinned_keys(HashMap::new())
}
fn verifier_with_pinned_keys(peer_pubkeys: HashMap<ZoneId, PublicKey>) -> CrossZoneVerifier {
CrossZoneVerifier {
self_zone: SELF_ZONE,
peer_pubkeys,
peers: PeerBlocks::default(),
seen: Arc::new(RwLock::new(HashSet::new())),
}
}
/// A `ping_sender` emission addressed to `SELF_ZONE` carrying `payload`.
fn emission(payload: &[u8]) -> LeeTransaction {
let receiver_id = programs::ping_receiver().id();
let send = SenderInstruction::Send {
outbox_program_id: programs::cross_zone_outbox().id(),
target_zone: SELF_ZONE,
target_program_id: receiver_id,
target_accounts: vec![ping_record_pda(receiver_id).into_value()],
payload: payload.to_vec(),
ordinal: 0,
};
let message = Message::try_new(programs::ping_sender().id(), vec![], vec![], send)
.expect("emission serializes");
LeeTransaction::Public(PublicTransaction::new(
message,
WitnessSet::from_raw_parts(vec![]),
))
}
/// The dispatch a watcher would inject for a `PEER_BLOCK_ID` emission of `payload`.
fn dispatch(payload: &[u8]) -> LeeTransaction {
let receiver_id = programs::ping_receiver().id();
LeeTransaction::Public(build_dispatch_from_emission(
PEER_ZONE,
PEER_BLOCK_ID,
0,
programs::ping_sender().id(),
receiver_id,
&[ping_record_pda(receiver_id).into_value()],
payload.to_vec(),
))
}
#[tokio::test]
async fn verifies_dispatch_matching_a_peer_emission() {
let verifier = verifier();
verifier
.peers
.insert(
PEER_ZONE,
produce_dummy_block(PEER_BLOCK_ID, None, vec![emission(b"hi")]),
)
.await;
let block = produce_dummy_block(9, None, vec![dispatch(b"hi")]);
verifier
.verify_block(&block)
.await
.expect("dispatch matching the peer emission verifies");
}
#[tokio::test]
async fn rejects_dispatch_with_no_matching_emission() {
let verifier = verifier();
// The peer block carries the real emission, but the block claims a
// different payload, so re-derivation does not reproduce it.
verifier
.peers
.insert(
PEER_ZONE,
produce_dummy_block(PEER_BLOCK_ID, None, vec![emission(b"real")]),
)
.await;
let block = produce_dummy_block(9, None, vec![dispatch(b"forged")]);
let err = verifier.verify_block(&block).await.unwrap_err();
assert!(
err.to_string().contains("forged"),
"unexpected error: {err}"
);
}
#[tokio::test]
async fn verifies_dispatch_signed_by_the_pinned_peer_key() {
// produce_dummy_block signs with PrivateKey([37; 32]); pin its pubkey.
let signer = PublicKey::new_from_private_key(&PrivateKey::try_new([37; 32]).unwrap());
let mut keys = HashMap::new();
keys.insert(PEER_ZONE, signer);
let verifier = verifier_with_pinned_keys(keys);
verifier
.peers
.insert(
PEER_ZONE,
produce_dummy_block(PEER_BLOCK_ID, None, vec![emission(b"hi")]),
)
.await;
let block = produce_dummy_block(9, None, vec![dispatch(b"hi")]);
verifier
.verify_block(&block)
.await
.expect("a dispatch from the pinned signer verifies");
}
#[tokio::test]
async fn rejects_dispatch_from_a_block_not_signed_by_the_pinned_key() {
// Pin a different key than the one that signed the peer block.
let mut keys = HashMap::new();
keys.insert(PEER_ZONE, PublicKey::try_new([42; 32]).unwrap());
let verifier = verifier_with_pinned_keys(keys);
verifier
.peers
.insert(
PEER_ZONE,
produce_dummy_block(PEER_BLOCK_ID, None, vec![emission(b"hi")]),
)
.await;
let block = produce_dummy_block(9, None, vec![dispatch(b"hi")]);
let err = verifier.verify_block(&block).await.unwrap_err();
assert!(
err.to_string().contains("pinned"),
"unexpected error: {err}"
);
}
#[tokio::test]
async fn rejects_reference_to_a_block_the_peer_never_finalized() {
let verifier = verifier();
// The reader has finalized past PEER_BLOCK_ID (it holds a later block) but
// never saw PEER_BLOCK_ID itself, so a dispatch referencing it is a forgery
// and must be rejected rather than waited on forever.
verifier
.peers
.insert(
PEER_ZONE,
produce_dummy_block(PEER_BLOCK_ID + 1, None, vec![emission(b"hi")]),
)
.await;
let block = produce_dummy_block(9, None, vec![dispatch(b"hi")]);
let err = verifier.verify_block(&block).await.unwrap_err();
assert!(
err.to_string().contains("forged"),
"unexpected error: {err}"
);
}
#[tokio::test]
async fn rejects_replayed_dispatch() {
let verifier = verifier();
verifier
.peers
.insert(
PEER_ZONE,
produce_dummy_block(PEER_BLOCK_ID, None, vec![emission(b"hi")]),
)
.await;
let first = produce_dummy_block(9, None, vec![dispatch(b"hi")]);
verifier
.verify_block(&first)
.await
.expect("first delivery verifies");
let replay = produce_dummy_block(10, None, vec![dispatch(b"hi")]);
let err = verifier.verify_block(&replay).await.unwrap_err();
assert!(
err.to_string().contains("replay"),
"unexpected error: {err}"
);
}
}

View File

@ -0,0 +1,80 @@
use common::HashType;
use serde::{Deserialize, Serialize};
/// Why the indexer could not apply an L2 block from the channel.
///
/// Persisted in `RocksDB`, so every variant must have the following
/// traits: `Clone + Serialize + Deserialize`.
#[derive(Debug, Clone, Serialize, Deserialize, thiserror::Error)]
pub enum BlockIngestError {
#[error("Failed to deserialize L2 block: {0}")]
/// Here we store the error string that is derived from [`borsh::from_slice`]'s [`Err`].
Deserialize(String),
#[error("Unexpected block id: expected {expected}, got {got}")]
UnexpectedBlockId { expected: u64, got: u64 },
#[error("Broken chain link: expected prev {expected_prev}, got {got_prev}")]
BrokenChainLink {
expected_prev: HashType,
got_prev: HashType,
},
#[error("Block hash mismatch: computed {computed}, header {header}")]
HashMismatch {
computed: HashType,
header: HashType,
},
#[error("Block has no transactions")]
EmptyBlock,
#[error("Last transaction must be the public clock invocation for the block timestamp")]
InvalidClockTransaction,
#[error("Genesis block must contain only public transactions")]
NonPublicGenesisTransaction,
#[error("State transition failed at transaction {tx_index}: {reason}")]
StateTransition {
/// Index of the failing transaction within the block body.
tx_index: u64,
/// Reason string from `lee::Error` to `anyhow::Error` to `{:#}`.
///
/// This is required because `lee::Error` is not `Clone + Serialize + Deserialize`, so we
/// cannot store it directly.
reason: String,
},
}
impl BlockIngestError {
/// Whether the failure may be transient rather than a property of the block.
///
/// FIXME: `StateTransition` is too coarse — its `reason` string mixes genuine
/// state-transition rejections with infra failures (risc0 executor teardown,
/// storage errors). Once it carries a structured cause, narrow this so only
/// infra failures retry.
#[must_use]
pub const fn is_retryable(&self) -> bool {
matches!(self, Self::StateTransition { .. })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn serializes_and_round_trips_externally_tagged() {
let err = BlockIngestError::UnexpectedBlockId {
expected: 5,
got: 7,
};
let value = serde_json::to_value(&err).expect("serialize");
assert_eq!(
value,
serde_json::json!({ "UnexpectedBlockId": { "expected": 5, "got": 7 } })
);
let back: BlockIngestError = serde_json::from_value(value).expect("deserialize");
assert!(matches!(
back,
BlockIngestError::UnexpectedBlockId {
expected: 5,
got: 7
}
));
}
}

View File

@ -3,35 +3,85 @@ use std::{path::Path, sync::Arc};
use anyhow::Result;
use arc_swap::ArcSwap;
use common::block::Block;
// ToDo: Remove after testnet
// TODO: Remove after testnet
use futures::StreamExt as _;
pub use ingest_error::BlockIngestError;
use log::{error, info, warn};
use logos_blockchain_core::header::HeaderId;
use logos_blockchain_zone_sdk::{
CommonHttpClient, ZoneMessage, adapter::NodeHttpClient, indexer::ZoneIndexer,
CommonHttpClient, Slot, ZoneMessage, adapter::NodeHttpClient, indexer::ZoneIndexer,
};
use retry::ApplyRetryGate;
pub use stall_reason::StallReason;
use crate::{
block_store::IndexerStore,
block_store::{AcceptOutcome, IndexerStore},
chain_consistency::ChainConsistency,
config::IndexerConfig,
cross_zone_verifier::CrossZoneVerifier,
status::{IndexerStatus, IndexerSyncStatus},
};
pub mod block_store;
pub mod chain_consistency;
pub mod config;
pub mod cross_zone_verifier;
pub mod ingest_error;
mod retry;
pub mod stall_reason;
pub mod status;
/// Consecutive failed apply attempts of the same block before parking.
const APPLY_RETRY_LIMIT: u32 = 3;
#[derive(Clone)]
pub struct IndexerCore {
pub zone_indexer: Arc<ZoneIndexer<NodeHttpClient>>,
/// Direct node handle for queries outside `ZoneIndexer`'s streaming API.
pub node: NodeHttpClient,
pub config: IndexerConfig,
pub store: IndexerStore,
/// Live ingestion status; updated by the ingest stream, read by `status`.
pub status: Arc<ArcSwap<IndexerSyncStatus>>,
/// Option B cross-zone verifier; `None` when cross-zone messaging is disabled.
pub verifier: Option<CrossZoneVerifier>,
}
impl IndexerCore {
pub fn new(config: IndexerConfig, storage_dir: &Path) -> Result<Self> {
/// Builds the core, then verifies the stored chain matches the channel's by
/// re-reading the channel at the stored tip's position.
///
/// On mismatch: refuse (error) unless `config.allow_chain_reset` is set, in which case wipe the
/// store and re-index from scratch.
pub async fn new(config: IndexerConfig, storage_dir: &Path) -> Result<Self> {
let home = storage_dir.join(format!("rocksdb-{}", config.channel_id));
let core = Self::open(config.clone(), storage_dir)?;
match core.verify_chain_consistency().await? {
// `Inconclusive` is deliberately treated the same as `Consistent`.
//
// We could not prove a reset, so proceed from the cursor without wiping
// a possibly-valid store. A genuinely divergent chain is still caught
// later when the ingest loop tries to apply and parks.
ChainConsistency::Consistent | ChainConsistency::Inconclusive => Ok(core),
ChainConsistency::Inconsistent(mismatch) if config.allow_chain_reset => {
warn!(
"Chain reset detected ({mismatch}). Wiping indexer store at {} and \
re-indexing.",
home.display()
);
drop(core); // sole owner before the ingest task is spawned → closes the DB
storage::indexer::RocksDBIO::destroy(&home)?;
Self::open(config, storage_dir)
}
ChainConsistency::Inconsistent(mismatch) => Err(anyhow::anyhow!(
"Indexer store at {} holds a different chain than the channel now serves \
({mismatch}). Delete the indexer storage directory, point at a fresh one, or \
set `allow_chain_reset` in the indexer config.",
home.display()
)),
}
}
/// Opens the store and builds the core without the chain-identity check.
fn open(config: IndexerConfig, storage_dir: &Path) -> Result<Self> {
// Namespace the DB by channel so indexers on different channels can
// share a storage dir without their RocksDB state colliding.
let home = storage_dir.join(format!("rocksdb-{}", config.channel_id));
@ -41,13 +91,37 @@ impl IndexerCore {
CommonHttpClient::new(basic_auth),
config.bedrock_config.addr.clone(),
);
let zone_indexer = ZoneIndexer::new(config.channel_id, node);
let zone_indexer = ZoneIndexer::new(config.channel_id, node.clone());
// Genesis accounts the indexer must seed to match the sequencer's state,
// since none are produced by a transaction: the cross-zone inbox config
// and any bridge-lock holdings. Both go through the same builders the
// sequencer uses, so the states are byte-identical.
let mut genesis_seed = Vec::new();
if let Some(cross_zone) = config.cross_zone.as_ref() {
let self_zone: [u8; 32] = *config.channel_id.as_ref();
genesis_seed.push(cross_zone::build_inbox_config_account(
self_zone, cross_zone,
));
}
for holding in &config.bridge_lock_holdings {
genesis_seed.push(cross_zone::build_holding_account(
holding.holder,
holding.amount,
));
}
// Option B verifier: re-derives each cross-zone dispatch from the peer's
// finalized blocks. `None` when cross-zone messaging is disabled.
let verifier = CrossZoneVerifier::start(&config);
Ok(Self {
zone_indexer: Arc::new(zone_indexer),
store: IndexerStore::open_db(&home, genesis_seed)?,
node,
config,
store: IndexerStore::open_db(&home)?,
status: Arc::new(ArcSwap::from_pointee(IndexerSyncStatus::starting())),
verifier,
})
}
@ -58,10 +132,27 @@ impl IndexerCore {
#[must_use]
pub fn status(&self) -> IndexerStatus {
let sync = IndexerSyncStatus::clone(&self.status.load());
let indexed_block_id = self.store.get_last_block_id().ok().flatten();
// Log-and-fall-back rather than collapsing a store error into the same
// `None` as "legitimately absent": a DB read failure must not silently
// masquerade as "no tip yet" / "no stall recorded" in the snapshot.
let indexed_block_id = match self.store.get_last_block_id() {
Ok(id) => id,
Err(err) => {
warn!("Failed to read last indexed block id for status: {err:#}");
None
}
};
let stall_reason = match self.store.get_stall_reason() {
Ok(reason) => reason,
Err(err) => {
warn!("Failed to read stall reason for status: {err:#}");
None
}
};
IndexerStatus {
sync,
indexed_block_id,
stall_reason,
}
}
@ -70,6 +161,41 @@ impl IndexerCore {
self.status.store(Arc::new(status));
}
/// Advances the in-memory L1 read cursor past `slot` and persists it.
/// A persist failure is only logged: the worst case is re-reading a batch
/// after a restart, which ingestion handles idempotently.
fn advance_cursor(&self, cursor: &mut Option<Slot>, slot: Slot) {
*cursor = Some(slot);
if let Err(err) = self.store.set_zone_cursor(&slot) {
warn!("Failed to persist indexer cursor: {err:#}");
}
}
/// Parks on an inscription that could not be parsed as an L2 block:
/// records the stall and flips the status. The validated tip stays frozen.
///
/// Returns `false` if the stall could not be recorded durably; the caller
/// must then hold the cursor and retry instead of advancing past the slot.
fn park_undeserializable(&self, slot: Slot, error: std::io::Error) -> bool {
let error = anyhow::Error::new(error);
// use `:#` to get the entire error chain
let reason = format!("{error:#}");
error!("Failed to deserialize L2 block from zone-sdk: {reason}");
if let Err(err) =
self.store
.record_stall(None, slot, BlockIngestError::Deserialize(reason.clone()))
{
error!("Failed to record stall reason: {err:#}");
self.set_status(IndexerSyncStatus::error(format!("store error: {err:#}")));
return false;
}
self.set_status(IndexerSyncStatus::stalled(format!(
"failed to deserialize L2 block: {reason}"
)));
true
}
pub fn subscribe_parse_block_stream(&self) -> impl futures::Stream<Item = Result<Block>> + '_ {
let poll_interval = self.config.consensus_info_polling_interval;
let initial_cursor = self
@ -79,6 +205,7 @@ impl IndexerCore {
async_stream::stream! {
let mut cursor = initial_cursor;
let mut retry_gate = ApplyRetryGate::new();
if cursor.is_some() {
info!("Resuming indexer from cursor {cursor:?}");
@ -90,8 +217,6 @@ impl IndexerCore {
let stream = match self.zone_indexer.next_messages(cursor).await {
Ok(s) => s,
Err(err) => {
// `next_messages` reads L1 consensus info internally, so
// this also covers an unreachable/misconfigured L1 node.
error!("Failed to start zone-sdk next_messages stream: {err}");
self.set_status(IndexerSyncStatus::error(format!(
"cannot reach L1 / read channel: {err}"
@ -102,11 +227,8 @@ impl IndexerCore {
};
let mut stream = std::pin::pin!(stream);
// Flip to Syncing on the first message of this cycle (not merely on
// a successful poll) so the steady-state CaughtUp status doesn't
// flicker. Until then the state stays Starting (cold-start scan of
// empty L1 history) or CaughtUp (idle).
let mut announced_syncing = false;
let mut had_cycle_error = false;
while let Some((msg, slot)) = stream.next().await {
if !announced_syncing {
@ -116,46 +238,133 @@ impl IndexerCore {
let zone_block = match msg {
ZoneMessage::Block(b) => b,
// Non-block messages don't carry a cursor position; the
// next ZoneBlock advances past them implicitly.
// FIXME: will be handled in prep of decentralized sequencers
ZoneMessage::Deposit(_) | ZoneMessage::Withdraw(_) => continue,
};
let block: Block = match borsh::from_slice(&zone_block.data) {
Ok(b) => b,
Err(e) => {
error!("Failed to deserialize L2 block from zone-sdk: {e}");
// Advance past the broken inscription so we don't
// re-process it on restart.
cursor = Some(slot);
if let Err(err) = self.store.set_zone_cursor(&slot) {
warn!("Failed to persist indexer cursor: {err:#}");
Err(error) => {
// The stall must be durable before the cursor moves.
if !self.park_undeserializable(slot, error) {
had_cycle_error = true;
break;
}
// L1 proceeds regardless
self.advance_cursor(&mut cursor, slot);
continue;
}
};
info!("Indexed L2 block {}", block.header.block_id);
// TODO: Remove l1_header placeholder once storage layer
// no longer requires it. Zone-sdk handles L1 tracking internally.
let placeholder_l1_header = HeaderId::from([0_u8; 32]);
if let Err(err) = self.store.put_block(block.clone(), placeholder_l1_header).await {
error!("Failed to store block {}: {err:#}", block.header.block_id);
// 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;
}
cursor = Some(slot);
if let Err(err) = self.store.set_zone_cursor(&slot) {
warn!("Failed to persist indexer cursor: {err:#}");
match self.store.accept_block(&block, slot).await {
Ok(AcceptOutcome::Applied) => {
retry_gate.reset();
info!("Indexed L2 block {}", block.header.block_id);
self.set_status(IndexerSyncStatus::syncing());
self.advance_cursor(&mut cursor, slot);
yield Ok(block);
}
Ok(AcceptOutcome::AlreadyApplied) => {
info!(
"Skipping already-applied block {}",
block.header.block_id
);
self.advance_cursor(&mut cursor, slot);
}
Ok(AcceptOutcome::Parked(ingest_err)) => {
error!(
"Parked at block {}: {ingest_err}",
block.header.block_id
);
self.set_status(IndexerSyncStatus::stalled(ingest_err.to_string()));
// L1 proceeds regardless
self.advance_cursor(&mut cursor, slot);
}
Ok(AcceptOutcome::RetryableFailure(ingest_err)) => {
let attempts = retry_gate.register_failure(block.header.block_id);
if attempts >= APPLY_RETRY_LIMIT {
error!(
"Parked at block {} after {attempts} failed apply attempts: {ingest_err}",
block.header.block_id
);
// The stall must be durable before the cursor moves.
if let Err(err) = self.store.record_stall(
Some(&block.header),
slot,
ingest_err.clone(),
) {
error!(
"Failed to record stall reason for block {}: {err:#}",
block.header.block_id
);
self.set_status(IndexerSyncStatus::error(format!(
"store error: {err:#}"
)));
had_cycle_error = true;
break;
}
self.set_status(IndexerSyncStatus::stalled(ingest_err.to_string()));
self.advance_cursor(&mut cursor, slot);
retry_gate.reset();
} else {
error!(
"Failed to apply block {} (attempt {attempts}/{APPLY_RETRY_LIMIT}), will retry: {ingest_err}",
block.header.block_id
);
self.set_status(IndexerSyncStatus::error(format!(
"apply failed, retrying: {ingest_err}"
)));
had_cycle_error = true;
break;
}
}
Err(err) => {
// Infrastructure error (DB read/write), not a bad block.
// will re-poll from the same cursor next cycle.
error!(
"Store error applying block {}: {err:#}",
block.header.block_id
);
self.set_status(IndexerSyncStatus::error(format!(
"store error: {err:#}"
)));
had_cycle_error = true;
break;
}
}
yield Ok(block);
}
// Stream drained: caught up to LIB as of this cycle. Clears any
// prior error (e.g. a transient L1 disconnect that left no
// backlog, so the `Syncing` branch above never ran). Sleep then
// poll again.
self.set_status(IndexerSyncStatus::caught_up());
if had_cycle_error {
tokio::time::sleep(poll_interval).await;
continue;
}
// Stream drained. Stay Stalled if parked; otherwise we are caught up.
// A store error here must not be collapsed to "no stall recorded":
// that would wrongly flip us to caught-up, so we log and hold state.
match self.store.get_stall_reason() {
Ok(None) => self.set_status(IndexerSyncStatus::caught_up()),
Ok(Some(_)) => {}
Err(err) => {
warn!("Failed to read stall reason after draining stream; not marking caught up: {err:#}");
}
}
tokio::time::sleep(poll_interval).await;
}
}

View File

@ -0,0 +1,64 @@
//! Retry gate for possibly-transient block-apply failures.
/// Counts consecutive apply failures per block id so the ingest loop can
/// retry before parking.
///
/// A failure streak is keyed to one block id: a failure of a different block
/// starts a fresh streak. Reset only on a genuinely applied block — the
/// `AlreadyApplied` replay of the prefix after a retry cycle must not clear
/// the failing block's streak.
pub struct ApplyRetryGate {
failing: Option<(u64, u32)>,
}
impl ApplyRetryGate {
#[must_use]
pub const fn new() -> Self {
Self { failing: None }
}
/// Registers a failed apply of `block_id`; returns its consecutive
/// attempt count.
pub const fn register_failure(&mut self, block_id: u64) -> u32 {
let attempts = match self.failing {
Some((id, attempts)) if id == block_id => attempts.saturating_add(1),
_ => 1,
};
self.failing = Some((block_id, attempts));
attempts
}
/// Clears the streak; call when a block actually applies.
pub const fn reset(&mut self) {
self.failing = None;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn counts_consecutive_failures_of_same_block() {
let mut gate = ApplyRetryGate::new();
assert_eq!(gate.register_failure(7), 1);
assert_eq!(gate.register_failure(7), 2);
assert_eq!(gate.register_failure(7), 3);
}
#[test]
fn different_block_starts_fresh_streak() {
let mut gate = ApplyRetryGate::new();
gate.register_failure(7);
gate.register_failure(7);
assert_eq!(gate.register_failure(8), 1);
}
#[test]
fn reset_clears_streak() {
let mut gate = ApplyRetryGate::new();
gate.register_failure(7);
gate.reset();
assert_eq!(gate.register_failure(7), 1);
}
}

View File

@ -0,0 +1,25 @@
use common::HashType;
use logos_blockchain_zone_sdk::Slot;
use serde::{Deserialize, Serialize};
use crate::ingest_error::BlockIngestError;
/// Diagnostic record of the first block that broke the L2 chain.
///
/// The block-derived fields are `None` for a deserialize break (no header was
/// ever parsed). `l1_slot` is the L1 slot the breaking inscription was read at.
/// `first_seen` is the breaking block's L2 timestamp (`None` for a deserialize break).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StallReason {
pub block_id: Option<u64>,
pub block_hash: Option<HashType>,
pub prev_block_hash: Option<HashType>,
pub l1_slot: Slot,
pub error: BlockIngestError,
pub first_seen: Option<u64>,
/// Number of later non-chaining blocks (orphans, since the tip is frozen).
///
/// TODO: We could store a different "branch" of blocks following this break, but for now we
/// just count them.
pub orphans_since: u64,
}

View File

@ -1,9 +1,10 @@
use serde::Serialize;
use crate::stall_reason::StallReason;
/// Coarse lifecycle state of the indexer's ingestion loop, so a client can tell
/// "still catching up" apart from "something went wrong".
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum IndexerSyncState {
/// Booted; no ingestion cycle has run yet.
Starting,
@ -13,12 +14,14 @@ pub enum IndexerSyncState {
CaughtUp,
/// The last cycle failed (e.g. the L1 node is unreachable). See `last_error`.
Error,
/// Parked on a stall reason: the validated tip is frozen awaiting a valid
/// continuation. See `last_error` and the snapshot's `stall_reason`.
Stalled,
}
/// Live ingestion status owned by the ingest loop: the coarse `state` plus the
/// reason when it is `Error`.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct IndexerSyncStatus {
pub state: IndexerSyncState,
pub last_error: Option<String>,
@ -56,6 +59,15 @@ impl IndexerSyncStatus {
last_error: Some(reason),
}
}
/// Parked on a stall reason; `reason` mirrors the stall's error message.
/// The full stall is attached to the [`IndexerStatus`] snapshot.
pub(crate) const fn stalled(reason: String) -> Self {
Self {
state: IndexerSyncState::Stalled,
last_error: Some(reason),
}
}
}
/// Full status snapshot returned to callers (FFI/RPC): the live [`IndexerSyncStatus`]
@ -64,11 +76,11 @@ impl IndexerSyncStatus {
/// The tip is tracked by the store, not the ingest loop, so it lives here on the
/// returned snapshot rather than inside the shared [`IndexerSyncStatus`].
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct IndexerStatus {
#[serde(flatten)]
pub sync: IndexerSyncStatus,
pub indexed_block_id: Option<u64>,
pub stall_reason: Option<StallReason>,
}
#[cfg(test)]
@ -80,14 +92,16 @@ mod tests {
let status = IndexerStatus {
sync: IndexerSyncStatus::error("boom".to_owned()),
indexed_block_id: Some(7),
stall_reason: None,
};
let value = serde_json::to_value(&status).expect("serialize");
assert_eq!(
value,
serde_json::json!({
"state": "error",
"lastError": "boom",
"indexedBlockId": 7,
"state": "Error",
"last_error": "boom",
"indexed_block_id": 7,
"stall_reason": null,
})
);
}
@ -97,7 +111,36 @@ mod tests {
let value = serde_json::to_value(IndexerSyncStatus::caught_up()).expect("serialize");
assert_eq!(
value,
serde_json::json!({ "state": "caught_up", "lastError": null })
serde_json::json!({ "state": "CaughtUp", "last_error": null })
);
}
#[test]
fn stalled_status_serializes_with_stall_reason() {
use logos_blockchain_zone_sdk::Slot;
use crate::{ingest_error::BlockIngestError, stall_reason::StallReason};
let status = IndexerStatus {
sync: IndexerSyncStatus::stalled("broken chain link".to_owned()),
indexed_block_id: Some(41),
stall_reason: Some(StallReason {
block_id: Some(42),
block_hash: None,
prev_block_hash: None,
l1_slot: Slot::from(0),
error: BlockIngestError::StateTransition {
tx_index: 0,
reason: String::default(),
},
first_seen: None,
orphans_since: 2,
}),
};
let value = serde_json::to_value(&status).expect("serialize");
assert_eq!(value["state"], serde_json::json!("Stalled"));
assert_eq!(value["last_error"], serde_json::json!("broken chain link"));
assert_eq!(value["indexed_block_id"], serde_json::json!(41));
assert_eq!(value["stall_reason"]["orphans_since"], serde_json::json!(2));
}
}

View File

@ -503,9 +503,9 @@ struct LastBlockIdResult query_last_block(const struct IndexerServiceFFI *indexe
* Query the indexer's current sync status as a JSON C-string.
*
* The JSON schema is owned by `indexer_core` (`IndexerStatus`): an object with
* `state` (`starting`/`syncing`/`caught_up`/`error`), `indexedBlockId`, and
* `lastError`. Lets a client distinguish "still catching up" from "something
* went wrong".
* `state` (`Starting`/`Syncing`/`CaughtUp`/`Error`/`Stalled`),
* `indexed_block_id`, `last_error`, and `stall_reason`. Lets a client
* distinguish "still catching up" from "something went wrong".
*
* # Arguments
*

View File

@ -112,10 +112,12 @@ unsafe fn setup_indexer(
unsafe { Runtime::from_borrowed(caller.as_ref()) }
};
let core = IndexerCore::new(config, &storage_dir).map_err(|e| {
log::error!("Could not initialize indexer core: {e}");
OperationStatus::InitializationError
})?;
let core = runtime
.block_on(IndexerCore::new(config, &storage_dir))
.map_err(|e| {
log::error!("Could not initialize indexer core: {e}");
OperationStatus::InitializationError
})?;
// The block stream writes each parsed block into the store as a side effect
// of being polled, so we spawn a task that simply drains it. There are no

View File

@ -91,9 +91,9 @@ pub unsafe extern "C" fn query_last_block(indexer: *const IndexerServiceFFI) ->
/// Query the indexer's current sync status as a JSON C-string.
///
/// The JSON schema is owned by `indexer_core` (`IndexerStatus`): an object with
/// `state` (`starting`/`syncing`/`caught_up`/`error`), `indexedBlockId`, and
/// `lastError`. Lets a client distinguish "still catching up" from "something
/// went wrong".
/// `state` (`Starting`/`Syncing`/`CaughtUp`/`Error`/`Stalled`),
/// `indexed_block_id`, `last_error`, and `stall_reason`. Lets a client
/// distinguish "still catching up" from "something went wrong".
///
/// # Arguments
///

View File

@ -1,7 +1,8 @@
{
"consensus_info_polling_interval": "1s",
"bedrock_config": {
"addr": "http://localhost:18080"
},
"channel_id": "0101010101010101010101010101010101010101010101010101010101010101"
"consensus_info_polling_interval": "1s",
"bedrock_config": {
"addr": "http://localhost:18080"
},
"channel_id": "0101010101010101010101010101010101010101010101010101010101010101",
"allow_chain_reset": true
}

View File

@ -3,5 +3,6 @@
"bedrock_config": {
"addr": "http://host.docker.internal:18080"
},
"channel_id": "0101010101010101010101010101010101010101010101010101010101010101"
"channel_id": "0101010101010101010101010101010101010101010101010101010101010101",
"allow_chain_reset": true
}

View File

@ -11,6 +11,7 @@ workspace = true
lee_core = { workspace = true, optional = true, features = ["host"] }
lee = { workspace = true, optional = true }
common = { workspace = true, optional = true }
indexer_core = { workspace = true, optional = true }
serde = { workspace = true, features = ["derive"] }
serde_with.workspace = true
@ -22,4 +23,4 @@ anyhow.workspace = true
[features]
# Enable conversion to/from LEE core types
convert = ["dep:lee_core", "dep:lee", "dep:common"]
convert = ["dep:lee_core", "dep:lee", "dep:common", "dep:indexer_core"]

View File

@ -3,11 +3,12 @@
use lee_core::account::Nonce;
use crate::{
Account, AccountId, BedrockStatus, Block, BlockBody, BlockHeader, Ciphertext, Commitment,
CommitmentSetDigest, Data, EncryptedAccountData, EphemeralPublicKey, HashType, Nullifier,
PrivacyPreservingMessage, PrivacyPreservingTransaction, ProgramDeploymentMessage,
ProgramDeploymentTransaction, ProgramId, Proof, PublicKey, PublicMessage, PublicTransaction,
Signature, Transaction, ValidityWindow, WitnessSet,
Account, AccountId, BedrockStatus, Block, BlockBody, BlockHeader, BlockIngestError, Ciphertext,
Commitment, CommitmentSetDigest, Data, EncryptedAccountData, EphemeralPublicKey, HashType,
IndexerStatus, IndexerSyncState, Nullifier, PrivacyPreservingMessage,
PrivacyPreservingTransaction, ProgramDeploymentMessage, ProgramDeploymentTransaction,
ProgramId, Proof, PublicKey, PublicMessage, PublicTransaction, Signature, StallReason,
Transaction, ValidityWindow, WitnessSet,
};
// ============================================================================
@ -707,3 +708,94 @@ impl TryFrom<ValidityWindow> for lee_core::program::ValidityWindow<u64> {
value.0.try_into()
}
}
// ============================================================================
// Indexer status conversions
// ============================================================================
impl From<indexer_core::status::IndexerSyncState> for IndexerSyncState {
fn from(value: indexer_core::status::IndexerSyncState) -> Self {
match value {
indexer_core::status::IndexerSyncState::Starting => Self::Starting,
indexer_core::status::IndexerSyncState::Syncing => Self::Syncing,
indexer_core::status::IndexerSyncState::CaughtUp => Self::CaughtUp,
indexer_core::status::IndexerSyncState::Error => Self::Error,
indexer_core::status::IndexerSyncState::Stalled => Self::Stalled,
}
}
}
impl From<indexer_core::BlockIngestError> for BlockIngestError {
fn from(value: indexer_core::BlockIngestError) -> Self {
match value {
indexer_core::BlockIngestError::Deserialize(msg) => Self::Deserialize(msg),
indexer_core::BlockIngestError::UnexpectedBlockId { expected, got } => {
Self::UnexpectedBlockId { expected, got }
}
indexer_core::BlockIngestError::BrokenChainLink {
expected_prev,
got_prev,
} => Self::BrokenChainLink {
expected_prev: expected_prev.into(),
got_prev: got_prev.into(),
},
indexer_core::BlockIngestError::HashMismatch { computed, header } => {
Self::HashMismatch {
computed: computed.into(),
header: header.into(),
}
}
indexer_core::BlockIngestError::EmptyBlock => Self::EmptyBlock,
indexer_core::BlockIngestError::InvalidClockTransaction => {
Self::InvalidClockTransaction
}
indexer_core::BlockIngestError::NonPublicGenesisTransaction => {
Self::NonPublicGenesisTransaction
}
indexer_core::BlockIngestError::StateTransition { tx_index, reason } => {
Self::StateTransition { tx_index, reason }
}
}
}
}
impl From<indexer_core::StallReason> for StallReason {
fn from(value: indexer_core::StallReason) -> Self {
let indexer_core::StallReason {
block_id,
block_hash,
prev_block_hash,
l1_slot,
error,
first_seen,
orphans_since,
} = value;
Self {
block_id,
block_hash: block_hash.map(Into::into),
prev_block_hash: prev_block_hash.map(Into::into),
l1_slot: l1_slot.into_inner(),
error: error.into(),
first_seen,
orphans_since,
}
}
}
impl From<indexer_core::status::IndexerStatus> for IndexerStatus {
fn from(value: indexer_core::status::IndexerStatus) -> Self {
let indexer_core::status::IndexerStatus {
sync,
indexed_block_id,
stall_reason,
} = value;
Self {
state: sync.state.into(),
last_error: sync.last_error,
indexed_block_id,
stall_reason: stall_reason.map(Into::into),
}
}
}

View File

@ -363,3 +363,73 @@ pub enum BedrockStatus {
Safe,
Finalized,
}
/// Coarse lifecycle state of the indexer's ingestion loop, so a client can tell
/// "still catching up" apart from "something went wrong".
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
pub enum IndexerSyncState {
/// Booted; no ingestion cycle has run yet.
Starting,
/// Streaming finalized messages toward the L1 frontier.
Syncing,
/// Drained the stream up to the last finalized block; idle until new blocks finalize.
CaughtUp,
/// The last cycle failed (e.g. the L1 node is unreachable). See `last_error`.
Error,
/// Parked on a stall reason: the validated tip is frozen awaiting a valid
/// continuation. See `last_error` and `stall_reason`.
Stalled,
}
/// Why the indexer could not apply an L2 block from the channel.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
pub enum BlockIngestError {
Deserialize(String),
UnexpectedBlockId {
expected: u64,
got: u64,
},
BrokenChainLink {
expected_prev: HashType,
got_prev: HashType,
},
HashMismatch {
computed: HashType,
header: HashType,
},
EmptyBlock,
InvalidClockTransaction,
NonPublicGenesisTransaction,
StateTransition {
/// Index of the failing transaction within the block body.
tx_index: u64,
reason: String,
},
}
/// Diagnostic record of the first block that broke the L2 chain.
///
/// The block-derived fields are `None` for a deserialize break (no header was
/// ever parsed). `l1_slot` is the L1 slot the breaking inscription was read at.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
pub struct StallReason {
pub block_id: Option<u64>,
pub block_hash: Option<HashType>,
pub prev_block_hash: Option<HashType>,
pub l1_slot: u64,
pub error: BlockIngestError,
/// The breaking block's L2 timestamp (`None` for a deserialize break).
pub first_seen: Option<Timestamp>,
/// Number of later non-chaining blocks seen while the tip is frozen.
pub orphans_since: u64,
}
/// Status snapshot returned by `getStatus`: the ingestion state plus the
/// indexed L2 tip and, when stalled, the stall diagnostics.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
pub struct IndexerStatus {
pub state: IndexerSyncState,
pub last_error: Option<String>,
pub indexed_block_id: Option<BlockId>,
pub stall_reason: Option<StallReason>,
}

View File

@ -1,4 +1,6 @@
use indexer_service_protocol::{Account, AccountId, Block, BlockId, HashType, Transaction};
use indexer_service_protocol::{
Account, AccountId, Block, BlockId, HashType, IndexerStatus, Transaction,
};
use jsonrpsee::proc_macros::rpc;
#[cfg(feature = "server")]
use jsonrpsee::{core::SubscriptionResult, types::ErrorObjectOwned};
@ -69,6 +71,9 @@ pub trait Rpc {
limit: u64,
) -> Result<Vec<Transaction>, ErrorObjectOwned>;
#[method(name = "getStatus")]
async fn get_status(&self) -> Result<IndexerStatus, ErrorObjectOwned>;
// ToDo: expand healthcheck response into some kind of report
#[method(name = "checkHealth")]
async fn healthcheck(&self) -> Result<(), ErrorObjectOwned>;

View File

@ -5,6 +5,7 @@ pub use indexer_core::config::*;
use indexer_service_rpc::RpcServer as _;
use jsonrpsee::server::{Server, ServerHandle};
use log::{error, info};
use tokio_util::sync::CancellationToken;
pub mod service;
@ -69,9 +70,10 @@ pub async fn run_server(
config: IndexerConfig,
storage_dir: &Path,
port: u16,
shutdown: CancellationToken,
) -> Result<IndexerHandle> {
#[cfg(feature = "mock-responses")]
let _ = (config, storage_dir);
let _ = (config, storage_dir, shutdown);
let server = Server::builder()
.build(SocketAddr::from(([0, 0, 0, 0], port)))
@ -86,7 +88,8 @@ pub async fn run_server(
#[cfg(not(feature = "mock-responses"))]
let handle = {
let service = service::IndexerService::new(config, storage_dir)
let service = service::IndexerService::new(config, storage_dir, shutdown.child_token())
.await
.context("Failed to initialize indexer service")?;
server.start(service.into_rpc())
};

View File

@ -34,7 +34,9 @@ async fn main() -> Result<()> {
let cancellation_token = listen_for_shutdown_signal();
let config = indexer_service::IndexerConfig::from_path(&config_path)?;
let indexer_handle = indexer_service::run_server(config, data_dir.as_path(), port).await?;
let indexer_handle =
indexer_service::run_server(config, data_dir.as_path(), port, cancellation_token.clone())
.await?;
tokio::select! {
() = cancellation_token.cancelled() => {

View File

@ -10,10 +10,10 @@ use std::{collections::HashMap, sync::Arc, time::Duration};
use indexer_service_protocol::{
Account, AccountId, BedrockStatus, Block, BlockBody, BlockHeader, BlockId, Commitment,
CommitmentSetDigest, Data, EncryptedAccountData, HashType, PrivacyPreservingMessage,
PrivacyPreservingTransaction, ProgramDeploymentMessage, ProgramDeploymentTransaction,
ProgramId, PublicMessage, PublicTransaction, Signature, Transaction, ValidityWindow,
WitnessSet,
CommitmentSetDigest, Data, EncryptedAccountData, HashType, IndexerStatus, IndexerSyncState,
PrivacyPreservingMessage, PrivacyPreservingTransaction, ProgramDeploymentMessage,
ProgramDeploymentTransaction, ProgramId, PublicMessage, PublicTransaction, Signature,
Transaction, ValidityWindow, WitnessSet,
};
use jsonrpsee::{
core::{SubscriptionResult, async_trait},
@ -325,6 +325,24 @@ impl indexer_service_rpc::RpcServer for MockIndexerService {
.collect())
}
async fn get_status(&self) -> Result<IndexerStatus, ErrorObjectOwned> {
let indexed_block_id = self
.state
.read()
.await
.blocks
.iter()
.rev()
.find(|block| block.bedrock_status == BedrockStatus::Finalized)
.map(|block| block.header.block_id);
Ok(IndexerStatus {
state: IndexerSyncState::CaughtUp,
last_error: None,
indexed_block_id,
stall_reason: None,
})
}
async fn healthcheck(&self) -> Result<(), ErrorObjectOwned> {
Ok(())
}

View File

@ -2,9 +2,11 @@ use std::{path::Path, pin::pin, sync::Arc};
use anyhow::{Context as _, Result, bail};
use arc_swap::ArcSwap;
use futures::{StreamExt as _, never::Never};
use futures::StreamExt as _;
use indexer_core::{IndexerCore, config::IndexerConfig};
use indexer_service_protocol::{Account, AccountId, Block, BlockId, HashType, Transaction};
use indexer_service_protocol::{
Account, AccountId, Block, BlockId, HashType, IndexerStatus, Transaction,
};
use jsonrpsee::{
SubscriptionSink,
core::{Serialize, SubscriptionResult, async_trait},
@ -12,6 +14,7 @@ use jsonrpsee::{
};
use log::{debug, error, info, warn};
use tokio::sync::mpsc::UnboundedSender;
use tokio_util::sync::CancellationToken;
pub struct IndexerService {
subscription_service: SubscriptionService,
@ -19,9 +22,13 @@ pub struct IndexerService {
}
impl IndexerService {
pub fn new(config: IndexerConfig, storage_dir: &Path) -> Result<Self> {
let indexer = IndexerCore::new(config, storage_dir)?;
let subscription_service = SubscriptionService::spawn_new(indexer.clone());
pub async fn new(
config: IndexerConfig,
storage_dir: &Path,
shutdown: CancellationToken,
) -> Result<Self> {
let indexer = IndexerCore::new(config, storage_dir).await?;
let subscription_service = SubscriptionService::spawn_new(indexer.clone(), shutdown);
Ok(Self {
subscription_service,
@ -149,6 +156,10 @@ impl indexer_service_rpc::RpcServer for IndexerService {
Ok(tx_res)
}
async fn get_status(&self) -> Result<IndexerStatus, ErrorObjectOwned> {
Ok(self.indexer.status().into())
}
async fn healthcheck(&self) -> Result<(), ErrorObjectOwned> {
// Checking, that indexer can calculate last state
let _ = self
@ -164,15 +175,21 @@ impl indexer_service_rpc::RpcServer for IndexerService {
struct SubscriptionService {
parts: ArcSwap<SubscriptionLoopParts>,
indexer: IndexerCore,
/// Cancellation token that is used to signal the subscription service to shut down.
///
/// NOTE: This will auto-cancel on `Drop`, so if your token is shared with other parts
/// use [`CancellationToken::child_token()`] instead.
shutdown: CancellationToken,
}
impl SubscriptionService {
pub fn spawn_new(indexer: IndexerCore) -> Self {
let parts = Self::spawn_respond_subscribers_loop(indexer.clone());
pub fn spawn_new(indexer: IndexerCore, shutdown: CancellationToken) -> Self {
let parts = Self::spawn_respond_subscribers_loop(indexer.clone(), shutdown.clone());
Self {
parts: ArcSwap::new(Arc::new(parts)),
indexer,
shutdown,
}
}
@ -184,14 +201,18 @@ impl SubscriptionService {
);
// Respawn the subscription service loop if it has finished (either with error or panic)
if guard.handle.is_finished() {
if guard.handle.is_finished() && !self.shutdown.is_cancelled() {
drop(guard);
let new_parts = Self::spawn_respond_subscribers_loop(self.indexer.clone());
let new_parts = Self::spawn_respond_subscribers_loop(
self.indexer.clone(),
self.shutdown.clone(),
);
let old_handle_and_sender = self.parts.swap(Arc::new(new_parts));
let old_parts = Arc::into_inner(old_handle_and_sender)
.expect("There should be no other references to the old handle and sender");
match old_parts.handle.await {
Ok(Ok(())) => {}
Ok(Err(err)) => {
error!(
"Subscription service loop has unexpectedly finished with error: {err:#}"
@ -209,7 +230,10 @@ impl SubscriptionService {
Ok(())
}
fn spawn_respond_subscribers_loop(indexer: IndexerCore) -> SubscriptionLoopParts {
fn spawn_respond_subscribers_loop(
indexer: IndexerCore,
shutdown: CancellationToken,
) -> SubscriptionLoopParts {
let (new_subscription_sender, mut sub_receiver) =
tokio::sync::mpsc::unbounded_channel::<Subscription<BlockId>>();
@ -225,6 +249,10 @@ impl SubscriptionService {
)]
loop {
tokio::select! {
() = shutdown.cancelled() => {
info!("Shutdown requested; stopping block ingestion");
return Ok(());
}
sub = sub_receiver.recv() => {
let Some(subscription) = sub else {
bail!("Subscription receiver closed unexpectedly");
@ -253,10 +281,11 @@ impl SubscriptionService {
}
}
};
let res: anyhow::Result<futures::never::Never> = run_loop.await;
let Err(err) = res;
error!("Subscription service loop has unexpectedly finished with error: {err:#?}");
Err(err)
let res: anyhow::Result<()> = run_loop.await;
if let Err(err) = &res {
error!("Subscription service loop has unexpectedly finished with error: {err:#?}");
}
res
});
SubscriptionLoopParts {
handle,
@ -267,12 +296,13 @@ impl SubscriptionService {
impl Drop for SubscriptionService {
fn drop(&mut self) {
self.shutdown.cancel();
self.parts.load().handle.abort();
}
}
struct SubscriptionLoopParts {
handle: tokio::task::JoinHandle<Result<Never>>,
handle: tokio::task::JoinHandle<Result<()>>,
new_subscription_sender: UnboundedSender<Subscription<BlockId>>,
}

View File

@ -54,6 +54,36 @@ name = "vault"
path = "vault/src/main.rs"
required-features = ["programs"]
[[bin]]
name = "cross_zone_outbox"
path = "cross_zone_outbox/src/main.rs"
required-features = ["programs"]
[[bin]]
name = "cross_zone_inbox"
path = "cross_zone_inbox/src/main.rs"
required-features = ["programs"]
[[bin]]
name = "ping_sender"
path = "ping_sender/src/main.rs"
required-features = ["programs"]
[[bin]]
name = "ping_receiver"
path = "ping_receiver/src/main.rs"
required-features = ["programs"]
[[bin]]
name = "bridge_lock"
path = "bridge_lock/src/main.rs"
required-features = ["programs"]
[[bin]]
name = "wrapped_token"
path = "wrapped_token/src/main.rs"
required-features = ["programs"]
[features]
# TODO: Uncomment once https://github.com/risc0/risc0/issues/3772 is resolved.
# default = ["artifacts"]
@ -78,6 +108,11 @@ programs = [
"dep:faucet_core",
"dep:token_core",
"dep:vault_core",
"dep:cross_zone_inbox_core",
"dep:cross_zone_outbox_core",
"dep:bridge_lock_core",
"dep:wrapped_token_core",
"dep:ping_core",
]
[dependencies]
@ -92,6 +127,11 @@ clock_core = { workspace = true, optional = true }
faucet_core = { workspace = true, optional = true }
token_core = { workspace = true, optional = true }
vault_core = { workspace = true, optional = true }
cross_zone_inbox_core = { workspace = true, optional = true }
cross_zone_outbox_core = { workspace = true, optional = true }
bridge_lock_core = { workspace = true, optional = true }
wrapped_token_core = { workspace = true, optional = true }
ping_core = { workspace = true, optional = true }
amm_program = { path = "amm", optional = true }
associated_token_account_program = { path = "associated_token_account", optional = true }

View File

@ -0,0 +1,15 @@
[package]
name = "bridge_lock_program"
version = "0.1.0"
edition = "2024"
license = { workspace = true }
[lints]
workspace = true
[dependencies]
lee_core.workspace = true
bridge_lock_core.workspace = true
cross_zone_outbox_core.workspace = true
wrapped_token_core.workspace = true
risc0-zkvm.workspace = true

View File

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

View File

@ -0,0 +1,71 @@
//! Core types for the bridge-lock program, the source side of the cross-zone
//! token bridge. A holder locks part of their balance into an escrow and emits a
//! cross-zone message minting the wrapped token on the target zone.
use lee_core::{
account::AccountId,
program::{PdaSeed, ProgramId},
};
use serde::{Deserialize, Serialize};
const ESCROW_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/BridgeLockEscrow/0000/";
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Instruction {
/// Lock `amount` of the holder's balance and emit a cross-zone message
/// minting the wrapped token on `target_zone`. The emission fields mirror
/// `cross_zone_outbox::Instruction::Emit` so the watcher reads them directly.
///
/// Required accounts (3): holder holding (authorized), escrow PDA, outbox PDA.
Lock {
amount: u128,
target_zone: [u8; 32],
target_program_id: ProgramId,
target_accounts: Vec<[u8; 32]>,
payload: Vec<u8>,
outbox_program_id: ProgramId,
ordinal: u32,
},
}
/// PDA accumulating all locked balance on this zone.
#[must_use]
pub fn escrow_account_id(bridge_lock_id: ProgramId) -> AccountId {
AccountId::for_public_pda(&bridge_lock_id, &escrow_seed())
}
#[must_use]
pub const fn escrow_seed() -> PdaSeed {
PdaSeed::new(ESCROW_SEED_DOMAIN)
}
/// Reads a bridgeable balance from account data; empty data is a zero balance.
#[must_use]
pub fn read_balance(data: &[u8]) -> u128 {
if data.len() < 16 {
return 0;
}
u128::from_le_bytes(data[..16].try_into().unwrap_or_else(|_| unreachable!()))
}
#[must_use]
pub const fn balance_bytes(amount: u128) -> [u8; 16] {
amount.to_le_bytes()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn balance_round_trips() {
assert_eq!(read_balance(&balance_bytes(7)), 7);
assert_eq!(read_balance(&[]), 0);
}
#[test]
fn escrow_is_stable() {
let id: ProgramId = [4; 8];
assert_eq!(escrow_account_id(id), escrow_account_id(id));
}
}

View File

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

View File

@ -0,0 +1,12 @@
[package]
name = "cross_zone_inbox_program"
version = "0.1.0"
edition = "2024"
license = { workspace = true }
[lints]
workspace = true
[dependencies]
lee_core.workspace = true
cross_zone_inbox_core.workspace = true

View File

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

View File

@ -0,0 +1,203 @@
use std::collections::{BTreeMap, BTreeSet};
use borsh::{BorshDeserialize, BorshSerialize};
use lee_core::{
account::AccountId,
program::{PdaSeed, ProgramId},
};
use serde::{Deserialize, Serialize};
/// Source blocks per seen-set shard, so no single seen account grows without bound.
pub const EPOCH_BLOCKS: u64 = 10_000;
const MESSAGE_KEY_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneMsgKey/00000/";
const INBOX_CONFIG_SEED: [u8; 32] = *b"/LEZ/v0.3/CrossZoneInboxCfg/000/";
const INBOX_SEEN_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneInboxSeen/00/";
/// Raw 32-byte zone (channel) id; the host maps it to the zone-sdk `ChannelId`.
pub type ZoneId = [u8; 32];
/// Block-signing public key pinned per peer zone.
pub type ExpectedPubkey = [u8; 32];
/// Content-addressed replay key for a delivered message.
pub type MessageKey = [u8; 32];
/// A peer zone whose outbox a zone watches for inbound cross-zone messages.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CrossZonePeer {
/// The peer's Bedrock channel; its 32 bytes double as the peer's zone id.
pub channel_id: ZoneId,
/// Programs on the local zone a message from this peer is allowed to target.
pub allowed_targets: Vec<ProgramId>,
/// The peer's block-signing public key, pinned to reject blocks inscribed by
/// anyone other than that zone's sequencer. `None` skips the check (the
/// channel signer is still authenticated by the zone-sdk).
#[serde(default)]
pub expected_block_signing_pubkey: Option<[u8; 32]>,
}
/// Cross-zone configuration shared by a zone's sequencer (watcher) and indexer
/// (verifier): the peers it reads from Bedrock and, per peer, the local programs
/// they may deliver to.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CrossZoneConfig {
pub peers: Vec<CrossZonePeer>,
}
/// A finalized outbound message observed on a peer zone, addressed to a program
/// on this zone. The watcher fills it from the peer's block; it is never
/// self-reported by a user.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CrossZoneMessage {
pub src_zone: ZoneId,
pub src_block_id: u64,
pub src_tx_index: u32,
pub src_program_id: ProgramId,
pub target_program_id: ProgramId,
pub payload: Vec<u8>,
/// Reserved for a future source-state proof; MUST be `None` in v1.
pub l1_inclusion_witness: Option<Vec<u8>>,
}
/// Peer and per-peer target allowlists, plus this inbox's own zone id.
#[derive(
Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, BorshSerialize, BorshDeserialize,
)]
pub struct InboxConfig {
pub self_zone: ZoneId,
pub allowed_peers: BTreeMap<ZoneId, ExpectedPubkey>,
pub allowed_targets: BTreeMap<ZoneId, Vec<ProgramId>>,
}
impl InboxConfig {
/// Borsh-encoded form stored in the inbox config account.
#[must_use]
pub fn to_bytes(&self) -> Vec<u8> {
borsh::to_vec(self).expect("InboxConfig serializes")
}
/// Decodes an [`InboxConfig`] from account data.
pub fn from_bytes(bytes: &[u8]) -> borsh::io::Result<Self> {
borsh::from_slice(bytes)
}
}
/// The replay keys seen for one `(src_zone, epoch)` shard.
#[derive(Clone, Debug, Default, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
pub struct SeenShard(pub BTreeSet<MessageKey>);
impl SeenShard {
/// Decodes a shard from account data; empty data is an empty shard.
pub fn from_bytes(bytes: &[u8]) -> borsh::io::Result<Self> {
if bytes.is_empty() {
return Ok(Self::default());
}
borsh::from_slice(bytes)
}
#[must_use]
pub fn to_bytes(&self) -> Vec<u8> {
borsh::to_vec(self).expect("SeenShard serializes")
}
#[must_use]
pub fn contains(&self, key: &MessageKey) -> bool {
self.0.contains(key)
}
/// Inserts a key; returns true if it was newly inserted.
pub fn insert(&mut self, key: MessageKey) -> bool {
self.0.insert(key)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Instruction {
/// Delivers a finalized peer message to its target program.
Dispatch(CrossZoneMessage),
}
/// Content-addressed replay key for a delivered message.
///
/// Hashes `(src_zone, src_block_id, src_tx_index)` under a domain separator.
/// Watcher-independent and immune to proof malleability, since it keys on block
/// id plus index rather than a tx hash.
#[must_use]
pub fn message_key(src_zone: &ZoneId, src_block_id: u64, src_tx_index: u32) -> MessageKey {
use risc0_zkvm::sha::{Impl, Sha256 as _};
let mut bytes = [0_u8; 76];
bytes[..32].copy_from_slice(&MESSAGE_KEY_DOMAIN);
bytes[32..64].copy_from_slice(src_zone);
bytes[64..72].copy_from_slice(&src_block_id.to_le_bytes());
bytes[72..].copy_from_slice(&src_tx_index.to_le_bytes());
Impl::hash_bytes(&bytes)
.as_bytes()
.try_into()
.unwrap_or_else(|_| unreachable!())
}
/// The config account holding the allowlists.
#[must_use]
pub fn inbox_config_account_id(inbox_id: ProgramId) -> AccountId {
AccountId::for_public_pda(&inbox_id, &PdaSeed::new(INBOX_CONFIG_SEED))
}
/// The seen-set shard for the `(src_zone, epoch)` the message falls in.
#[must_use]
pub fn inbox_seen_shard_account_id(
inbox_id: ProgramId,
src_zone: &ZoneId,
src_block_id: u64,
) -> AccountId {
AccountId::for_public_pda(&inbox_id, &inbox_seen_shard_seed(src_zone, src_block_id))
}
/// Seed of the seen-shard PDA, exposed so the guest can claim the account.
#[must_use]
pub fn inbox_seen_shard_seed(src_zone: &ZoneId, src_block_id: u64) -> PdaSeed {
use risc0_zkvm::sha::{Impl, Sha256 as _};
let src_epoch = src_block_id.wrapping_div(EPOCH_BLOCKS);
let mut bytes = [0_u8; 72];
bytes[..32].copy_from_slice(&INBOX_SEEN_SEED_DOMAIN);
bytes[32..64].copy_from_slice(src_zone);
bytes[64..].copy_from_slice(&src_epoch.to_le_bytes());
let seed: [u8; 32] = Impl::hash_bytes(&bytes)
.as_bytes()
.try_into()
.unwrap_or_else(|_| unreachable!());
PdaSeed::new(seed)
}
#[cfg(test)]
mod tests {
use super::*;
fn zone(b: u8) -> ZoneId {
[b; 32]
}
#[test]
fn message_key_is_stable_and_content_addressed() {
assert_eq!(message_key(&zone(1), 7, 3), message_key(&zone(1), 7, 3));
assert_ne!(message_key(&zone(1), 7, 3), message_key(&zone(2), 7, 3));
assert_ne!(message_key(&zone(1), 7, 3), message_key(&zone(1), 8, 3));
assert_ne!(message_key(&zone(1), 7, 3), message_key(&zone(1), 7, 4));
}
#[test]
fn seen_shards_split_on_epoch_boundary() {
let id: ProgramId = [9; 8];
assert_eq!(
inbox_seen_shard_account_id(id, &zone(1), 0),
inbox_seen_shard_account_id(id, &zone(1), EPOCH_BLOCKS - 1),
);
assert_ne!(
inbox_seen_shard_account_id(id, &zone(1), EPOCH_BLOCKS - 1),
inbox_seen_shard_account_id(id, &zone(1), EPOCH_BLOCKS),
);
}
}

View File

@ -0,0 +1,125 @@
use cross_zone_inbox_core::{
InboxConfig, Instruction, SeenShard, inbox_config_account_id, inbox_seen_shard_account_id,
inbox_seen_shard_seed, message_key,
};
use lee_core::{
account::AccountWithMetadata,
program::{AccountPostState, ChainedCall, Claim, ProgramInput, ProgramOutput, read_lee_inputs},
};
fn unchanged(pre: &AccountWithMetadata) -> AccountPostState {
AccountPostState::new(pre.account.clone())
}
fn main() {
let (
ProgramInput {
self_program_id,
caller_program_id,
pre_states,
instruction,
},
instruction_words,
) = read_lee_inputs::<Instruction>();
assert!(
caller_program_id.is_none(),
"Inbox is only invoked as a top-level sequencer-origin transaction"
);
let Instruction::Dispatch(msg) = instruction;
assert!(
msg.l1_inclusion_witness.is_none(),
"l1_inclusion_witness must be None in v1"
);
// pre_states layout: [config, seen_shard, then the target accounts].
let mut accounts = pre_states.into_iter();
let config = accounts.next().expect("config account required");
let seen = accounts.next().expect("seen shard account required");
let target_accounts: Vec<AccountWithMetadata> = accounts.collect();
assert_eq!(
config.account_id,
inbox_config_account_id(self_program_id),
"First account must be the inbox config PDA"
);
assert_eq!(
seen.account_id,
inbox_seen_shard_account_id(self_program_id, &msg.src_zone, msg.src_block_id),
"Second account must be the seen-shard PDA"
);
let cfg = InboxConfig::from_bytes(&config.account.data.clone().into_inner())
.expect("inbox config decodes");
assert!(
msg.src_zone != cfg.self_zone,
"Source zone must not be this zone"
);
let allowed_targets = cfg
.allowed_targets
.get(&msg.src_zone)
.expect("Source zone is not an allowed peer");
assert!(
allowed_targets.contains(&msg.target_program_id),
"Target program is not allowed for this peer"
);
let key = message_key(&msg.src_zone, msg.src_block_id, msg.src_tx_index);
let mut shard =
SeenShard::from_bytes(&seen.account.data.clone().into_inner()).expect("seen shard decodes");
let already_seen = shard.contains(&key);
// On replay this is a no-op: the seen shard is untouched and no call is made.
let (seen_post, chained_calls) = if already_seen {
(unchanged(&seen), vec![])
} else {
shard.insert(key);
let mut seen_account = seen.account.clone();
seen_account.data = shard
.to_bytes()
.try_into()
.expect("seen shard fits in account data");
let seen_post = AccountPostState::new_claimed_if_default(
seen_account,
Claim::Pda(inbox_seen_shard_seed(&msg.src_zone, msg.src_block_id)),
);
// The payload carries the target instruction as risc0 words, little-endian.
assert!(
msg.payload.len().is_multiple_of(4),
"payload must be u32-aligned instruction words"
);
let instruction_data = msg
.payload
.chunks_exact(4)
.map(|c| u32::from_le_bytes(c.try_into().unwrap_or_else(|_| unreachable!())))
.collect();
let call = ChainedCall {
program_id: msg.target_program_id,
pre_states: target_accounts.clone(),
instruction_data,
pda_seeds: vec![],
};
(seen_post, vec![call])
};
let mut post_states = vec![unchanged(&config), seen_post];
post_states.extend(target_accounts.iter().map(unchanged));
let mut output_pre_states = vec![config, seen];
output_pre_states.extend(target_accounts);
ProgramOutput::new(
self_program_id,
caller_program_id,
instruction_words,
output_pre_states,
post_states,
)
.with_chained_calls(chained_calls)
.write();
}

View File

@ -0,0 +1,12 @@
[package]
name = "cross_zone_outbox_program"
version = "0.1.0"
edition = "2024"
license = { workspace = true }
[lints]
workspace = true
[dependencies]
lee_core.workspace = true
cross_zone_outbox_core.workspace = true

View File

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

View File

@ -0,0 +1,93 @@
use borsh::{BorshDeserialize, BorshSerialize};
use lee_core::{
account::AccountId,
program::{PdaSeed, ProgramId},
};
use serde::{Deserialize, Serialize};
const OUTBOX_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneOutbox/00000/";
/// Raw 32-byte zone (channel) id; the host maps it to the zone-sdk `ChannelId`.
pub type ZoneId = [u8; 32];
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Instruction {
/// Records an outbound cross-zone message as a write to a self-owned PDA.
///
/// Required accounts (1):
/// - Outbox PDA account
Emit {
target_zone: ZoneId,
target_program_id: ProgramId,
/// Accounts the destination inbox must hand to the target program's
/// chained call. The emitter specifies them; the watcher forwards them
/// verbatim so the inbox stays target-agnostic.
target_accounts: Vec<[u8; 32]>,
payload: Vec<u8>,
ordinal: u32,
},
}
/// The message as stored in an outbox PDA. The destination zone's watcher reads
/// this from the inscribed block; the source coordinates are filled by the
/// watcher, not stored here.
#[derive(Clone, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
pub struct OutboxRecord {
pub target_zone: ZoneId,
pub target_program_id: ProgramId,
pub target_accounts: Vec<[u8; 32]>,
pub payload: Vec<u8>,
}
impl OutboxRecord {
/// Borsh-encoded form stored in the outbox PDA's account data.
#[must_use]
pub fn to_bytes(&self) -> Vec<u8> {
borsh::to_vec(self).expect("OutboxRecord serializes")
}
/// Decodes an [`OutboxRecord`] from account data.
pub fn from_bytes(bytes: &[u8]) -> borsh::io::Result<Self> {
borsh::from_slice(bytes)
}
}
/// PDA holding one emitted message, keyed by destination zone and a per-zone
/// ordinal.
#[must_use]
pub fn outbox_pda(outbox_id: ProgramId, target_zone: &ZoneId, ordinal: u32) -> AccountId {
AccountId::for_public_pda(&outbox_id, &outbox_pda_seed(target_zone, ordinal))
}
/// Seed of an outbox message PDA, exposed so the guest can claim the account.
#[must_use]
pub fn outbox_pda_seed(target_zone: &ZoneId, ordinal: u32) -> PdaSeed {
use risc0_zkvm::sha::{Impl, Sha256 as _};
let mut bytes = [0_u8; 68];
bytes[..32].copy_from_slice(&OUTBOX_SEED_DOMAIN);
bytes[32..64].copy_from_slice(target_zone);
bytes[64..].copy_from_slice(&ordinal.to_le_bytes());
let seed: [u8; 32] = Impl::hash_bytes(&bytes)
.as_bytes()
.try_into()
.unwrap_or_else(|_| unreachable!());
PdaSeed::new(seed)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn outbox_pda_is_unique_per_zone_and_ordinal() {
let id: ProgramId = [3; 8];
let zone_a = [1; 32];
let zone_b = [2; 32];
assert_eq!(outbox_pda(id, &zone_a, 0), outbox_pda(id, &zone_a, 0));
assert_ne!(outbox_pda(id, &zone_a, 0), outbox_pda(id, &zone_a, 1));
assert_ne!(outbox_pda(id, &zone_a, 0), outbox_pda(id, &zone_b, 0));
}
}

View File

@ -0,0 +1,72 @@
use cross_zone_outbox_core::{Instruction, OutboxRecord, outbox_pda, outbox_pda_seed};
use lee_core::{
account::AccountWithMetadata,
program::{AccountPostState, Claim, ProgramInput, ProgramOutput, read_lee_inputs},
};
fn main() {
let (
ProgramInput {
self_program_id,
caller_program_id,
pre_states,
instruction,
},
instruction_words,
) = read_lee_inputs::<Instruction>();
assert!(
caller_program_id.is_some(),
"Outbox is only callable through a chain call from a user program"
);
let (target_zone, target_program_id, target_accounts, payload, ordinal) = match instruction {
Instruction::Emit {
target_zone,
target_program_id,
target_accounts,
payload,
ordinal,
} => (
target_zone,
target_program_id,
target_accounts,
payload,
ordinal,
),
};
let [outbox] =
<[AccountWithMetadata; 1]>::try_from(pre_states).expect("Emit requires exactly 1 account");
assert_eq!(
outbox.account_id,
outbox_pda(self_program_id, &target_zone, ordinal),
"Account must be the outbox PDA for (target_zone, ordinal)"
);
let mut post_account = outbox.account.clone();
post_account.data = OutboxRecord {
target_zone,
target_program_id,
target_accounts,
payload,
}
.to_bytes()
.try_into()
.expect("OutboxRecord fits in account data");
let post = AccountPostState::new_claimed_if_default(
post_account,
Claim::Pda(outbox_pda_seed(&target_zone, ordinal)),
);
ProgramOutput::new(
self_program_id,
caller_program_id,
instruction_words,
vec![outbox],
vec![post],
)
.write();
}

View File

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

View File

@ -0,0 +1,38 @@
use lee_core::{
account::AccountId,
program::{PdaSeed, ProgramId},
};
use serde::{Deserialize, Serialize};
const PING_RECORD_SEED: [u8; 32] = *b"/LEZ/v0.3/PingRecord/0000000000/";
/// Instruction delivered to `ping_receiver` by the inbox: record the payload.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum ReceiverInstruction {
Record { payload: Vec<u8> },
}
/// Instruction to `ping_sender`: forwarded verbatim into `cross_zone_outbox::Instruction::Emit`.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum SenderInstruction {
Send {
outbox_program_id: ProgramId,
target_zone: [u8; 32],
target_program_id: ProgramId,
target_accounts: Vec<[u8; 32]>,
payload: Vec<u8>,
ordinal: u32,
},
}
/// The account a `ping_receiver` records the latest delivered payload into.
#[must_use]
pub fn ping_record_pda(receiver_id: ProgramId) -> AccountId {
AccountId::for_public_pda(&receiver_id, &ping_record_seed())
}
/// Seed of the record PDA, exposed so the guest can claim the account.
#[must_use]
pub const fn ping_record_seed() -> PdaSeed {
PdaSeed::new(PING_RECORD_SEED)
}

View File

@ -0,0 +1,12 @@
[package]
name = "ping_receiver_program"
version = "0.1.0"
edition = "2024"
license = { workspace = true }
[lints]
workspace = true
[dependencies]
lee_core.workspace = true
ping_core.workspace = true

View File

@ -0,0 +1,48 @@
use lee_core::{
account::AccountWithMetadata,
program::{AccountPostState, Claim, ProgramInput, ProgramOutput, read_lee_inputs},
};
use ping_core::{ReceiverInstruction, ping_record_pda, ping_record_seed};
fn main() {
let (
ProgramInput {
self_program_id,
caller_program_id,
pre_states,
instruction,
},
instruction_words,
) = read_lee_inputs::<ReceiverInstruction>();
assert!(
caller_program_id.is_some(),
"ping_receiver is only callable through a chained call"
);
let payload = match instruction {
ReceiverInstruction::Record { payload } => payload,
};
let [record] = <[AccountWithMetadata; 1]>::try_from(pre_states)
.expect("Record requires exactly 1 account");
assert_eq!(
record.account_id,
ping_record_pda(self_program_id),
"Account must be the ping record PDA"
);
let mut post_account = record.account.clone();
post_account.data = payload.try_into().expect("payload fits in account data");
let post =
AccountPostState::new_claimed_if_default(post_account, Claim::Pda(ping_record_seed()));
ProgramOutput::new(
self_program_id,
caller_program_id,
instruction_words,
vec![record],
vec![post],
)
.write();
}

View File

@ -0,0 +1,13 @@
[package]
name = "ping_sender_program"
version = "0.1.0"
edition = "2024"
license = { workspace = true }
[lints]
workspace = true
[dependencies]
lee_core.workspace = true
ping_core.workspace = true
cross_zone_outbox_core.workspace = true

View File

@ -0,0 +1,59 @@
use cross_zone_outbox_core::Instruction as OutboxInstruction;
use lee_core::{
account::AccountWithMetadata,
program::{AccountPostState, ChainedCall, ProgramInput, ProgramOutput, read_lee_inputs},
};
use ping_core::SenderInstruction;
fn main() {
let (
ProgramInput {
self_program_id,
caller_program_id,
pre_states,
instruction,
},
instruction_words,
) = read_lee_inputs::<SenderInstruction>();
assert!(
caller_program_id.is_none(),
"ping_sender is only invoked as a top-level user transaction"
);
let SenderInstruction::Send {
outbox_program_id,
target_zone,
target_program_id,
target_accounts,
payload,
ordinal,
} = instruction;
// The single account is the outbox PDA the chained call writes into; the
// outbox claims it, so ping_sender forwards it unchanged.
let [outbox] =
<[AccountWithMetadata; 1]>::try_from(pre_states).expect("Send requires exactly 1 account");
let call = ChainedCall::new(
outbox_program_id,
vec![outbox.clone()],
&OutboxInstruction::Emit {
target_zone,
target_program_id,
target_accounts,
payload,
ordinal,
},
);
ProgramOutput::new(
self_program_id,
caller_program_id,
instruction_words,
vec![outbox.clone()],
vec![AccountPostState::new(outbox.account)],
)
.with_chained_calls(vec![call])
.write();
}

View File

@ -10,9 +10,12 @@ mod inner {
use guests::{
AMM_ELF, AMM_ID, ASSOCIATED_TOKEN_ACCOUNT_ELF, ASSOCIATED_TOKEN_ACCOUNT_ID,
AUTHENTICATED_TRANSFER_ELF, AUTHENTICATED_TRANSFER_ID, BRIDGE_ELF, BRIDGE_ID, CLOCK_ELF,
CLOCK_ID, FAUCET_ELF, FAUCET_ID, PINATA_ELF, PINATA_ID, PINATA_TOKEN_ELF, PINATA_TOKEN_ID,
TOKEN_ELF, TOKEN_ID, VAULT_ELF, VAULT_ID,
AUTHENTICATED_TRANSFER_ELF, AUTHENTICATED_TRANSFER_ID, BRIDGE_ELF, BRIDGE_ID,
BRIDGE_LOCK_ELF, BRIDGE_LOCK_ID, CLOCK_ELF, CLOCK_ID, CROSS_ZONE_INBOX_ELF,
CROSS_ZONE_INBOX_ID, CROSS_ZONE_OUTBOX_ELF, CROSS_ZONE_OUTBOX_ID, FAUCET_ELF, FAUCET_ID,
PINATA_ELF, PINATA_ID, PINATA_TOKEN_ELF, PINATA_TOKEN_ID, PING_RECEIVER_ELF,
PING_RECEIVER_ID, PING_SENDER_ELF, PING_SENDER_ID, TOKEN_ELF, TOKEN_ID, VAULT_ELF,
VAULT_ID, WRAPPED_TOKEN_ELF, WRAPPED_TOKEN_ID,
};
use lee::program::Program;
@ -87,6 +90,42 @@ mod inner {
Program::new_unchecked(BRIDGE_ID, Cow::Borrowed(BRIDGE_ELF))
}
#[must_use]
#[inline]
pub const fn cross_zone_outbox() -> Program {
Program::new_unchecked(CROSS_ZONE_OUTBOX_ID, Cow::Borrowed(CROSS_ZONE_OUTBOX_ELF))
}
#[must_use]
#[inline]
pub const fn cross_zone_inbox() -> Program {
Program::new_unchecked(CROSS_ZONE_INBOX_ID, Cow::Borrowed(CROSS_ZONE_INBOX_ELF))
}
#[must_use]
#[inline]
pub const fn ping_sender() -> Program {
Program::new_unchecked(PING_SENDER_ID, Cow::Borrowed(PING_SENDER_ELF))
}
#[must_use]
#[inline]
pub const fn ping_receiver() -> Program {
Program::new_unchecked(PING_RECEIVER_ID, Cow::Borrowed(PING_RECEIVER_ELF))
}
#[must_use]
#[inline]
pub const fn bridge_lock() -> Program {
Program::new_unchecked(BRIDGE_LOCK_ID, Cow::Borrowed(BRIDGE_LOCK_ELF))
}
#[must_use]
#[inline]
pub const fn wrapped_token() -> Program {
Program::new_unchecked(WRAPPED_TOKEN_ID, Cow::Borrowed(WRAPPED_TOKEN_ELF))
}
#[cfg(test)]
mod tests {
use super::*;
@ -127,6 +166,12 @@ mod inner {
(PINATA_TOKEN_ELF, PINATA_TOKEN_ID),
(TOKEN_ELF, TOKEN_ID),
(VAULT_ELF, VAULT_ID),
(CROSS_ZONE_OUTBOX_ELF, CROSS_ZONE_OUTBOX_ID),
(CROSS_ZONE_INBOX_ELF, CROSS_ZONE_INBOX_ID),
(PING_SENDER_ELF, PING_SENDER_ID),
(PING_RECEIVER_ELF, PING_RECEIVER_ID),
(BRIDGE_LOCK_ELF, BRIDGE_LOCK_ID),
(WRAPPED_TOKEN_ELF, WRAPPED_TOKEN_ID),
];
for (elf, expected_id) in cases {
let program = Program::new((*elf).into()).unwrap();

View File

@ -0,0 +1,12 @@
[package]
name = "wrapped_token_program"
version = "0.1.0"
edition = "2024"
license = { workspace = true }
[lints]
workspace = true
[dependencies]
lee_core.workspace = true
wrapped_token_core.workspace = true

View File

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

View File

@ -0,0 +1,121 @@
//! Core types for the wrapped-token program, the destination side of the
//! cross-zone bridge. Only the cross-zone inbox may mint; the guest enforces
//! this by reading the authorized minter from a genesis-seeded config account.
use lee_core::{
account::AccountId,
program::{PdaSeed, ProgramId},
};
use serde::{Deserialize, Serialize};
const CONFIG_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/WrappedTokenConfig/00/";
const HOLDING_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/WrappedTokenHold/00000";
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Instruction {
/// Credit `amount` wrapped tokens to `recipient`'s holding. Delivered only by
/// the cross-zone inbox.
///
/// Required accounts (2): the wrapped-token config PDA, then the recipient's
/// holding PDA.
Mint { recipient: [u8; 32], amount: u128 },
}
/// PDA holding the authorized minter program id (the cross-zone inbox), seeded at
/// genesis so the guest can pin its caller without importing the inbox image id.
#[must_use]
pub fn config_account_id(wrapped_token_id: ProgramId) -> AccountId {
AccountId::for_public_pda(&wrapped_token_id, &config_seed())
}
#[must_use]
pub const fn config_seed() -> PdaSeed {
PdaSeed::new(CONFIG_SEED_DOMAIN)
}
/// PDA holding one recipient's wrapped-token balance.
#[must_use]
pub fn holding_account_id(wrapped_token_id: ProgramId, recipient: &[u8; 32]) -> AccountId {
AccountId::for_public_pda(&wrapped_token_id, &holding_seed(recipient))
}
#[must_use]
pub fn holding_seed(recipient: &[u8; 32]) -> PdaSeed {
use risc0_zkvm::sha::{Impl, Sha256 as _};
let mut bytes = [0_u8; 64];
bytes[..32].copy_from_slice(&HOLDING_SEED_DOMAIN);
bytes[32..].copy_from_slice(recipient);
let seed: [u8; 32] = Impl::hash_bytes(&bytes)
.as_bytes()
.try_into()
.unwrap_or_else(|_| unreachable!());
PdaSeed::new(seed)
}
/// Encodes the authorized minter program id for the config account's data.
#[must_use]
pub fn minter_bytes(minter: ProgramId) -> [u8; 32] {
let mut bytes = [0_u8; 32];
for (word, chunk) in minter.iter().zip(bytes.chunks_exact_mut(4)) {
chunk.copy_from_slice(&word.to_le_bytes());
}
bytes
}
/// Decodes the authorized minter program id from the config account's data.
#[must_use]
pub fn read_minter(data: &[u8]) -> Option<ProgramId> {
if data.len() < 32 {
return None;
}
let mut minter = [0_u32; 8];
for (word, chunk) in minter.iter_mut().zip(data[..32].chunks_exact(4)) {
*word = u32::from_le_bytes(chunk.try_into().unwrap_or_else(|_| unreachable!()));
}
Some(minter)
}
/// Reads a wrapped-token balance from account data; empty data is a zero balance.
#[must_use]
pub fn read_balance(data: &[u8]) -> u128 {
if data.len() < 16 {
return 0;
}
u128::from_le_bytes(data[..16].try_into().unwrap_or_else(|_| unreachable!()))
}
#[must_use]
pub const fn balance_bytes(amount: u128) -> [u8; 16] {
amount.to_le_bytes()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn minter_round_trips() {
let minter: ProgramId = [1, 2, 3, 4, 5, 6, 7, 8];
assert_eq!(read_minter(&minter_bytes(minter)), Some(minter));
}
#[test]
fn balance_round_trips() {
assert_eq!(read_balance(&balance_bytes(42)), 42);
assert_eq!(read_balance(&[]), 0);
}
#[test]
fn holding_is_unique_per_recipient() {
let id: ProgramId = [9; 8];
assert_ne!(
holding_account_id(id, &[1; 32]),
holding_account_id(id, &[2; 32])
);
assert_eq!(
holding_account_id(id, &[1; 32]),
holding_account_id(id, &[1; 32])
);
}
}

View File

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

View File

@ -20,6 +20,8 @@ bridge_core.workspace = true
vault_core.workspace = true
programs.workspace = true
system_accounts.workspace = true
cross_zone.workspace = true
cross_zone_inbox_core.workspace = true
logos-blockchain-key-management-system-service.workspace = true
logos-blockchain-core.workspace = true
@ -39,6 +41,7 @@ url.workspace = true
num-bigint.workspace = true
risc0-zkvm.workspace = true
futures.workspace = true
itertools.workspace = true
[features]
default = []

View File

@ -7,18 +7,19 @@ use common::{
transaction::LeeTransaction,
};
use lee::V03State;
use lee_core::BlockId;
use log::info;
use logos_blockchain_zone_sdk::sequencer::SequencerCheckpoint;
pub use storage::DbResult;
use storage::sequencer::{
RocksDBIO,
sequencer_cells::{PendingDepositEventRecord, WithdrawalReconciliationKey},
};
pub use storage::{DbResult, sequencer::DbDump};
pub struct SequencerStore {
dbio: Arc<RocksDBIO>,
// TODO: Consider adding the hashmap to the database for faster recovery.
tx_hash_to_block_map: HashMap<HashType, u64>,
tx_hash_to_block_map: HashMap<HashType, BlockId>,
genesis_id: u64,
signing_key: lee::PrivateKey,
}
@ -96,7 +97,7 @@ impl SequencerStore {
/// Returns the transaction corresponding to the given hash, if it exists in the blockchain.
#[must_use]
pub fn get_transaction_by_hash(&self, hash: HashType) -> Option<LeeTransaction> {
pub fn get_transaction_by_hash(&self, hash: HashType) -> Option<(LeeTransaction, BlockId)> {
let block_id = *self.tx_hash_to_block_map.get(&hash)?;
let block = self
.get_block_at_id(block_id)
@ -105,7 +106,7 @@ impl SequencerStore {
.expect("Block should be present since the hash is in the map");
for transaction in block.body.transactions {
if transaction.hash() == hash {
return Some(transaction);
return Some((transaction, block_id));
}
}
panic!(
@ -149,6 +150,28 @@ impl SequencerStore {
self.dbio.get_lee_state()
}
/// Remove the persisted zone-sdk checkpoint so the next startup is treated as a fresh start.
pub fn delete_zone_checkpoint(&self) -> DbResult<()> {
self.dbio.delete_zone_sdk_checkpoint_bytes()
}
/// Reset every stored block to `Pending` so the next fresh start republishes the whole chain.
pub fn reset_all_blocks_to_pending(&self) -> DbResult<()> {
self.dbio.reset_all_blocks_to_pending()
}
/// Single-blob [`DbDump`] of the whole store; restore with [`Self::restore_db_from_dump`].
pub fn dump(&self) -> DbResult<DbDump> {
self.dbio.dump_all()
}
/// Create a fresh rocksdb at `location` from `dump`, closing it before returning so a sequencer
/// can open it normally afterwards.
pub fn restore_db_from_dump(location: &Path, dump: &DbDump) -> DbResult<()> {
RocksDBIO::restore_from_dump(location, dump)?;
Ok(())
}
pub fn get_zone_checkpoint(&self) -> Result<Option<SequencerCheckpoint>> {
let Some(bytes) = self.dbio.get_zone_sdk_checkpoint_bytes()? else {
return Ok(None);
@ -181,8 +204,6 @@ pub(crate) fn block_to_transactions_map(block: &Block) -> HashMap<HashType, u64>
#[cfg(test)]
mod tests {
#![expect(clippy::shadow_unrelated, reason = "We don't care about it in tests")]
use common::{block::HashableBlockData, test_utils::sequencer_sign_key_for_testing};
use tempfile::tempdir;
@ -224,8 +245,8 @@ mod tests {
.update(&block, &[], vec![], &dummy_state)
.unwrap();
// Try again
let retrieved_tx = node_store.get_transaction_by_hash(tx.hash());
assert_eq!(Some(tx), retrieved_tx);
let output = node_store.get_transaction_by_hash(tx.hash());
assert_eq!(Some((tx, 1)), output);
}
#[test]
@ -383,7 +404,7 @@ mod tests {
// Re-open the store and verify that the transaction is still retrievable (which means it
// was cached correctly)
let node_store = SequencerStore::open_db(path, signing_key).unwrap();
let retrieved_tx = node_store.get_transaction_by_hash(tx.hash());
assert_eq!(Some(tx), retrieved_tx);
let output = node_store.get_transaction_by_hash(tx.hash());
assert_eq!(Some((tx, 1)), output);
}
}

View File

@ -8,6 +8,7 @@ use std::{
use anyhow::Result;
use bytesize::ByteSize;
use common::config::BasicAuth;
pub use cross_zone_inbox_core::{CrossZoneConfig, CrossZonePeer};
use humantime_serde;
use lee::AccountId;
use logos_blockchain_core::mantle::ops::channel::ChannelId;
@ -25,6 +26,11 @@ pub enum GenesisAction {
SupplyBridgeAccount {
balance: u128,
},
/// Seeds a bridge-lock holder's initial bridgeable balance into genesis state.
SupplyBridgeLockHolding {
holder: AccountId,
amount: u128,
},
}
// TODO: Provide default values
@ -53,6 +59,9 @@ pub struct SequencerConfig {
/// Genesis configuration.
#[serde(default)]
pub genesis: Vec<GenesisAction>,
/// Cross-zone messaging configuration. `None` disables the watcher.
#[serde(default)]
pub cross_zone: Option<CrossZoneConfig>,
}
#[derive(Clone, Serialize, Deserialize)]

View File

@ -0,0 +1,185 @@
use std::time::Duration;
use common::{block::Block, transaction::LeeTransaction};
use cross_zone::{build_dispatch_from_emission, extract_emission};
use futures::StreamExt as _;
use lee::PublicKey;
use lee_core::program::ProgramId;
use log::{debug, error, info, warn};
use logos_blockchain_core::mantle::ops::channel::ChannelId;
use logos_blockchain_zone_sdk::{
CommonHttpClient, ZoneMessage, adapter::NodeHttpClient, indexer::ZoneIndexer,
};
use mempool::MemPoolHandle;
use crate::{
TransactionOrigin,
config::{BedrockConfig, CrossZoneConfig},
};
/// Spawns one watcher task per configured peer.
///
/// Each task reads the peer's finalized blocks from Bedrock, recognizes outbound
/// messages addressed to this zone, and injects the matching inbox dispatch as a
/// sequencer-origin transaction into the local mempool.
pub fn spawn_watchers(
bedrock_config: &BedrockConfig,
cross_zone: &CrossZoneConfig,
poll_interval: Duration,
mempool_handle: &MemPoolHandle<(TransactionOrigin, LeeTransaction)>,
) {
let self_zone: [u8; 32] = *bedrock_config.channel_id.as_ref();
for peer in cross_zone.peers.clone() {
let node = NodeHttpClient::new(
CommonHttpClient::new(bedrock_config.auth.clone().map(Into::into)),
bedrock_config.node_url.clone(),
);
let expected_pubkey = peer.expected_block_signing_pubkey.map(|bytes| {
PublicKey::try_new(bytes).expect("configured peer block-signing pubkey is a valid key")
});
tokio::spawn(watch_peer(
ZoneIndexer::new(ChannelId::from(peer.channel_id), node),
peer.channel_id,
peer.allowed_targets,
expected_pubkey,
self_zone,
poll_interval,
mempool_handle.clone(),
));
}
}
#[expect(
clippy::infinite_loop,
reason = "the peer watcher runs for the lifetime of the sequencer process"
)]
async fn watch_peer(
zone_indexer: ZoneIndexer<NodeHttpClient>,
peer_zone: [u8; 32],
allowed_targets: Vec<ProgramId>,
expected_pubkey: Option<PublicKey>,
self_zone: [u8; 32],
poll_interval: Duration,
mempool_handle: MemPoolHandle<(TransactionOrigin, LeeTransaction)>,
) {
info!(
"Cross-zone watcher started for peer {}",
hex::encode(peer_zone)
);
let mut cursor = None;
loop {
let stream = match zone_indexer.next_messages(cursor).await {
Ok(stream) => stream,
Err(err) => {
error!(
"Watcher next_messages failed for peer {}: {err}",
hex::encode(peer_zone)
);
tokio::time::sleep(poll_interval).await;
continue;
}
};
let mut stream = std::pin::pin!(stream);
while let Some((msg, slot)) = stream.next().await {
let zone_block = match msg {
ZoneMessage::Block(block) => block,
ZoneMessage::Deposit(_) | ZoneMessage::Withdraw(_) => continue,
};
match borsh::from_slice::<Block>(&zone_block.data) {
Ok(block) => {
debug!(
"Watcher observed finalized peer {} block {}",
hex::encode(peer_zone),
block.header.block_id
);
// Reject blocks not signed by the pinned peer key (equivocation):
// the channel signer is authenticated by the zone-sdk, but that
// does not prove the peer's honest sequencer produced the block.
if expected_pubkey
.as_ref()
.is_some_and(|pk| !block.is_signed_by(pk))
{
warn!(
"Watcher dropping peer {} block {}: block-signing key does not match the pinned key",
hex::encode(peer_zone),
block.header.block_id
);
} else {
deliver_block(
&block,
peer_zone,
self_zone,
&allowed_targets,
&mempool_handle,
)
.await;
}
}
Err(err) => error!("Watcher failed to deserialize peer block: {err}"),
}
cursor = Some(slot);
}
// Stream ended (caught up to the peer's last finalized block); poll again.
tokio::time::sleep(poll_interval).await;
}
}
/// Scans one peer block for outbound messages and injects a dispatch per match.
async fn deliver_block(
block: &Block,
peer_zone: [u8; 32],
self_zone: [u8; 32],
allowed_targets: &[ProgramId],
mempool_handle: &MemPoolHandle<(TransactionOrigin, LeeTransaction)>,
) {
for (index, tx) in block.body.transactions.iter().enumerate() {
let LeeTransaction::Public(public_tx) = tx else {
continue;
};
let message = public_tx.message();
let Some(emission) = extract_emission(message.program_id, &message.instruction_data) else {
continue;
};
if emission.target_zone != self_zone {
continue;
}
if !allowed_targets.contains(&emission.target_program_id) {
warn!(
"Watcher dropping message to disallowed target from peer {}",
hex::encode(peer_zone)
);
continue;
}
let dispatch = build_dispatch_from_emission(
peer_zone,
block.header.block_id,
u32::try_from(index).unwrap_or(u32::MAX),
message.program_id,
emission.target_program_id,
&emission.target_accounts,
emission.payload,
);
match mempool_handle
.push((
TransactionOrigin::Sequencer,
LeeTransaction::Public(dispatch),
))
.await
{
Ok(()) => info!(
"Watcher injected cross-zone dispatch from peer {} block {} tx {}",
hex::encode(peer_zone),
block.header.block_id,
index
),
Err(err) => error!("Watcher failed to enqueue inbox dispatch: {err}"),
}
}
}

View File

@ -8,6 +8,7 @@ use common::{
transaction::{LeeTransaction, clock_invocation},
};
use config::{GenesisAction, SequencerConfig};
use itertools::Itertools as _;
use lee::{AccountId, PublicTransaction, public_transaction::Message};
use lee_core::GENESIS_BLOCK_ID;
use log::{error, info, warn};
@ -31,6 +32,7 @@ use crate::{
pub mod block_publisher;
pub mod block_store;
pub mod config;
pub mod cross_zone_watcher;
#[cfg(feature = "mock")]
pub mod mock;
@ -124,7 +126,7 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
load_or_create_signing_key(&config.home.join("bedrock_signing_key"))
.expect("Failed to load or create bedrock signing key");
let (store, state, genesis_block) = Self::open_or_create_store(&config);
let (store, state, _genesis_block) = Self::open_or_create_store(&config);
let latest_block_meta = store
.latest_block_meta()
@ -151,15 +153,44 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
.await
.expect("Failed to initialize Block Publisher");
// On a truly fresh start (no checkpoint persisted yet), publish the
// genesis block so the indexer can find the channel start. After the
// first publish, zone-sdk's checkpoint persistence covers further
// restarts.
// Fresh start (no checkpoint): republish all pending blocks
if is_fresh_start {
block_publisher
.publish_block(&genesis_block, vec![])
.await
.expect("Failed to publish genesis block");
let mut pending_blocks = store
.get_all_blocks()
.filter_ok(|block| matches!(block.bedrock_status, BedrockStatus::Pending))
.collect::<Result<Vec<_>, _>>()
.expect("Failed to read blocks from store while republishing on fresh start");
pending_blocks.sort_unstable_by_key(|block| block.header.block_id);
assert!(
pending_blocks
.first()
.is_none_or(|block| block.header.block_id == GENESIS_BLOCK_ID),
"First pending block on fresh start should be the genesis block"
);
for block in &pending_blocks {
block_publisher
.publish_block(block, vec![])
.await
.unwrap_or_else(|err| {
panic!(
"Failed to publish block {} on fresh start: {err:#}",
block.header.block_id
)
});
}
}
// 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 {
@ -609,14 +640,18 @@ fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec<LeeTrans
let genesis_txs = config
.genesis
.iter()
.map(|genesis_tx| match genesis_tx {
.filter_map(|genesis_tx| match genesis_tx {
GenesisAction::SupplyAccount {
account_id,
balance,
} => build_supply_account_genesis_transaction(account_id, *balance),
} => Some(build_supply_account_genesis_transaction(
account_id, *balance,
)),
GenesisAction::SupplyBridgeAccount { balance } => {
build_supply_bridge_account_genesis_transaction(*balance)
Some(build_supply_bridge_account_genesis_transaction(*balance))
}
// Force-inserted below: bridge_lock has no mint transaction.
GenesisAction::SupplyBridgeLockHolding { .. } => None,
})
.chain(std::iter::once(clock_invocation(0)))
.inspect(|tx| {
@ -627,9 +662,36 @@ fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec<LeeTrans
.map(LeeTransaction::Public)
.collect();
// Seed bridge-lock holder balances directly: they are not produced by any tx.
for action in &config.genesis {
if let GenesisAction::SupplyBridgeLockHolding { holder, amount } = action {
let (holder_id, account) = cross_zone::build_holding_account(*holder, *amount);
state.insert_genesis_account(holder_id, account);
}
}
// Seed this zone's cross-zone inbox config so the inbox guest can authorize
// inbound peer messages (zone-specific config, not produced by any tx).
if let Some(cross_zone) = &config.cross_zone {
let self_zone = *config.bedrock_config.channel_id.as_ref();
let (config_id, config_account) =
cross_zone::build_inbox_config_account(self_zone, cross_zone);
state.insert_genesis_account(config_id, config_account);
}
(state, genesis_txs)
}
/// Whether a program may only be invoked by sequencer-origin transactions.
///
/// The cross-zone inbox is injected solely by the watcher; a user-submitted call
/// must be rejected at ingress, since `TransactionOrigin` is not carried in the
/// block.
#[must_use]
pub fn is_sequencer_only_program(program_id: lee::ProgramId) -> bool {
cross_zone::is_sequencer_only_program(program_id)
}
fn build_supply_account_genesis_transaction(
account_id: &AccountId,
balance: u128,

View File

@ -33,6 +33,7 @@ use crate::{
block_store::SequencerStore,
build_genesis_state,
config::{BedrockConfig, SequencerConfig},
is_sequencer_only_program,
mock::SequencerCoreWithMockClients,
};
@ -59,9 +60,21 @@ fn setup_sequencer_config() -> SequencerConfig {
},
retry_pending_blocks_timeout: Duration::from_mins(4),
genesis: vec![],
cross_zone: None,
}
}
#[test]
fn only_the_cross_zone_inbox_is_sequencer_only() {
assert!(is_sequencer_only_program(programs::cross_zone_inbox().id()));
assert!(!is_sequencer_only_program(
programs::cross_zone_outbox().id()
));
assert!(!is_sequencer_only_program(programs::wrapped_token().id()));
assert!(!is_sequencer_only_program(programs::ping_sender().id()));
assert!(!is_sequencer_only_program(programs::clock().id()));
}
fn create_signing_key_for_account1() -> lee::PrivateKey {
initial_pub_accounts_private_keys()[0].pub_sign_key.clone()
}

View File

@ -68,7 +68,7 @@ pub trait Rpc {
async fn get_transaction(
&self,
tx_hash: HashType,
) -> Result<Option<LeeTransaction>, ErrorObjectOwned>;
) -> Result<Option<(LeeTransaction, BlockId)>, ErrorObjectOwned>;
#[method(name = "getAccountsNonces")]
async fn get_accounts_nonces(

View File

@ -24,6 +24,8 @@ async fn main() -> Result<()> {
let Args { config_path, port } = Args::parse();
// TODO: handle this cancellation token more gracefully within Sequencer service
// similar to how we do in Indexer
let cancellation_token = listen_for_shutdown_signal();
let config = sequencer_service::SequencerConfig::from_path(&config_path)?;

View File

@ -74,6 +74,20 @@ impl<BC: BlockPublisherTrait + Send + 'static> sequencer_service_rpc::RpcServer
)
})?;
// Sequencer-only programs (the cross-zone inbox) are injected by the
// watcher; a user must not invoke them top-level, or anyone could forge
// an inbound cross-zone delivery. Chained user calls are already rejected
// by the inbox guest's caller-is-none assertion.
if let LeeTransaction::Public(public_tx) = &authenticated_tx
&& sequencer_core::is_sequencer_only_program(public_tx.message().program_id)
{
return Err(ErrorObjectOwned::owned(
ErrorCode::InvalidParams.code(),
"Program is sequencer-only and cannot be invoked by a user transaction".to_owned(),
None::<()>,
));
}
self.mempool_handle
.push((TransactionOrigin::User, authenticated_tx))
.await
@ -131,7 +145,7 @@ impl<BC: BlockPublisherTrait + Send + 'static> sequencer_service_rpc::RpcServer
async fn get_transaction(
&self,
tx_hash: HashType,
) -> Result<Option<LeeTransaction>, ErrorObjectOwned> {
) -> Result<Option<(LeeTransaction, BlockId)>, ErrorObjectOwned> {
let sequencer = self.sequencer.lock().await;
Ok(sequencer.block_store().get_transaction_by_hash(tx_hash))
}

View File

@ -13,8 +13,10 @@ lee.workspace = true
thiserror.workspace = true
borsh.workspace = true
log.workspace = true
rocksdb.workspace = true
tempfile.workspace = true
zstd.workspace = true
[dev-dependencies]
programs.workspace = true

View File

@ -12,6 +12,12 @@ pub enum DbError {
error: borsh::io::Error,
additional_info: Option<String>,
},
#[error("Compression error: {}", additional_info.as_deref().unwrap_or("No additional info"))]
CompressionError {
#[source]
error: std::io::Error,
additional_info: Option<String>,
},
#[error("Logic Error: {additional_info}")]
DbInteractionError { additional_info: String },
}
@ -33,6 +39,14 @@ impl DbError {
}
}
#[must_use]
pub const fn compression_error(err: std::io::Error, message: Option<String>) -> Self {
Self::CompressionError {
error: err,
additional_info: message,
}
}
#[must_use]
pub const fn db_interaction_error(message: String) -> Self {
Self::DbInteractionError {

View File

@ -7,9 +7,9 @@ use crate::{
error::DbError,
indexer::{
ACC_NUM_CELL_NAME, BLOCK_HASH_CELL_NAME, BREAKPOINT_CELL_NAME, CF_ACC_META,
CF_BREAKPOINT_NAME, CF_HASH_TO_ID, CF_TX_TO_ID, DB_META_LAST_BREAKPOINT_ID,
DB_META_LAST_OBSERVED_L1_LIB_HEADER_ID_IN_DB_KEY, DB_META_ZONE_SDK_INDEXER_CURSOR_KEY,
TX_HASH_CELL_NAME,
CF_BREAKPOINT_NAME, CF_HASH_TO_ID, CF_TX_TO_ID,
DB_META_LAST_OBSERVED_L1_LIB_HEADER_ID_IN_DB_KEY, DB_META_STALL_REASON_KEY,
DB_META_TIP_SLOT_KEY, DB_META_ZONE_SDK_INDEXER_CURSOR_KEY, TX_HASH_CELL_NAME,
},
};
@ -36,29 +36,6 @@ impl SimpleWritableCell for LastObservedL1LibHeaderCell {
}
}
#[derive(Debug, BorshSerialize, BorshDeserialize)]
pub struct LastBreakpointIdCell(pub u64);
impl SimpleStorableCell for LastBreakpointIdCell {
type KeyParams = ();
const CELL_NAME: &'static str = DB_META_LAST_BREAKPOINT_ID;
const CF_NAME: &'static str = CF_META_NAME;
}
impl SimpleReadableCell for LastBreakpointIdCell {}
impl SimpleWritableCell for LastBreakpointIdCell {
fn value_constructor(&self) -> DbResult<Vec<u8>> {
borsh::to_vec(&self).map_err(|err| {
DbError::borsh_cast_message(
err,
Some("Failed to serialize last breakpoint id".to_owned()),
)
})
}
}
#[derive(BorshDeserialize)]
pub struct BreakpointCellOwned(pub V03State);
@ -212,6 +189,27 @@ impl SimpleWritableCell for AccNumTxCell {
}
}
/// The L1 inscription slot of the tip block, written atomically with the tip.
#[derive(Debug, BorshSerialize, BorshDeserialize)]
pub struct TipSlotCell(pub u64);
impl SimpleStorableCell for TipSlotCell {
type KeyParams = ();
const CELL_NAME: &'static str = DB_META_TIP_SLOT_KEY;
const CF_NAME: &'static str = CF_META_NAME;
}
impl SimpleReadableCell for TipSlotCell {}
impl SimpleWritableCell for TipSlotCell {
fn value_constructor(&self) -> DbResult<Vec<u8>> {
borsh::to_vec(&self).map_err(|err| {
DbError::borsh_cast_message(err, Some("Failed to serialize tip slot".to_owned()))
})
}
}
/// Opaque bytes for the zone-sdk indexer cursor `Option<(MsgId, Slot)>`.
/// The caller serializes via `serde_json` (neither type derives borsh).
#[derive(BorshDeserialize)]
@ -247,6 +245,40 @@ impl SimpleWritableCell for ZoneSdkIndexerCursorCellRef<'_> {
}
}
/// Opaque JSON bytes for the indexer's persisted `Option<StallReason>`.
#[derive(BorshDeserialize)]
pub struct StallReasonCellOwned(pub Vec<u8>);
impl SimpleStorableCell for StallReasonCellOwned {
type KeyParams = ();
const CELL_NAME: &'static str = DB_META_STALL_REASON_KEY;
const CF_NAME: &'static str = CF_META_NAME;
}
impl SimpleReadableCell for StallReasonCellOwned {}
#[derive(BorshSerialize)]
pub struct StallReasonCellRef<'bytes>(pub &'bytes [u8]);
impl SimpleStorableCell for StallReasonCellRef<'_> {
type KeyParams = ();
const CELL_NAME: &'static str = DB_META_STALL_REASON_KEY;
const CF_NAME: &'static str = CF_META_NAME;
}
impl SimpleWritableCell for StallReasonCellRef<'_> {
fn value_constructor(&self) -> DbResult<Vec<u8>> {
borsh::to_vec(&self).map_err(|err| {
DbError::borsh_cast_message(
err,
Some("Failed to serialize stall reason cell".to_owned()),
)
})
}
}
#[cfg(test)]
mod uniform_tests {
use crate::{

Some files were not shown because too many files have changed in this diff Show More