mirror of
https://github.com/logos-blockchain/logos-execution-zone.git
synced 2026-07-15 10:20:01 +00:00
Merge branch 'dev' into Pravdyvy/multi-sequencer-client
This commit is contained in:
commit
ac2bf7f996
34
.github/workflows/ci.yml
vendored
34
.github/workflows/ci.yml
vendored
@ -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:
|
||||
|
||||
188
Cargo.lock
generated
188
Cargo.lock
generated
@ -1154,6 +1154,25 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bridge_lock_core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"lee_core",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bridge_lock_program"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bridge_lock_core",
|
||||
"cross_zone_outbox_core",
|
||||
"lee_core",
|
||||
"risc0-zkvm",
|
||||
"wrapped_token_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bridge_program"
|
||||
version = "0.1.0"
|
||||
@ -1833,6 +1852,76 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
|
||||
|
||||
[[package]]
|
||||
name = "cross_zone"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bridge_lock_core",
|
||||
"cross_zone_inbox_core",
|
||||
"lee",
|
||||
"lee_core",
|
||||
"ping_core",
|
||||
"programs",
|
||||
"risc0-zkvm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cross_zone_chat"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
"common",
|
||||
"cross_zone_inbox_core",
|
||||
"cross_zone_outbox_core",
|
||||
"env_logger",
|
||||
"lee",
|
||||
"log",
|
||||
"ping_core",
|
||||
"programs",
|
||||
"risc0-zkvm",
|
||||
"sequencer_service_rpc",
|
||||
"serde",
|
||||
"test_fixtures",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cross_zone_inbox_core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"borsh",
|
||||
"lee_core",
|
||||
"risc0-zkvm",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cross_zone_inbox_program"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"cross_zone_inbox_core",
|
||||
"lee_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cross_zone_outbox_core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"borsh",
|
||||
"lee_core",
|
||||
"risc0-zkvm",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cross_zone_outbox_program"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"cross_zone_outbox_core",
|
||||
"lee_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-channel"
|
||||
version = "0.5.15"
|
||||
@ -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",
|
||||
]
|
||||
|
||||
16
Cargo.toml
16
Cargo.toml
@ -43,6 +43,13 @@ members = [
|
||||
"lez/programs/pinata_token",
|
||||
"lez/programs/token",
|
||||
"lez/programs/vault",
|
||||
"lez/programs/cross_zone_inbox",
|
||||
"lez/programs/cross_zone_outbox",
|
||||
"lez/programs/bridge_lock",
|
||||
"lez/programs/wrapped_token",
|
||||
"lez/programs/ping_sender",
|
||||
"lez/programs/ping_receiver",
|
||||
"lez/cross_zone",
|
||||
|
||||
"test_programs",
|
||||
"test_programs/guest",
|
||||
@ -58,6 +65,7 @@ members = [
|
||||
"tools/cycle_bench",
|
||||
"tools/crypto_primitives_bench",
|
||||
"tools/integration_bench",
|
||||
"tools/cross_zone_chat",
|
||||
]
|
||||
|
||||
[workspace.dependencies]
|
||||
@ -94,6 +102,12 @@ authenticated_transfer_core = { path = "lez/programs/authenticated_transfer/core
|
||||
faucet_core = { path = "lez/programs/faucet/core" }
|
||||
bridge_core = { path = "lez/programs/bridge/core" }
|
||||
vault_core = { path = "lez/programs/vault/core" }
|
||||
cross_zone_inbox_core = { path = "lez/programs/cross_zone_inbox/core" }
|
||||
cross_zone_outbox_core = { path = "lez/programs/cross_zone_outbox/core" }
|
||||
bridge_lock_core = { path = "lez/programs/bridge_lock/core" }
|
||||
wrapped_token_core = { path = "lez/programs/wrapped_token/core" }
|
||||
ping_core = { path = "lez/programs/ping_core" }
|
||||
cross_zone = { path = "lez/cross_zone" }
|
||||
build_utils = { path = "build_utils" }
|
||||
test_programs = { path = "test_programs" }
|
||||
testnet_initial_state = { path = "lez/testnet_initial_state" }
|
||||
@ -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"] }
|
||||
|
||||
30
Justfile
30
Justfile
@ -6,6 +6,11 @@ default:
|
||||
# ---- Configuration ----
|
||||
ARTIFACTS := "artifacts"
|
||||
|
||||
# On macOS the integration-test binary links pyo3 against the CommandLineTools
|
||||
# Python framework with no embedded rpath, so it needs this to launch. Empty on
|
||||
# Linux/CI, which is unaffected.
|
||||
DEMO_ENV := if os() == "macos" { "DYLD_FALLBACK_FRAMEWORK_PATH=/Library/Developer/CommandLineTools/Library/Frameworks" } else { "" }
|
||||
|
||||
# Build risc0 program artifacts.
|
||||
build-artifacts:
|
||||
@echo "🔨 Building artifacts"
|
||||
@ -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.
BIN
artifacts/lez/programs/bridge_lock.bin
Normal file
BIN
artifacts/lez/programs/bridge_lock.bin
Normal file
Binary file not shown.
Binary file not shown.
BIN
artifacts/lez/programs/cross_zone_inbox.bin
Normal file
BIN
artifacts/lez/programs/cross_zone_inbox.bin
Normal file
Binary file not shown.
BIN
artifacts/lez/programs/cross_zone_outbox.bin
Normal file
BIN
artifacts/lez/programs/cross_zone_outbox.bin
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
artifacts/lez/programs/ping_receiver.bin
Normal file
BIN
artifacts/lez/programs/ping_receiver.bin
Normal file
Binary file not shown.
BIN
artifacts/lez/programs/ping_sender.bin
Normal file
BIN
artifacts/lez/programs/ping_sender.bin
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
artifacts/lez/programs/wrapped_token.bin
Normal file
BIN
artifacts/lez/programs/wrapped_token.bin
Normal file
Binary file not shown.
@ -22,6 +22,12 @@ associated_token_account_core.workspace = true
|
||||
vault_core.workspace = true
|
||||
faucet_core.workspace = true
|
||||
bridge_core.workspace = true
|
||||
ping_core.workspace = true
|
||||
cross_zone_outbox_core.workspace = true
|
||||
cross_zone_inbox_core.workspace = true
|
||||
bridge_lock_core.workspace = true
|
||||
wrapped_token_core.workspace = true
|
||||
risc0-zkvm.workspace = true
|
||||
indexer_service_rpc = { workspace = true, features = ["client"] }
|
||||
sequencer_service_rpc = { workspace = true, features = ["client"] }
|
||||
wallet-ffi.workspace = true
|
||||
|
||||
197
integration_tests/tests/cross_zone_bridge.rs
Normal file
197
integration_tests/tests/cross_zone_bridge.rs
Normal file
@ -0,0 +1,197 @@
|
||||
#![expect(
|
||||
clippy::tests_outside_test_module,
|
||||
reason = "top-level test functions are conventional for integration tests"
|
||||
)]
|
||||
|
||||
//! Demo 2: a wrapped-token bridge over the cross-zone spine. A holder locks part
|
||||
//! of their bridgeable balance on zone A; the watcher carries the emitted mint to
|
||||
//! zone B, where the indexer re-derives and verifies it (Option B) before the
|
||||
//! wrapped token is minted to the recipient. Reuses the M3/M4 spine unchanged;
|
||||
//! only the source caller (`bridge_lock`) and target (`wrapped_token`) are new.
|
||||
|
||||
use std::{net::SocketAddr, time::Duration};
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use common::transaction::LeeTransaction;
|
||||
use cross_zone_outbox_core::outbox_pda;
|
||||
use integration_tests::{
|
||||
config::{self, SequencerPartialConfig},
|
||||
indexer_client::IndexerClient,
|
||||
setup::{setup_bedrock_node, setup_indexer, setup_sequencer},
|
||||
};
|
||||
use lee::{
|
||||
AccountId, PrivateKey, PublicKey, PublicTransaction,
|
||||
public_transaction::{Message, WitnessSet},
|
||||
};
|
||||
use sequencer_core::config::{CrossZoneConfig, CrossZonePeer, GenesisAction};
|
||||
use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder};
|
||||
use tokio::test;
|
||||
|
||||
const DELIVERY_TIMEOUT: Duration = Duration::from_secs(600);
|
||||
const INITIAL_BALANCE: u128 = 100;
|
||||
const LOCK_AMOUNT: u128 = 30;
|
||||
const RECIPIENT: [u8; 32] = [9; 32];
|
||||
|
||||
#[test]
|
||||
async fn lock_on_zone_a_mints_wrapped_token_on_zone_b() -> Result<()> {
|
||||
// Declared first so it outlives both zones (drops run in reverse order).
|
||||
let (_bedrock, bedrock_addr) = setup_bedrock_node()
|
||||
.await
|
||||
.context("Failed to set up shared Bedrock node")?;
|
||||
|
||||
let partial = SequencerPartialConfig::default();
|
||||
let channel_a = config::bedrock_channel_id();
|
||||
let channel_b = config::bedrock_channel_id_b();
|
||||
let zone_b: [u8; 32] = *channel_b.as_ref();
|
||||
|
||||
let holder_key = PrivateKey::try_new([7; 32]).expect("valid key");
|
||||
let holder_id = AccountId::from(&PublicKey::new_from_private_key(&holder_key));
|
||||
|
||||
let wrapped_token_id = programs::wrapped_token().id();
|
||||
let cross_zone = CrossZoneConfig {
|
||||
peers: vec![CrossZonePeer {
|
||||
channel_id: *channel_a.as_ref(),
|
||||
allowed_targets: vec![wrapped_token_id],
|
||||
expected_block_signing_pubkey: None,
|
||||
}],
|
||||
};
|
||||
|
||||
// Zone A seeds the holder's bridgeable balance. Zone B runs the watcher on its
|
||||
// sequencer and the verifier on its indexer.
|
||||
let genesis_a = vec![GenesisAction::SupplyBridgeLockHolding {
|
||||
holder: holder_id,
|
||||
amount: INITIAL_BALANCE,
|
||||
}];
|
||||
let (seq_a, _seq_a_home) = setup_sequencer(partial, bedrock_addr, genesis_a, channel_a, None)
|
||||
.await
|
||||
.context("Failed to set up zone A sequencer")?;
|
||||
let (_seq_b, _seq_b_home) = setup_sequencer(
|
||||
partial,
|
||||
bedrock_addr,
|
||||
vec![],
|
||||
channel_b,
|
||||
Some(cross_zone.clone()),
|
||||
)
|
||||
.await
|
||||
.context("Failed to set up zone B sequencer")?;
|
||||
let (idx_b, _idx_b_home) = setup_indexer(bedrock_addr, channel_b, Some(cross_zone))
|
||||
.await
|
||||
.context("Failed to set up zone B indexer")?;
|
||||
|
||||
// Lock LOCK_AMOUNT on zone A, addressed to the recipient on zone B.
|
||||
let lock = build_lock_tx(&holder_key, holder_id, zone_b);
|
||||
sequencer_client(seq_a.addr())?
|
||||
.send_transaction(lock)
|
||||
.await
|
||||
.context("Failed to submit lock on zone A")?;
|
||||
|
||||
// Wait until zone B's indexer reflects the verified mint.
|
||||
let holding_id = wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT);
|
||||
let indexer_url = config::addr_to_url(config::UrlProtocol::Ws, idx_b.addr())
|
||||
.context("Failed to build indexer URL")?;
|
||||
let indexer = IndexerClient::new(&indexer_url)
|
||||
.await
|
||||
.context("Failed to build indexer client")?;
|
||||
|
||||
let minted = wait_for_mint(&indexer, holding_id).await?;
|
||||
assert_eq!(
|
||||
minted, LOCK_AMOUNT,
|
||||
"zone B must mint exactly the locked amount"
|
||||
);
|
||||
|
||||
// Conservation: the mint on B must be backed by an equal lock on A. The lock
|
||||
// has already landed (it preceded delivery), so zone A reflects the debit and
|
||||
// escrow now.
|
||||
let seq_a_client = sequencer_client(seq_a.addr())?;
|
||||
let escrow_id = bridge_lock_core::escrow_account_id(programs::bridge_lock().id());
|
||||
let escrowed = bridge_lock_core::read_balance(
|
||||
&seq_a_client.get_account(escrow_id).await?.data.into_inner(),
|
||||
);
|
||||
assert_eq!(
|
||||
escrowed, LOCK_AMOUNT,
|
||||
"zone A escrow must hold the locked amount"
|
||||
);
|
||||
let remaining = bridge_lock_core::read_balance(
|
||||
&seq_a_client.get_account(holder_id).await?.data.into_inner(),
|
||||
);
|
||||
assert_eq!(
|
||||
remaining,
|
||||
INITIAL_BALANCE - LOCK_AMOUNT,
|
||||
"zone A holder must be debited by the locked amount"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Builds a signed `bridge_lock` Lock that forwards a wrapped-token Mint of the
|
||||
/// locked amount to the recipient on the target zone.
|
||||
fn build_lock_tx(
|
||||
holder_key: &PrivateKey,
|
||||
holder_id: AccountId,
|
||||
target_zone: [u8; 32],
|
||||
) -> LeeTransaction {
|
||||
let bridge_lock_id = programs::bridge_lock().id();
|
||||
let wrapped_token_id = programs::wrapped_token().id();
|
||||
let outbox_id = programs::cross_zone_outbox().id();
|
||||
let ordinal = 0;
|
||||
|
||||
let mint = wrapped_token_core::Instruction::Mint {
|
||||
recipient: RECIPIENT,
|
||||
amount: LOCK_AMOUNT,
|
||||
};
|
||||
let words = risc0_zkvm::serde::to_vec(&mint).expect("serialize mint");
|
||||
let payload: Vec<u8> = words.iter().flat_map(|word| word.to_le_bytes()).collect();
|
||||
|
||||
let target_accounts = vec![
|
||||
wrapped_token_core::config_account_id(wrapped_token_id).into_value(),
|
||||
wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT).into_value(),
|
||||
];
|
||||
let lock = bridge_lock_core::Instruction::Lock {
|
||||
amount: LOCK_AMOUNT,
|
||||
target_zone,
|
||||
target_program_id: wrapped_token_id,
|
||||
target_accounts,
|
||||
payload,
|
||||
outbox_program_id: outbox_id,
|
||||
ordinal,
|
||||
};
|
||||
|
||||
let accounts = vec![
|
||||
holder_id,
|
||||
bridge_lock_core::escrow_account_id(bridge_lock_id),
|
||||
outbox_pda(outbox_id, &target_zone, ordinal),
|
||||
];
|
||||
// One nonce per signature: the holder signs, at its genesis nonce 0.
|
||||
let message = Message::try_new(bridge_lock_id, accounts, vec![0_u128.into()], lock)
|
||||
.expect("build lock message");
|
||||
let witness = WitnessSet::for_message(&message, &[holder_key]);
|
||||
LeeTransaction::Public(PublicTransaction::new(message, witness))
|
||||
}
|
||||
|
||||
fn sequencer_client(addr: SocketAddr) -> Result<SequencerClient> {
|
||||
let url = config::addr_to_url(config::UrlProtocol::Http, addr)
|
||||
.context("Failed to build sequencer URL")?;
|
||||
SequencerClientBuilder::default()
|
||||
.build(url)
|
||||
.context("Failed to build sequencer client")
|
||||
}
|
||||
|
||||
/// Polls zone B's indexer until the recipient's wrapped holding is non-zero.
|
||||
async fn wait_for_mint(indexer: &IndexerClient, holding_id: AccountId) -> Result<u128> {
|
||||
let account_id = indexer_service_protocol::AccountId {
|
||||
value: holding_id.into_value(),
|
||||
};
|
||||
let wait = async {
|
||||
loop {
|
||||
let account =
|
||||
indexer_service_rpc::RpcClient::get_account(&**indexer, account_id).await?;
|
||||
let balance = wrapped_token_core::read_balance(&account.data.0);
|
||||
if balance != 0 {
|
||||
return Ok::<u128, anyhow::Error>(balance);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
}
|
||||
};
|
||||
tokio::time::timeout(DELIVERY_TIMEOUT, wait)
|
||||
.await
|
||||
.context("Zone B's indexer did not mint the wrapped token in time")?
|
||||
}
|
||||
80
integration_tests/tests/cross_zone_ingress_guard.rs
Normal file
80
integration_tests/tests/cross_zone_ingress_guard.rs
Normal file
@ -0,0 +1,80 @@
|
||||
#![expect(
|
||||
clippy::tests_outside_test_module,
|
||||
reason = "We don't care about these in tests"
|
||||
)]
|
||||
|
||||
//! M6 ingress guard: the cross-zone inbox is sequencer-only. Only the watcher
|
||||
//! injects inbox dispatches; a user must not be able to invoke the inbox through
|
||||
//! the public RPC, or anyone could forge an inbound cross-zone delivery. The
|
||||
//! inbox guest's caller-is-none assertion passes for a top-level user tx, so the
|
||||
//! sequencer ingress guard is the only thing that stops this.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use common::transaction::LeeTransaction;
|
||||
use cross_zone_inbox_core::{
|
||||
CrossZoneMessage, Instruction, inbox_config_account_id, inbox_seen_shard_account_id,
|
||||
};
|
||||
use integration_tests::{
|
||||
config::{self, SequencerPartialConfig},
|
||||
setup::{setup_bedrock_node, setup_sequencer},
|
||||
};
|
||||
use lee::{
|
||||
PublicTransaction,
|
||||
public_transaction::{Message, WitnessSet},
|
||||
};
|
||||
use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder};
|
||||
use tokio::test;
|
||||
|
||||
#[test]
|
||||
async fn user_origin_inbox_call_rejected() -> Result<()> {
|
||||
let (_bedrock, bedrock_addr) = setup_bedrock_node()
|
||||
.await
|
||||
.context("Failed to set up Bedrock node")?;
|
||||
let partial = SequencerPartialConfig::default();
|
||||
let channel = config::bedrock_channel_id();
|
||||
let (seq, _seq_home) = setup_sequencer(partial, bedrock_addr, vec![], channel, None)
|
||||
.await
|
||||
.context("Failed to set up sequencer")?;
|
||||
|
||||
// A user hand-builds a top-level inbox Dispatch and submits it via RPC.
|
||||
let inbox_id = programs::cross_zone_inbox().id();
|
||||
let msg = CrossZoneMessage {
|
||||
src_zone: [2; 32],
|
||||
src_block_id: 1,
|
||||
src_tx_index: 0,
|
||||
src_program_id: [9; 8],
|
||||
target_program_id: programs::ping_receiver().id(),
|
||||
payload: vec![],
|
||||
l1_inclusion_witness: None,
|
||||
};
|
||||
let seen_id = inbox_seen_shard_account_id(inbox_id, &msg.src_zone, msg.src_block_id);
|
||||
let message = Message::try_new(
|
||||
inbox_id,
|
||||
vec![inbox_config_account_id(inbox_id), seen_id],
|
||||
vec![],
|
||||
Instruction::Dispatch(msg),
|
||||
)
|
||||
.expect("build dispatch message");
|
||||
let tx = LeeTransaction::Public(PublicTransaction::new(
|
||||
message,
|
||||
WitnessSet::from_raw_parts(vec![]),
|
||||
));
|
||||
|
||||
let result = sequencer_client(seq.addr())?.send_transaction(tx).await;
|
||||
let err = result.expect_err("the sequencer must reject a user-origin inbox call");
|
||||
assert!(
|
||||
err.to_string().contains("sequencer-only"),
|
||||
"rejection should cite the sequencer-only guard, got: {err}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sequencer_client(addr: SocketAddr) -> Result<SequencerClient> {
|
||||
let url = config::addr_to_url(config::UrlProtocol::Http, addr)
|
||||
.context("Failed to build sequencer URL")?;
|
||||
SequencerClientBuilder::default()
|
||||
.build(url)
|
||||
.context("Failed to build sequencer client")
|
||||
}
|
||||
143
integration_tests/tests/cross_zone_ping.rs
Normal file
143
integration_tests/tests/cross_zone_ping.rs
Normal file
@ -0,0 +1,143 @@
|
||||
#![expect(
|
||||
clippy::tests_outside_test_module,
|
||||
reason = "top-level test functions are conventional for integration tests"
|
||||
)]
|
||||
|
||||
//! End-to-end cross-zone round trip: a ping submitted on zone A is delivered by
|
||||
//! zone B's watcher to `ping_receiver` on zone B, which records the payload.
|
||||
//!
|
||||
//! Two sequencers share one Bedrock node (no indexers): zone A publishes the
|
||||
//! ping to Bedrock, zone B's watcher reads zone A's finalized blocks, injects the
|
||||
//! inbox dispatch, and zone B's sequencer delivers it. This is the M3 milestone,
|
||||
//! sequencer-trusted, with no indexer re-derivation (that is M4).
|
||||
|
||||
use std::{net::SocketAddr, time::Duration};
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use common::transaction::LeeTransaction;
|
||||
use cross_zone_outbox_core::outbox_pda;
|
||||
use integration_tests::{
|
||||
config::{self, SequencerPartialConfig},
|
||||
setup::{setup_bedrock_node, setup_sequencer},
|
||||
};
|
||||
use lee::{AccountId, PublicTransaction, public_transaction::Message};
|
||||
use lee_core::program::ProgramId;
|
||||
use ping_core::{ReceiverInstruction, SenderInstruction, ping_record_pda};
|
||||
use sequencer_core::config::{CrossZoneConfig, CrossZonePeer};
|
||||
use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder};
|
||||
use tokio::test;
|
||||
|
||||
const DELIVERY_TIMEOUT: Duration = Duration::from_secs(480);
|
||||
const PING_PAYLOAD: &[u8] = b"hello-cross-zone";
|
||||
|
||||
#[test]
|
||||
async fn ping_crosses_from_zone_a_to_zone_b() -> Result<()> {
|
||||
// Declared first so it outlives both zones (drops run in reverse order).
|
||||
let (_bedrock, bedrock_addr) = setup_bedrock_node()
|
||||
.await
|
||||
.context("Failed to set up shared Bedrock node")?;
|
||||
|
||||
let partial = SequencerPartialConfig::default();
|
||||
let channel_a = config::bedrock_channel_id();
|
||||
let channel_b = config::bedrock_channel_id_b();
|
||||
let zone_a: [u8; 32] = *channel_a.as_ref();
|
||||
let zone_b: [u8; 32] = *channel_b.as_ref();
|
||||
|
||||
let receiver_id = programs::ping_receiver().id();
|
||||
|
||||
// Zone B watches zone A and allows delivery only to ping_receiver.
|
||||
let cross_zone = CrossZoneConfig {
|
||||
peers: vec![CrossZonePeer {
|
||||
channel_id: zone_a,
|
||||
allowed_targets: vec![receiver_id],
|
||||
expected_block_signing_pubkey: None,
|
||||
}],
|
||||
};
|
||||
|
||||
let (seq_a, _seq_a_home) = setup_sequencer(partial, bedrock_addr, vec![], channel_a, None)
|
||||
.await
|
||||
.context("Failed to set up zone A sequencer")?;
|
||||
let (seq_b, _seq_b_home) =
|
||||
setup_sequencer(partial, bedrock_addr, vec![], channel_b, Some(cross_zone))
|
||||
.await
|
||||
.context("Failed to set up zone B sequencer")?;
|
||||
|
||||
// Submit the ping on zone A, addressed to ping_receiver on zone B.
|
||||
let ping = build_ping_tx(zone_b, receiver_id);
|
||||
sequencer_client(seq_a.addr())?
|
||||
.send_transaction(ping)
|
||||
.await
|
||||
.context("Failed to submit ping on zone A")?;
|
||||
|
||||
// Wait until zone B's sequencer records the delivered payload.
|
||||
let record_id = ping_record_pda(receiver_id);
|
||||
let delivered = wait_for_delivery(sequencer_client(seq_b.addr())?, record_id).await?;
|
||||
|
||||
assert_eq!(
|
||||
delivered, PING_PAYLOAD,
|
||||
"Zone B must record the payload delivered from zone A"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Builds a top-level `ping_sender` transaction that chains into the outbox to emit
|
||||
/// a message carrying a `ping_receiver::Record` instruction for the target zone.
|
||||
fn build_ping_tx(target_zone: [u8; 32], receiver_id: ProgramId) -> LeeTransaction {
|
||||
let outbox_id = programs::cross_zone_outbox().id();
|
||||
let ordinal = 0;
|
||||
|
||||
// The payload is the ping_receiver instruction, serialized as risc0 words in
|
||||
// little-endian bytes (the contract the inbox reverses when forwarding).
|
||||
let words = risc0_zkvm::serde::to_vec(&ReceiverInstruction::Record {
|
||||
payload: PING_PAYLOAD.to_vec(),
|
||||
})
|
||||
.expect("serialize ping instruction");
|
||||
let payload: Vec<u8> = words.iter().flat_map(|word| word.to_le_bytes()).collect();
|
||||
|
||||
let send = SenderInstruction::Send {
|
||||
outbox_program_id: outbox_id,
|
||||
target_zone,
|
||||
target_program_id: receiver_id,
|
||||
target_accounts: vec![ping_record_pda(receiver_id).into_value()],
|
||||
payload,
|
||||
ordinal,
|
||||
};
|
||||
|
||||
let outbox_account = outbox_pda(outbox_id, &target_zone, ordinal);
|
||||
let message = Message::try_new(
|
||||
programs::ping_sender().id(),
|
||||
vec![outbox_account],
|
||||
vec![],
|
||||
send,
|
||||
)
|
||||
.expect("build ping message");
|
||||
LeeTransaction::Public(PublicTransaction::new(
|
||||
message,
|
||||
lee::public_transaction::WitnessSet::from_raw_parts(vec![]),
|
||||
))
|
||||
}
|
||||
|
||||
fn sequencer_client(addr: SocketAddr) -> Result<SequencerClient> {
|
||||
let url = config::addr_to_url(config::UrlProtocol::Http, addr)
|
||||
.context("Failed to build sequencer URL")?;
|
||||
SequencerClientBuilder::default()
|
||||
.build(url)
|
||||
.context("Failed to build sequencer client")
|
||||
}
|
||||
|
||||
/// Polls zone B's sequencer until the ping record PDA holds a payload.
|
||||
async fn wait_for_delivery(client: SequencerClient, record_id: AccountId) -> Result<Vec<u8>> {
|
||||
let wait = async {
|
||||
loop {
|
||||
let account = client.get_account(record_id).await?;
|
||||
let data = account.data.into_inner();
|
||||
if !data.is_empty() {
|
||||
return Ok::<Vec<u8>, anyhow::Error>(data);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
}
|
||||
};
|
||||
tokio::time::timeout(DELIVERY_TIMEOUT, wait)
|
||||
.await
|
||||
.context("Zone B did not record the cross-zone payload in time")?
|
||||
}
|
||||
373
integration_tests/tests/cross_zone_state_machine.rs
Normal file
373
integration_tests/tests/cross_zone_state_machine.rs
Normal file
@ -0,0 +1,373 @@
|
||||
#![expect(
|
||||
clippy::tests_outside_test_module,
|
||||
reason = "top-level test functions are conventional for integration tests"
|
||||
)]
|
||||
|
||||
//! Single-zone state-machine tests for cross-zone delivery (ping demo) and the
|
||||
//! wrapped-token bridge (Demo 2). They drive the guests in isolation, no watcher
|
||||
//! or Bedrock: a hand-built `cross_zone_inbox::Dispatch` (as the watcher would
|
||||
//! inject) and the source `bridge_lock::Lock` (which escrows and chains
|
||||
//! `outbox::Emit`). Fast, so they pin guest logic before the e2e exercises the
|
||||
//! plumbing. Run with `RISC0_DEV_MODE=1`.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use cross_zone_inbox_core::{
|
||||
CrossZoneMessage, InboxConfig, Instruction as InboxInstruction, SeenShard,
|
||||
inbox_config_account_id, inbox_seen_shard_account_id, message_key,
|
||||
};
|
||||
use cross_zone_outbox_core::{OutboxRecord, outbox_pda};
|
||||
use lee::{
|
||||
AccountId, PrivateKey, PublicKey, PublicTransaction, V03State, ValidatedStateDiff,
|
||||
public_transaction::{Message, WitnessSet},
|
||||
};
|
||||
use lee_core::account::Account;
|
||||
use ping_core::{ReceiverInstruction, ping_record_pda};
|
||||
|
||||
const INITIAL_BALANCE: u128 = 100;
|
||||
const LOCK_AMOUNT: u128 = 30;
|
||||
const RECIPIENT: [u8; 32] = [9; 32];
|
||||
|
||||
/// State registering the cross-zone builtins these tests exercise.
|
||||
fn base_state() -> V03State {
|
||||
V03State::new().with_programs([
|
||||
programs::cross_zone_inbox(),
|
||||
programs::cross_zone_outbox(),
|
||||
programs::ping_receiver(),
|
||||
programs::bridge_lock(),
|
||||
programs::wrapped_token(),
|
||||
])
|
||||
}
|
||||
|
||||
/// Seeds an inbox config (inbox-owned) allowing `src_zone -> target`.
|
||||
fn seed_inbox_config(
|
||||
state: &mut V03State,
|
||||
self_zone: [u8; 32],
|
||||
src_zone: [u8; 32],
|
||||
target: lee_core::program::ProgramId,
|
||||
) {
|
||||
let inbox_id = programs::cross_zone_inbox().id();
|
||||
let mut allowed_targets = BTreeMap::new();
|
||||
allowed_targets.insert(src_zone, vec![target]);
|
||||
let config = InboxConfig {
|
||||
self_zone,
|
||||
allowed_peers: BTreeMap::new(),
|
||||
allowed_targets,
|
||||
};
|
||||
state.insert_genesis_account(
|
||||
inbox_config_account_id(inbox_id),
|
||||
Account {
|
||||
program_owner: inbox_id,
|
||||
balance: 0,
|
||||
data: config
|
||||
.to_bytes()
|
||||
.try_into()
|
||||
.expect("config fits in account data"),
|
||||
nonce: 0_u128.into(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Seeds the wrapped-token config account pinning the inbox as authorized minter,
|
||||
/// matching what genesis seeds for a real zone.
|
||||
fn seed_wrapped_config(state: &mut V03State) {
|
||||
let wrapped_token_id = programs::wrapped_token().id();
|
||||
state.insert_genesis_account(
|
||||
wrapped_token_core::config_account_id(wrapped_token_id),
|
||||
Account {
|
||||
program_owner: wrapped_token_id,
|
||||
data: wrapped_token_core::minter_bytes(programs::cross_zone_inbox().id())
|
||||
.to_vec()
|
||||
.try_into()
|
||||
.expect("minter id fits in account data"),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// The wrapped-token `Mint` the bridge forwards, serialized as the cross-zone
|
||||
/// payload (risc0 words, little-endian bytes).
|
||||
fn mint_payload() -> Vec<u8> {
|
||||
let mint = wrapped_token_core::Instruction::Mint {
|
||||
recipient: RECIPIENT,
|
||||
amount: LOCK_AMOUNT,
|
||||
};
|
||||
let words = risc0_zkvm::serde::to_vec(&mint).expect("serialize mint");
|
||||
words.iter().flat_map(|word| word.to_le_bytes()).collect()
|
||||
}
|
||||
|
||||
/// Drives `cross_zone_inbox::Dispatch` directly through the state machine
|
||||
/// (no watcher) and asserts the message is delivered to `ping_receiver`, which
|
||||
/// records the payload into its own PDA.
|
||||
#[test]
|
||||
fn inbox_dispatch_delivers_payload_to_ping_receiver() {
|
||||
let inbox_id = programs::cross_zone_inbox().id();
|
||||
let receiver_id = programs::ping_receiver().id();
|
||||
|
||||
let self_zone = [1_u8; 32];
|
||||
let src_zone = [2_u8; 32];
|
||||
let src_block_id = 5;
|
||||
|
||||
let mut state = base_state();
|
||||
seed_inbox_config(&mut state, self_zone, src_zone, receiver_id);
|
||||
|
||||
// The payload is the ping_receiver instruction, serialized as risc0 words in
|
||||
// little-endian bytes (the contract the inbox reverses when forwarding).
|
||||
let inner = b"hello-cross-zone".to_vec();
|
||||
let words = risc0_zkvm::serde::to_vec(&ReceiverInstruction::Record {
|
||||
payload: inner.clone(),
|
||||
})
|
||||
.expect("serialize ping instruction");
|
||||
let payload: Vec<u8> = words.iter().flat_map(|word| word.to_le_bytes()).collect();
|
||||
|
||||
let msg = CrossZoneMessage {
|
||||
src_zone,
|
||||
src_block_id,
|
||||
src_tx_index: 0,
|
||||
src_program_id: [9_u32; 8],
|
||||
target_program_id: receiver_id,
|
||||
payload,
|
||||
l1_inclusion_witness: None,
|
||||
};
|
||||
|
||||
let seen_id = inbox_seen_shard_account_id(inbox_id, &src_zone, src_block_id);
|
||||
let record_id = ping_record_pda(receiver_id);
|
||||
|
||||
let message = Message::try_new(
|
||||
inbox_id,
|
||||
vec![inbox_config_account_id(inbox_id), seen_id, record_id],
|
||||
vec![],
|
||||
InboxInstruction::Dispatch(msg),
|
||||
)
|
||||
.expect("build dispatch message");
|
||||
let tx = PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![]));
|
||||
|
||||
let diff = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0)
|
||||
.expect("dispatch must validate and execute");
|
||||
let record = diff
|
||||
.public_diff()
|
||||
.get(&record_id)
|
||||
.expect("ping record account must change")
|
||||
.clone();
|
||||
assert_eq!(
|
||||
record.data.into_inner(),
|
||||
inner,
|
||||
"ping_receiver must record the delivered payload"
|
||||
);
|
||||
}
|
||||
|
||||
/// Drives `bridge_lock::Lock` and asserts it debits the holder, credits the
|
||||
/// escrow, and records the forwarded mint in the outbox PDA.
|
||||
#[test]
|
||||
fn lock_escrows_balance_and_emits_to_outbox() {
|
||||
let bridge_lock_id = programs::bridge_lock().id();
|
||||
let wrapped_token_id = programs::wrapped_token().id();
|
||||
let outbox_id = programs::cross_zone_outbox().id();
|
||||
let zone_b = [2_u8; 32];
|
||||
let ordinal = 0;
|
||||
|
||||
let mut state = base_state();
|
||||
|
||||
let holder_key = PrivateKey::try_new([7; 32]).expect("valid key");
|
||||
let holder_id = AccountId::from(&PublicKey::new_from_private_key(&holder_key));
|
||||
state.insert_genesis_account(
|
||||
holder_id,
|
||||
Account {
|
||||
program_owner: bridge_lock_id,
|
||||
balance: 0,
|
||||
data: bridge_lock_core::balance_bytes(INITIAL_BALANCE)
|
||||
.to_vec()
|
||||
.try_into()
|
||||
.expect("balance fits in account data"),
|
||||
nonce: 0_u128.into(),
|
||||
},
|
||||
);
|
||||
|
||||
let payload = mint_payload();
|
||||
let target_accounts = vec![
|
||||
wrapped_token_core::config_account_id(wrapped_token_id).into_value(),
|
||||
wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT).into_value(),
|
||||
];
|
||||
let lock = bridge_lock_core::Instruction::Lock {
|
||||
amount: LOCK_AMOUNT,
|
||||
target_zone: zone_b,
|
||||
target_program_id: wrapped_token_id,
|
||||
target_accounts,
|
||||
payload: payload.clone(),
|
||||
outbox_program_id: outbox_id,
|
||||
ordinal,
|
||||
};
|
||||
|
||||
let escrow_id = bridge_lock_core::escrow_account_id(bridge_lock_id);
|
||||
let outbox_record_id = outbox_pda(outbox_id, &zone_b, ordinal);
|
||||
let message = Message::try_new(
|
||||
bridge_lock_id,
|
||||
vec![holder_id, escrow_id, outbox_record_id],
|
||||
vec![0_u128.into()],
|
||||
lock,
|
||||
)
|
||||
.expect("build lock message");
|
||||
let witness = WitnessSet::for_message(&message, &[&holder_key]);
|
||||
let tx = PublicTransaction::new(message, witness);
|
||||
|
||||
let diff = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0)
|
||||
.expect("lock must validate and execute");
|
||||
let public_diff = diff.public_diff();
|
||||
|
||||
let holder_after =
|
||||
bridge_lock_core::read_balance(&public_diff[&holder_id].data.clone().into_inner());
|
||||
assert_eq!(
|
||||
holder_after,
|
||||
INITIAL_BALANCE - LOCK_AMOUNT,
|
||||
"holder debited"
|
||||
);
|
||||
|
||||
let escrow_after =
|
||||
bridge_lock_core::read_balance(&public_diff[&escrow_id].data.clone().into_inner());
|
||||
assert_eq!(escrow_after, LOCK_AMOUNT, "escrow credited");
|
||||
|
||||
let record =
|
||||
OutboxRecord::from_bytes(&public_diff[&outbox_record_id].data.clone().into_inner())
|
||||
.expect("outbox PDA holds an OutboxRecord");
|
||||
assert_eq!(record.target_zone, zone_b);
|
||||
assert_eq!(record.target_program_id, wrapped_token_id);
|
||||
assert_eq!(
|
||||
record.payload, payload,
|
||||
"emitted payload is the wrapped mint"
|
||||
);
|
||||
}
|
||||
|
||||
/// Drives a hand-built `cross_zone_inbox::Dispatch` (as the watcher would inject)
|
||||
/// and asserts it chains into `wrapped_token::Mint`, crediting the recipient.
|
||||
#[test]
|
||||
fn inbox_dispatch_mints_wrapped_token() {
|
||||
let inbox_id = programs::cross_zone_inbox().id();
|
||||
let wrapped_token_id = programs::wrapped_token().id();
|
||||
|
||||
let self_zone = [1_u8; 32];
|
||||
let src_zone = [2_u8; 32];
|
||||
let src_block_id = 5;
|
||||
|
||||
let mut state = base_state();
|
||||
seed_inbox_config(&mut state, self_zone, src_zone, wrapped_token_id);
|
||||
seed_wrapped_config(&mut state);
|
||||
|
||||
let msg = CrossZoneMessage {
|
||||
src_zone,
|
||||
src_block_id,
|
||||
src_tx_index: 0,
|
||||
src_program_id: [9_u32; 8],
|
||||
target_program_id: wrapped_token_id,
|
||||
payload: mint_payload(),
|
||||
l1_inclusion_witness: None,
|
||||
};
|
||||
|
||||
let seen_id = inbox_seen_shard_account_id(inbox_id, &src_zone, src_block_id);
|
||||
let wrapped_config_id = wrapped_token_core::config_account_id(wrapped_token_id);
|
||||
let holding_id = wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT);
|
||||
|
||||
let message = Message::try_new(
|
||||
inbox_id,
|
||||
vec![
|
||||
inbox_config_account_id(inbox_id),
|
||||
seen_id,
|
||||
wrapped_config_id,
|
||||
holding_id,
|
||||
],
|
||||
vec![],
|
||||
InboxInstruction::Dispatch(msg),
|
||||
)
|
||||
.expect("build dispatch message");
|
||||
let tx = PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![]));
|
||||
|
||||
let diff = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0)
|
||||
.expect("dispatch must validate and execute");
|
||||
let minted = wrapped_token_core::read_balance(
|
||||
&diff.public_diff()[&holding_id].data.clone().into_inner(),
|
||||
);
|
||||
assert_eq!(
|
||||
minted, LOCK_AMOUNT,
|
||||
"recipient holding minted the locked amount"
|
||||
);
|
||||
}
|
||||
|
||||
/// A dispatch whose message key is already in the seen-shard is an idempotent
|
||||
/// no-op: the inbox makes no chained call, so the wrapped token is not minted a
|
||||
/// second time. This is the bridge's replay defense.
|
||||
#[test]
|
||||
fn mint_replay_rejected() {
|
||||
let inbox_id = programs::cross_zone_inbox().id();
|
||||
let wrapped_token_id = programs::wrapped_token().id();
|
||||
|
||||
let self_zone = [1_u8; 32];
|
||||
let src_zone = [2_u8; 32];
|
||||
let src_block_id = 5;
|
||||
let src_tx_index = 0;
|
||||
|
||||
let mut state = base_state();
|
||||
seed_inbox_config(&mut state, self_zone, src_zone, wrapped_token_id);
|
||||
seed_wrapped_config(&mut state);
|
||||
|
||||
// Seed the seen-shard as already containing this message's key, so the inbox
|
||||
// takes the replay no-op branch. The shard is inbox-owned (claimed on a prior
|
||||
// delivery), so the guest leaves it untouched.
|
||||
let seen_id = inbox_seen_shard_account_id(inbox_id, &src_zone, src_block_id);
|
||||
let mut shard = SeenShard::default();
|
||||
shard.insert(message_key(&src_zone, src_block_id, src_tx_index));
|
||||
state.insert_genesis_account(
|
||||
seen_id,
|
||||
Account {
|
||||
program_owner: inbox_id,
|
||||
balance: 0,
|
||||
data: shard
|
||||
.to_bytes()
|
||||
.try_into()
|
||||
.expect("shard fits in account data"),
|
||||
nonce: 0_u128.into(),
|
||||
},
|
||||
);
|
||||
|
||||
let msg = CrossZoneMessage {
|
||||
src_zone,
|
||||
src_block_id,
|
||||
src_tx_index,
|
||||
src_program_id: [9_u32; 8],
|
||||
target_program_id: wrapped_token_id,
|
||||
payload: mint_payload(),
|
||||
l1_inclusion_witness: None,
|
||||
};
|
||||
|
||||
let wrapped_config_id = wrapped_token_core::config_account_id(wrapped_token_id);
|
||||
let holding_id = wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT);
|
||||
|
||||
let message = Message::try_new(
|
||||
inbox_id,
|
||||
vec![
|
||||
inbox_config_account_id(inbox_id),
|
||||
seen_id,
|
||||
wrapped_config_id,
|
||||
holding_id,
|
||||
],
|
||||
vec![],
|
||||
InboxInstruction::Dispatch(msg),
|
||||
)
|
||||
.expect("build dispatch message");
|
||||
let tx = PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![]));
|
||||
|
||||
let diff = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0)
|
||||
.expect("a replayed dispatch is a valid no-op, not an error");
|
||||
let public_diff = diff.public_diff();
|
||||
|
||||
// No mint: the holding is never credited on replay.
|
||||
let minted = public_diff.get(&holding_id).map_or(0, |account| {
|
||||
wrapped_token_core::read_balance(&account.data.clone().into_inner())
|
||||
});
|
||||
assert_eq!(minted, 0, "a replayed message must not mint again");
|
||||
|
||||
// The seen-shard is untouched by the no-op.
|
||||
if let Some(seen) = public_diff.get(&seen_id) {
|
||||
let shard_after =
|
||||
SeenShard::from_bytes(&seen.data.clone().into_inner()).expect("seen shard decodes");
|
||||
assert_eq!(shard_after, shard, "replay must not modify the seen-shard");
|
||||
}
|
||||
}
|
||||
162
integration_tests/tests/cross_zone_verified.rs
Normal file
162
integration_tests/tests/cross_zone_verified.rs
Normal file
@ -0,0 +1,162 @@
|
||||
#![expect(
|
||||
clippy::tests_outside_test_module,
|
||||
reason = "top-level test functions are conventional for integration tests"
|
||||
)]
|
||||
|
||||
//! Cross-zone round trip with the indexer in the loop (Option B). A ping on zone
|
||||
//! A is delivered to zone B, and zone B's indexer independently re-derives the
|
||||
//! injected dispatch from zone A's finalized blocks before applying it. The
|
||||
//! payload landing in the indexer's state proves verification passed; a forgery
|
||||
//! would have halted the indexer instead.
|
||||
|
||||
use std::{net::SocketAddr, time::Duration};
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use common::transaction::LeeTransaction;
|
||||
use cross_zone_outbox_core::outbox_pda;
|
||||
use integration_tests::{
|
||||
config::{self, SequencerPartialConfig},
|
||||
indexer_client::IndexerClient,
|
||||
setup::{setup_bedrock_node, setup_indexer, setup_sequencer},
|
||||
};
|
||||
use lee::{AccountId, PublicTransaction, public_transaction::Message};
|
||||
use lee_core::program::ProgramId;
|
||||
use ping_core::{ReceiverInstruction, SenderInstruction, ping_record_pda};
|
||||
use sequencer_core::config::{CrossZoneConfig, CrossZonePeer};
|
||||
use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder};
|
||||
use tokio::test;
|
||||
|
||||
const DELIVERY_TIMEOUT: Duration = Duration::from_secs(600);
|
||||
const PING_PAYLOAD: &[u8] = b"hello-verified-zone";
|
||||
|
||||
#[test]
|
||||
async fn indexer_verifies_and_delivers_cross_zone_ping() -> Result<()> {
|
||||
// Declared first so it outlives both zones (drops run in reverse order).
|
||||
let (_bedrock, bedrock_addr) = setup_bedrock_node()
|
||||
.await
|
||||
.context("Failed to set up shared Bedrock node")?;
|
||||
|
||||
let partial = SequencerPartialConfig::default();
|
||||
let channel_a = config::bedrock_channel_id();
|
||||
let channel_b = config::bedrock_channel_id_b();
|
||||
let zone_a: [u8; 32] = *channel_a.as_ref();
|
||||
let zone_b: [u8; 32] = *channel_b.as_ref();
|
||||
|
||||
let receiver_id = programs::ping_receiver().id();
|
||||
let cross_zone = CrossZoneConfig {
|
||||
peers: vec![CrossZonePeer {
|
||||
channel_id: zone_a,
|
||||
allowed_targets: vec![receiver_id],
|
||||
expected_block_signing_pubkey: None,
|
||||
}],
|
||||
};
|
||||
|
||||
// Zone A: source. Zone B: destination, with the watcher on its sequencer and
|
||||
// the verifier on its indexer.
|
||||
let (seq_a, _seq_a_home) = setup_sequencer(partial, bedrock_addr, vec![], channel_a, None)
|
||||
.await
|
||||
.context("Failed to set up zone A sequencer")?;
|
||||
let (_idx_a, _idx_a_home) = setup_indexer(bedrock_addr, channel_a, None)
|
||||
.await
|
||||
.context("Failed to set up zone A indexer")?;
|
||||
let (_seq_b, _seq_b_home) = setup_sequencer(
|
||||
partial,
|
||||
bedrock_addr,
|
||||
vec![],
|
||||
channel_b,
|
||||
Some(cross_zone.clone()),
|
||||
)
|
||||
.await
|
||||
.context("Failed to set up zone B sequencer")?;
|
||||
let (idx_b, _idx_b_home) = setup_indexer(bedrock_addr, channel_b, Some(cross_zone))
|
||||
.await
|
||||
.context("Failed to set up zone B indexer")?;
|
||||
|
||||
// Submit the ping on zone A, addressed to ping_receiver on zone B.
|
||||
let ping = build_ping_tx(zone_b, receiver_id);
|
||||
sequencer_client(seq_a.addr())?
|
||||
.send_transaction(ping)
|
||||
.await
|
||||
.context("Failed to submit ping on zone A")?;
|
||||
|
||||
// Wait until zone B's indexer records the delivered payload. The indexer only
|
||||
// applies the dispatch after re-deriving and verifying it.
|
||||
let record_id = ping_record_pda(receiver_id);
|
||||
let indexer_url = config::addr_to_url(config::UrlProtocol::Ws, idx_b.addr())
|
||||
.context("Failed to build indexer URL")?;
|
||||
let indexer = IndexerClient::new(&indexer_url)
|
||||
.await
|
||||
.context("Failed to build indexer client")?;
|
||||
|
||||
let delivered = wait_for_indexer_delivery(&indexer, record_id).await?;
|
||||
assert_eq!(
|
||||
delivered, PING_PAYLOAD,
|
||||
"Zone B's indexer must record the verified cross-zone payload"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_ping_tx(target_zone: [u8; 32], receiver_id: ProgramId) -> LeeTransaction {
|
||||
let outbox_id = programs::cross_zone_outbox().id();
|
||||
let ordinal = 0;
|
||||
|
||||
let words = risc0_zkvm::serde::to_vec(&ReceiverInstruction::Record {
|
||||
payload: PING_PAYLOAD.to_vec(),
|
||||
})
|
||||
.expect("serialize ping instruction");
|
||||
let payload: Vec<u8> = words.iter().flat_map(|word| word.to_le_bytes()).collect();
|
||||
|
||||
let send = SenderInstruction::Send {
|
||||
outbox_program_id: outbox_id,
|
||||
target_zone,
|
||||
target_program_id: receiver_id,
|
||||
target_accounts: vec![ping_record_pda(receiver_id).into_value()],
|
||||
payload,
|
||||
ordinal,
|
||||
};
|
||||
|
||||
let outbox_account = outbox_pda(outbox_id, &target_zone, ordinal);
|
||||
let message = Message::try_new(
|
||||
programs::ping_sender().id(),
|
||||
vec![outbox_account],
|
||||
vec![],
|
||||
send,
|
||||
)
|
||||
.expect("build ping message");
|
||||
LeeTransaction::Public(PublicTransaction::new(
|
||||
message,
|
||||
lee::public_transaction::WitnessSet::from_raw_parts(vec![]),
|
||||
))
|
||||
}
|
||||
|
||||
fn sequencer_client(addr: SocketAddr) -> Result<SequencerClient> {
|
||||
let url = config::addr_to_url(config::UrlProtocol::Http, addr)
|
||||
.context("Failed to build sequencer URL")?;
|
||||
SequencerClientBuilder::default()
|
||||
.build(url)
|
||||
.context("Failed to build sequencer client")
|
||||
}
|
||||
|
||||
/// Polls zone B's indexer until the ping record PDA holds a payload.
|
||||
async fn wait_for_indexer_delivery(
|
||||
indexer: &IndexerClient,
|
||||
record_id: AccountId,
|
||||
) -> Result<Vec<u8>> {
|
||||
let account_id = indexer_service_protocol::AccountId {
|
||||
value: record_id.into_value(),
|
||||
};
|
||||
let wait = async {
|
||||
loop {
|
||||
let account =
|
||||
indexer_service_rpc::RpcClient::get_account(&**indexer, account_id).await?;
|
||||
let data = account.data.0;
|
||||
if !data.is_empty() {
|
||||
return Ok::<Vec<u8>, anyhow::Error>(data);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
}
|
||||
};
|
||||
tokio::time::timeout(DELIVERY_TIMEOUT, wait)
|
||||
.await
|
||||
.context("Zone B's indexer did not record the payload in time")?
|
||||
}
|
||||
@ -50,8 +50,12 @@ pub fn setup_indexer_ffi(bedrock_addr: SocketAddr) -> Result<(IndexerServiceFFI,
|
||||
temp_indexer_dir.path().display()
|
||||
);
|
||||
|
||||
let indexer_config = integration_tests::config::indexer_config(bedrock_addr)
|
||||
.context("Failed to create Indexer config")?;
|
||||
let indexer_config = integration_tests::config::indexer_config(
|
||||
bedrock_addr,
|
||||
integration_tests::config::bedrock_channel_id(),
|
||||
None,
|
||||
)
|
||||
.context("Failed to create Indexer config")?;
|
||||
|
||||
let config_json = serde_json::to_vec(&indexer_config)?;
|
||||
let config_path = temp_indexer_dir.path().join("indexer_config.json");
|
||||
|
||||
54
integration_tests/tests/indexer_stall.rs
Normal file
54
integration_tests/tests/indexer_stall.rs
Normal 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(())
|
||||
}
|
||||
110
integration_tests/tests/two_zone.rs
Normal file
110
integration_tests/tests/two_zone.rs
Normal file
@ -0,0 +1,110 @@
|
||||
#![expect(
|
||||
clippy::tests_outside_test_module,
|
||||
reason = "top-level test functions are conventional for integration tests"
|
||||
)]
|
||||
|
||||
//! Two zones (sequencer + indexer each, on separate channels) sharing one
|
||||
//! Bedrock node, each producing and finalizing blocks independently.
|
||||
|
||||
use std::{net::SocketAddr, time::Duration};
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use indexer_service_rpc::RpcClient as _;
|
||||
use integration_tests::{
|
||||
config::{self, SequencerPartialConfig},
|
||||
indexer_client::IndexerClient,
|
||||
setup::{setup_bedrock_node, setup_indexer, setup_sequencer},
|
||||
};
|
||||
use sequencer_service_rpc::{RpcClient as _, SequencerClientBuilder};
|
||||
use tokio::test;
|
||||
|
||||
const ZONE_LIVE_TIMEOUT: Duration = Duration::from_secs(360);
|
||||
|
||||
// Genesis is block 1, so reaching 2 means a block was produced past it.
|
||||
const MIN_BLOCK_ID: u64 = 2;
|
||||
|
||||
#[test]
|
||||
async fn two_zones_share_one_bedrock_and_both_advance() -> Result<()> {
|
||||
// Declared first so it outlives both zones (drops run in reverse order).
|
||||
let (_bedrock, bedrock_addr) = setup_bedrock_node()
|
||||
.await
|
||||
.context("Failed to set up shared Bedrock node")?;
|
||||
|
||||
let partial = SequencerPartialConfig::default();
|
||||
let channel_a = config::bedrock_channel_id();
|
||||
let channel_b = config::bedrock_channel_id_b();
|
||||
|
||||
// Empty genesis is enough: the clock transaction drives block production.
|
||||
let (seq_a, _seq_a_home) = setup_sequencer(partial, bedrock_addr, vec![], channel_a, None)
|
||||
.await
|
||||
.context("Failed to set up zone A sequencer")?;
|
||||
let (idx_a, _idx_a_home) = setup_indexer(bedrock_addr, channel_a, None)
|
||||
.await
|
||||
.context("Failed to set up zone A indexer")?;
|
||||
let (seq_b, _seq_b_home) = setup_sequencer(partial, bedrock_addr, vec![], channel_b, None)
|
||||
.await
|
||||
.context("Failed to set up zone B sequencer")?;
|
||||
let (idx_b, _idx_b_home) = setup_indexer(bedrock_addr, channel_b, None)
|
||||
.await
|
||||
.context("Failed to set up zone B indexer")?;
|
||||
|
||||
let (height_a, height_b) = tokio::try_join!(
|
||||
wait_until_zone_live("A", seq_a.addr(), idx_a.addr()),
|
||||
wait_until_zone_live("B", seq_b.addr(), idx_b.addr()),
|
||||
)?;
|
||||
|
||||
assert!(
|
||||
height_a >= MIN_BLOCK_ID,
|
||||
"Zone A indexer only reached block {height_a}, expected >= {MIN_BLOCK_ID}"
|
||||
);
|
||||
assert!(
|
||||
height_b >= MIN_BLOCK_ID,
|
||||
"Zone B indexer only reached block {height_b}, expected >= {MIN_BLOCK_ID}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Wait for the sequencer to produce past genesis and the indexer to finalize up
|
||||
/// to it. Returns the indexer's finalized block id.
|
||||
async fn wait_until_zone_live(
|
||||
label: &str,
|
||||
sequencer_addr: SocketAddr,
|
||||
indexer_addr: SocketAddr,
|
||||
) -> Result<u64> {
|
||||
let sequencer_url = config::addr_to_url(config::UrlProtocol::Http, sequencer_addr)
|
||||
.context("Failed to build sequencer URL")?;
|
||||
let sequencer = SequencerClientBuilder::default()
|
||||
.build(sequencer_url)
|
||||
.context("Failed to build sequencer client")?;
|
||||
|
||||
let indexer_url = config::addr_to_url(config::UrlProtocol::Ws, indexer_addr)
|
||||
.context("Failed to build indexer URL")?;
|
||||
let indexer = IndexerClient::new(&indexer_url)
|
||||
.await
|
||||
.context("Failed to build indexer client")?;
|
||||
|
||||
let wait = async {
|
||||
loop {
|
||||
if sequencer.get_last_block_id().await? >= MIN_BLOCK_ID {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
}
|
||||
let target = sequencer.get_last_block_id().await?;
|
||||
loop {
|
||||
let finalized = indexer.get_last_finalized_block_id().await?.unwrap_or(0);
|
||||
if finalized >= target {
|
||||
log::info!(
|
||||
"Zone {label} live: sequencer at {target}, indexer finalized {finalized}"
|
||||
);
|
||||
return Ok::<u64, anyhow::Error>(finalized);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
}
|
||||
};
|
||||
|
||||
tokio::time::timeout(ZONE_LIVE_TIMEOUT, wait)
|
||||
.await
|
||||
.with_context(|| format!("Zone {label} did not become live within {ZONE_LIVE_TIMEOUT:?}"))?
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
use aes_gcm::{Aes256Gcm, KeyInit as _, aead::Aead as _};
|
||||
use lee_core::{
|
||||
SharedSecretKey,
|
||||
Identifier, SharedSecretKey,
|
||||
encryption::{EphemeralPublicKey, ML_KEM_768_CIPHERTEXT_LEN, ViewingPublicKey},
|
||||
program::{PdaSeed, ProgramId},
|
||||
};
|
||||
@ -146,6 +146,23 @@ impl GroupKeyHolder {
|
||||
SecretSpendingKey(hasher.finalize_fixed().into()).produce_private_key_holder(None)
|
||||
}
|
||||
|
||||
/// Derive keys for a shared regular account from its `identifier`.
|
||||
///
|
||||
/// Computes the derivation seed via the `SharedAccountTag` domain separator, then delegates
|
||||
/// to [`Self::derive_keys_for_shared_account`].
|
||||
#[must_use]
|
||||
pub fn derive_regular_shared_account_keys_from_identifier(
|
||||
&self,
|
||||
identifier: Identifier,
|
||||
) -> PrivateKeyHolder {
|
||||
const PREFIX: &[u8; 32] = b"/LEE/v0.3/SharedAccountTag/\x00\x00\x00\x00\x00";
|
||||
let mut hasher = sha2::Sha256::new();
|
||||
hasher.update(PREFIX);
|
||||
hasher.update(identifier.to_le_bytes());
|
||||
let derivation_seed: [u8; 32] = hasher.finalize().into();
|
||||
self.derive_keys_for_shared_account(&derivation_seed)
|
||||
}
|
||||
|
||||
/// Encrypts this holder's GMS under the recipient's [`SealingPublicKey`].
|
||||
///
|
||||
/// Uses ML-KEM-768 encapsulation to derive a shared secret, then AES-256-GCM to encrypt
|
||||
|
||||
@ -324,7 +324,7 @@ impl AccountPostState {
|
||||
pub type BlockValidityWindow = ValidityWindow<BlockId>;
|
||||
pub type TimestampValidityWindow = ValidityWindow<Timestamp>;
|
||||
|
||||
#[derive(Clone, Copy, Serialize, Deserialize)]
|
||||
#[derive(Clone, Copy, Default, Serialize, Deserialize)]
|
||||
#[cfg_attr(
|
||||
any(feature = "host", test),
|
||||
derive(Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)
|
||||
|
||||
@ -11,7 +11,7 @@ use crate::{AccountId, error::LeeError};
|
||||
|
||||
const PREFIX: &[u8; 32] = b"/LEE/v0.3/Message/Privacy/\x00\x00\x00\x00\x00\x00";
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
|
||||
#[derive(Clone, Default, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
|
||||
pub struct Message {
|
||||
pub public_account_ids: Vec<AccountId>,
|
||||
pub nonces: Vec<Nonce>,
|
||||
@ -84,6 +84,20 @@ impl Message {
|
||||
|
||||
Sha256::digest(bytes).into()
|
||||
}
|
||||
|
||||
/// Ensure that the commitments, nullifiers, and ciphertexts agree.
|
||||
pub fn validate_note_lengths(&self) -> Result<usize, LeeError> {
|
||||
let count = self.new_nullifiers.len();
|
||||
if self.new_commitments.len() != count || self.encrypted_private_post_states.len() != count
|
||||
{
|
||||
return Err(LeeError::InvalidInput(format!(
|
||||
"Note vectors disagree in length with {count} nullifiers, {} commitments, and {} ciphertexts",
|
||||
self.new_commitments.len(),
|
||||
self.encrypted_private_post_states.len(),
|
||||
)));
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@ -141,6 +155,20 @@ pub mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_note_lengths_accepts_matching_and_rejects_mismatched() {
|
||||
assert_eq!(Message::default().validate_note_lengths().unwrap(), 0);
|
||||
|
||||
let mismatched = Message {
|
||||
new_commitments: vec![Commitment::new(
|
||||
&AccountId::new([0; 32]),
|
||||
&Account::default(),
|
||||
)],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(mismatched.validate_note_lengths().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_privacy_pinned() {
|
||||
let msg = Message {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
17
lez/cross_zone/Cargo.toml
Normal file
@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "cross_zone"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
lee.workspace = true
|
||||
lee_core.workspace = true
|
||||
programs.workspace = true
|
||||
cross_zone_inbox_core.workspace = true
|
||||
bridge_lock_core.workspace = true
|
||||
ping_core.workspace = true
|
||||
risc0-zkvm.workspace = true
|
||||
189
lez/cross_zone/src/lib.rs
Normal file
189
lez/cross_zone/src/lib.rs
Normal file
@ -0,0 +1,189 @@
|
||||
//! Host-side cross-zone helpers that need program ids (`programs`) or the state
|
||||
//! machine (`lee`), kept out of the guest-pure cores. Mirrors `system_accounts`:
|
||||
//! it resolves builtin program ids and bakes them into transactions and genesis
|
||||
//! accounts for the watcher (sequencer) and verifier (indexer).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub use cross_zone_inbox_core::{CrossZoneConfig, CrossZonePeer};
|
||||
use cross_zone_inbox_core::{
|
||||
CrossZoneMessage, InboxConfig, Instruction, ZoneId, inbox_config_account_id,
|
||||
inbox_seen_shard_account_id,
|
||||
};
|
||||
use lee_core::{
|
||||
account::{Account, AccountId},
|
||||
program::ProgramId,
|
||||
};
|
||||
|
||||
/// The cross-zone emission fields a watcher or verifier reads off a source
|
||||
/// transaction, common to every emitter program.
|
||||
pub struct Emission {
|
||||
pub target_zone: ZoneId,
|
||||
pub target_program_id: ProgramId,
|
||||
pub target_accounts: Vec<[u8; 32]>,
|
||||
pub payload: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Whether a program may only be invoked by sequencer-origin transactions.
|
||||
///
|
||||
/// The cross-zone inbox is injected solely by the watcher; a user-submitted call
|
||||
/// must be rejected at ingress, since `TransactionOrigin` is not carried in the
|
||||
/// block.
|
||||
#[must_use]
|
||||
pub fn is_sequencer_only_program(program_id: ProgramId) -> bool {
|
||||
program_id == programs::cross_zone_inbox().id()
|
||||
}
|
||||
|
||||
/// Extracts the cross-zone emission from a source transaction.
|
||||
///
|
||||
/// Recognizes the known emitter programs (`ping_sender`, `bridge_lock`). The
|
||||
/// watcher and verifier both use this so they agree on what a given source tx
|
||||
/// emits.
|
||||
#[must_use]
|
||||
pub fn extract_emission(program_id: ProgramId, instruction_data: &[u32]) -> Option<Emission> {
|
||||
if program_id == programs::ping_sender().id() {
|
||||
let ping_core::SenderInstruction::Send {
|
||||
target_zone,
|
||||
target_program_id,
|
||||
target_accounts,
|
||||
payload,
|
||||
..
|
||||
} = risc0_zkvm::serde::from_slice(instruction_data).ok()?;
|
||||
Some(Emission {
|
||||
target_zone,
|
||||
target_program_id,
|
||||
target_accounts,
|
||||
payload,
|
||||
})
|
||||
} else if program_id == programs::bridge_lock().id() {
|
||||
let bridge_lock_core::Instruction::Lock {
|
||||
target_zone,
|
||||
target_program_id,
|
||||
target_accounts,
|
||||
payload,
|
||||
..
|
||||
} = risc0_zkvm::serde::from_slice(instruction_data).ok()?;
|
||||
Some(Emission {
|
||||
target_zone,
|
||||
target_program_id,
|
||||
target_accounts,
|
||||
payload,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the sequencer-origin dispatch transaction. Pure for fixed inputs, so
|
||||
/// the watcher's injected tx and the indexer's re-derived tx are byte-identical.
|
||||
fn build_inbox_dispatch_tx(
|
||||
inbox_id: ProgramId,
|
||||
msg: &CrossZoneMessage,
|
||||
target_account_ids: Vec<AccountId>,
|
||||
) -> lee::PublicTransaction {
|
||||
let mut account_ids = Vec::with_capacity(target_account_ids.len().saturating_add(2));
|
||||
account_ids.push(inbox_config_account_id(inbox_id));
|
||||
account_ids.push(inbox_seen_shard_account_id(
|
||||
inbox_id,
|
||||
&msg.src_zone,
|
||||
msg.src_block_id,
|
||||
));
|
||||
account_ids.extend(target_account_ids);
|
||||
|
||||
let message = lee::public_transaction::Message::try_new(
|
||||
inbox_id,
|
||||
account_ids,
|
||||
vec![],
|
||||
Instruction::Dispatch(msg.clone()),
|
||||
)
|
||||
.expect("inbox dispatch instruction must serialize");
|
||||
|
||||
lee::PublicTransaction::new(
|
||||
message,
|
||||
lee::public_transaction::WitnessSet::from_raw_parts(vec![]),
|
||||
)
|
||||
}
|
||||
|
||||
/// Builds the dispatch transaction for one peer emission.
|
||||
///
|
||||
/// Both the sequencer's watcher and the indexer's verifier go through this so
|
||||
/// their transactions are byte-identical for the same emission (the basis of the
|
||||
/// Option B check).
|
||||
#[must_use]
|
||||
pub fn build_dispatch_from_emission(
|
||||
src_zone: ZoneId,
|
||||
src_block_id: u64,
|
||||
src_tx_index: u32,
|
||||
src_program_id: ProgramId,
|
||||
target_program_id: ProgramId,
|
||||
target_accounts: &[[u8; 32]],
|
||||
payload: Vec<u8>,
|
||||
) -> lee::PublicTransaction {
|
||||
let msg = CrossZoneMessage {
|
||||
src_zone,
|
||||
src_block_id,
|
||||
src_tx_index,
|
||||
src_program_id,
|
||||
target_program_id,
|
||||
payload,
|
||||
l1_inclusion_witness: None,
|
||||
};
|
||||
let target_ids = target_accounts
|
||||
.iter()
|
||||
.copied()
|
||||
.map(AccountId::new)
|
||||
.collect();
|
||||
build_inbox_dispatch_tx(programs::cross_zone_inbox().id(), &msg, target_ids)
|
||||
}
|
||||
|
||||
/// Builds the inbox config account a zone seeds into genesis state.
|
||||
///
|
||||
/// Lets the inbox guest authorize inbound peer messages. The sequencer and
|
||||
/// indexer seed the same account from the same config, keeping their replayed
|
||||
/// state consistent.
|
||||
#[must_use]
|
||||
pub fn build_inbox_config_account(
|
||||
self_zone: ZoneId,
|
||||
cross_zone: &CrossZoneConfig,
|
||||
) -> (AccountId, Account) {
|
||||
let inbox_id = programs::cross_zone_inbox().id();
|
||||
|
||||
let mut allowed_targets = BTreeMap::new();
|
||||
for peer in &cross_zone.peers {
|
||||
allowed_targets.insert(peer.channel_id, peer.allowed_targets.clone());
|
||||
}
|
||||
let config = InboxConfig {
|
||||
self_zone,
|
||||
allowed_peers: BTreeMap::new(),
|
||||
allowed_targets,
|
||||
};
|
||||
|
||||
let account = Account {
|
||||
program_owner: inbox_id,
|
||||
balance: 0,
|
||||
data: config
|
||||
.to_bytes()
|
||||
.try_into()
|
||||
.expect("inbox config fits in account data"),
|
||||
nonce: 0_u128.into(),
|
||||
};
|
||||
(inbox_config_account_id(inbox_id), account)
|
||||
}
|
||||
|
||||
/// Builds the genesis holding account funding a holder's bridgeable balance.
|
||||
///
|
||||
/// Owned by `bridge_lock`, data is the LE balance. Not produced by any
|
||||
/// transaction, so the sequencer and indexer both seed it through this one
|
||||
/// builder.
|
||||
#[must_use]
|
||||
pub fn build_holding_account(holder: AccountId, amount: u128) -> (AccountId, Account) {
|
||||
let account = Account {
|
||||
program_owner: programs::bridge_lock().id(),
|
||||
data: bridge_lock_core::balance_bytes(amount)
|
||||
.to_vec()
|
||||
.try_into()
|
||||
.expect("balance fits in account data"),
|
||||
..Default::default()
|
||||
};
|
||||
(holder, account)
|
||||
}
|
||||
@ -16,6 +16,9 @@ common.workspace = true
|
||||
logos-blockchain-zone-sdk.workspace = true
|
||||
lee.workspace = true
|
||||
lee_core.workspace = true
|
||||
cross_zone.workspace = true
|
||||
cross_zone_inbox_core.workspace = true
|
||||
programs.workspace = true
|
||||
storage.workspace = true
|
||||
testnet_initial_state.workspace = true
|
||||
|
||||
@ -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
550
lez/indexer/core/src/chain_consistency.rs
Normal file
550
lez/indexer/core/src/chain_consistency.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
500
lez/indexer/core/src/cross_zone_verifier.rs
Normal file
500
lez/indexer/core/src/cross_zone_verifier.rs
Normal file
@ -0,0 +1,500 @@
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use common::{block::Block, transaction::LeeTransaction};
|
||||
use cross_zone::{build_dispatch_from_emission, extract_emission};
|
||||
use cross_zone_inbox_core::{
|
||||
CrossZoneMessage, Instruction as InboxInstruction, MessageKey, ZoneId, message_key,
|
||||
};
|
||||
use futures::StreamExt as _;
|
||||
use lee::PublicKey;
|
||||
use log::{error, info};
|
||||
use logos_blockchain_core::mantle::ops::channel::ChannelId;
|
||||
use logos_blockchain_zone_sdk::{
|
||||
CommonHttpClient, ZoneMessage, adapter::NodeHttpClient, indexer::ZoneIndexer,
|
||||
};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::config::IndexerConfig;
|
||||
|
||||
/// How often the verifier logs that it is still waiting on a lagging peer reader,
|
||||
/// so a stuck wait is observable without rejecting a legitimate message.
|
||||
const LAG_LOG_INTERVAL: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Cache of finalized peer-zone blocks, filled by per-peer reader tasks and read
|
||||
/// by the verifier to re-derive cross-zone dispatch transactions.
|
||||
#[derive(Clone, Default)]
|
||||
struct PeerBlocks {
|
||||
chains: Arc<RwLock<HashMap<ZoneId, HashMap<u64, Block>>>>,
|
||||
}
|
||||
|
||||
impl PeerBlocks {
|
||||
async fn insert(&self, zone: ZoneId, block: Block) {
|
||||
self.chains
|
||||
.write()
|
||||
.await
|
||||
.entry(zone)
|
||||
.or_default()
|
||||
.insert(block.header.block_id, block);
|
||||
}
|
||||
|
||||
async fn get(&self, zone: ZoneId, block_id: u64) -> Option<Block> {
|
||||
self.chains
|
||||
.read()
|
||||
.await
|
||||
.get(&zone)
|
||||
.and_then(|chain| chain.get(&block_id).cloned())
|
||||
}
|
||||
|
||||
/// The highest block id this reader has finalized for `zone`, or `None` if it
|
||||
/// has read nothing yet. Used to tell forgery (we have read past the
|
||||
/// referenced block and it is absent) from lag (we simply have not caught up).
|
||||
async fn highest_seen(&self, zone: ZoneId) -> Option<u64> {
|
||||
self.chains
|
||||
.read()
|
||||
.await
|
||||
.get(&zone)
|
||||
.and_then(|chain| chain.keys().copied().max())
|
||||
}
|
||||
}
|
||||
|
||||
/// The indexer-side Option B verifier.
|
||||
///
|
||||
/// For every cross-zone dispatch in a block it re-derives the transaction from
|
||||
/// the peer's finalized block and rejects it if the bytes differ (a forgery) or
|
||||
/// the message was already delivered (a replay), so delivery no longer relies on
|
||||
/// trusting the sequencer.
|
||||
#[derive(Clone)]
|
||||
pub struct CrossZoneVerifier {
|
||||
self_zone: ZoneId,
|
||||
/// Pinned block-signing key per peer zone, enforced during re-derivation.
|
||||
peer_pubkeys: HashMap<ZoneId, PublicKey>,
|
||||
peers: PeerBlocks,
|
||||
seen: Arc<RwLock<HashSet<MessageKey>>>,
|
||||
}
|
||||
|
||||
impl CrossZoneVerifier {
|
||||
/// Builds the verifier and spawns one peer reader per configured peer.
|
||||
/// Returns `None` when cross-zone messaging is disabled.
|
||||
pub fn start(config: &IndexerConfig) -> Option<Self> {
|
||||
let cross_zone = config.cross_zone.as_ref()?;
|
||||
let self_zone: ZoneId = *config.channel_id.as_ref();
|
||||
let peers = PeerBlocks::default();
|
||||
let mut peer_pubkeys = HashMap::new();
|
||||
|
||||
for peer in &cross_zone.peers {
|
||||
let node = NodeHttpClient::new(
|
||||
CommonHttpClient::new(config.bedrock_config.auth.clone().map(Into::into)),
|
||||
config.bedrock_config.addr.clone(),
|
||||
);
|
||||
if let Some(bytes) = peer.expected_block_signing_pubkey {
|
||||
let pubkey = PublicKey::try_new(bytes)
|
||||
.expect("configured peer block-signing pubkey is a valid key");
|
||||
peer_pubkeys.insert(peer.channel_id, pubkey);
|
||||
}
|
||||
tokio::spawn(read_peer(
|
||||
ZoneIndexer::new(ChannelId::from(peer.channel_id), node),
|
||||
peer.channel_id,
|
||||
peers.clone(),
|
||||
config.consensus_info_polling_interval,
|
||||
));
|
||||
}
|
||||
|
||||
Some(Self {
|
||||
self_zone,
|
||||
peer_pubkeys,
|
||||
peers,
|
||||
seen: Arc::new(RwLock::new(HashSet::new())),
|
||||
})
|
||||
}
|
||||
|
||||
/// Verifies every cross-zone dispatch in a block, returning `Err` on the
|
||||
/// first forged or replayed dispatch. The caller halts ingestion on error.
|
||||
pub async fn verify_block(&self, block: &Block) -> Result<()> {
|
||||
for tx in &block.body.transactions {
|
||||
let Some(msg) = Self::decode_dispatch(tx) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let key = message_key(&msg.src_zone, msg.src_block_id, msg.src_tx_index);
|
||||
if self.seen.read().await.contains(&key) {
|
||||
bail!(
|
||||
"cross-zone replay: message {} re-delivered",
|
||||
hex::encode(key)
|
||||
);
|
||||
}
|
||||
|
||||
let expected = self.rederive(&msg).await?;
|
||||
if LeeTransaction::Public(expected) != *tx {
|
||||
bail!(
|
||||
"forged cross-zone dispatch from zone {} block {} tx {}: re-derivation mismatch",
|
||||
hex::encode(msg.src_zone),
|
||||
msg.src_block_id,
|
||||
msg.src_tx_index
|
||||
);
|
||||
}
|
||||
|
||||
self.seen.write().await.insert(key);
|
||||
info!(
|
||||
"Verified cross-zone dispatch from zone {} block {} tx {}",
|
||||
hex::encode(msg.src_zone),
|
||||
msg.src_block_id,
|
||||
msg.src_tx_index
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Decodes a transaction into the cross-zone message it dispatches, or `None`
|
||||
/// if it is not an inbox dispatch.
|
||||
fn decode_dispatch(tx: &LeeTransaction) -> Option<CrossZoneMessage> {
|
||||
let LeeTransaction::Public(public_tx) = tx else {
|
||||
return None;
|
||||
};
|
||||
if public_tx.message().program_id != programs::cross_zone_inbox().id() {
|
||||
return None;
|
||||
}
|
||||
match risc0_zkvm::serde::from_slice::<InboxInstruction, _>(
|
||||
&public_tx.message().instruction_data,
|
||||
) {
|
||||
Ok(InboxInstruction::Dispatch(msg)) => Some(msg),
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-derives the dispatch transaction the watcher should have injected for
|
||||
/// `msg`, reading the source emission from the peer's finalized block.
|
||||
async fn rederive(&self, msg: &CrossZoneMessage) -> Result<lee::PublicTransaction> {
|
||||
let peer_block = self
|
||||
.wait_for_peer_block(msg.src_zone, msg.src_block_id)
|
||||
.await?;
|
||||
|
||||
// Equivocation defense: the source block must be signed by the peer's
|
||||
// pinned block-signing key, not merely inscribed on the channel.
|
||||
if let Some(expected) = self.peer_pubkeys.get(&msg.src_zone)
|
||||
&& !peer_block.is_signed_by(expected)
|
||||
{
|
||||
bail!(
|
||||
"forged cross-zone dispatch: peer zone {} block {} is not signed by the pinned block-signing key",
|
||||
hex::encode(msg.src_zone),
|
||||
msg.src_block_id
|
||||
);
|
||||
}
|
||||
|
||||
let emission_tx = peer_block
|
||||
.body
|
||||
.transactions
|
||||
.get(usize::try_from(msg.src_tx_index).expect("u32 index fits in usize"))
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"src_tx_index {} out of range in peer block",
|
||||
msg.src_tx_index
|
||||
)
|
||||
})?;
|
||||
|
||||
let LeeTransaction::Public(emission_tx) = emission_tx else {
|
||||
bail!("peer emission transaction is not public");
|
||||
};
|
||||
let message = emission_tx.message();
|
||||
let emission =
|
||||
extract_emission(message.program_id, &message.instruction_data).ok_or_else(|| {
|
||||
anyhow::anyhow!("peer transaction at src_tx_index is not a recognized emitter")
|
||||
})?;
|
||||
|
||||
if emission.target_zone != self.self_zone {
|
||||
bail!("peer emission targets a different zone");
|
||||
}
|
||||
|
||||
Ok(build_dispatch_from_emission(
|
||||
msg.src_zone,
|
||||
msg.src_block_id,
|
||||
msg.src_tx_index,
|
||||
message.program_id,
|
||||
emission.target_program_id,
|
||||
&emission.target_accounts,
|
||||
emission.payload,
|
||||
))
|
||||
}
|
||||
|
||||
/// Resolves the referenced peer block, distinguishing forgery from lag.
|
||||
///
|
||||
/// If the block is cached, return it. If our peer reader has already
|
||||
/// finalized past `block_id` and we still do not have it, the reference is to
|
||||
/// a block that does not exist on the peer chain, a forgery, so reject now.
|
||||
/// Otherwise the reader simply has not caught up yet: keep waiting, since a
|
||||
/// legitimate dispatch is only injected after its peer block finalized and
|
||||
/// our reader of the same finalized chain will see it too. Rejecting on a
|
||||
/// timeout here would turn a lagging reader into a permanent halt of an
|
||||
/// honest message.
|
||||
async fn wait_for_peer_block(&self, zone: ZoneId, block_id: u64) -> Result<Block> {
|
||||
let mut waited = Duration::ZERO;
|
||||
loop {
|
||||
if let Some(block) = self.peers.get(zone, block_id).await {
|
||||
return Ok(block);
|
||||
}
|
||||
if self
|
||||
.peers
|
||||
.highest_seen(zone)
|
||||
.await
|
||||
.is_some_and(|h| h >= block_id)
|
||||
{
|
||||
bail!(
|
||||
"forged cross-zone reference: peer zone {} finalized past block {} but it is absent",
|
||||
hex::encode(zone),
|
||||
block_id
|
||||
);
|
||||
}
|
||||
if !waited.is_zero() && waited.as_secs().is_multiple_of(LAG_LOG_INTERVAL.as_secs()) {
|
||||
info!(
|
||||
"Waiting for peer zone {} to finalize block {} ({}s); reader is behind",
|
||||
hex::encode(zone),
|
||||
block_id,
|
||||
waited.as_secs()
|
||||
);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
waited = waited.saturating_add(Duration::from_secs(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads a peer zone's finalized blocks from Bedrock into the shared cache.
|
||||
#[expect(
|
||||
clippy::infinite_loop,
|
||||
reason = "the peer reader runs for the lifetime of the indexer process"
|
||||
)]
|
||||
async fn read_peer(
|
||||
zone_indexer: ZoneIndexer<NodeHttpClient>,
|
||||
peer_zone: ZoneId,
|
||||
peers: PeerBlocks,
|
||||
poll_interval: Duration,
|
||||
) {
|
||||
info!(
|
||||
"Cross-zone peer reader started for {}",
|
||||
hex::encode(peer_zone)
|
||||
);
|
||||
|
||||
let mut cursor = None;
|
||||
loop {
|
||||
let stream = match zone_indexer.next_messages(cursor).await {
|
||||
Ok(stream) => stream,
|
||||
Err(err) => {
|
||||
error!(
|
||||
"Peer reader next_messages failed for {}: {err}",
|
||||
hex::encode(peer_zone)
|
||||
);
|
||||
tokio::time::sleep(poll_interval).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let mut stream = std::pin::pin!(stream);
|
||||
|
||||
while let Some((msg, slot)) = stream.next().await {
|
||||
if let ZoneMessage::Block(zone_block) = msg {
|
||||
match borsh::from_slice::<Block>(&zone_block.data) {
|
||||
Ok(block) => peers.insert(peer_zone, block).await,
|
||||
Err(err) => error!("Peer reader failed to deserialize block: {err}"),
|
||||
}
|
||||
}
|
||||
cursor = Some(slot);
|
||||
}
|
||||
|
||||
tokio::time::sleep(poll_interval).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use common::test_utils::produce_dummy_block;
|
||||
use lee::{
|
||||
PrivateKey, PublicKey, PublicTransaction,
|
||||
public_transaction::{Message, WitnessSet},
|
||||
};
|
||||
use ping_core::{SenderInstruction, ping_record_pda};
|
||||
|
||||
use super::*;
|
||||
|
||||
const SELF_ZONE: ZoneId = [1; 32];
|
||||
const PEER_ZONE: ZoneId = [2; 32];
|
||||
const PEER_BLOCK_ID: u64 = 5;
|
||||
|
||||
fn verifier() -> CrossZoneVerifier {
|
||||
verifier_with_pinned_keys(HashMap::new())
|
||||
}
|
||||
|
||||
fn verifier_with_pinned_keys(peer_pubkeys: HashMap<ZoneId, PublicKey>) -> CrossZoneVerifier {
|
||||
CrossZoneVerifier {
|
||||
self_zone: SELF_ZONE,
|
||||
peer_pubkeys,
|
||||
peers: PeerBlocks::default(),
|
||||
seen: Arc::new(RwLock::new(HashSet::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// A `ping_sender` emission addressed to `SELF_ZONE` carrying `payload`.
|
||||
fn emission(payload: &[u8]) -> LeeTransaction {
|
||||
let receiver_id = programs::ping_receiver().id();
|
||||
let send = SenderInstruction::Send {
|
||||
outbox_program_id: programs::cross_zone_outbox().id(),
|
||||
target_zone: SELF_ZONE,
|
||||
target_program_id: receiver_id,
|
||||
target_accounts: vec![ping_record_pda(receiver_id).into_value()],
|
||||
payload: payload.to_vec(),
|
||||
ordinal: 0,
|
||||
};
|
||||
let message = Message::try_new(programs::ping_sender().id(), vec![], vec![], send)
|
||||
.expect("emission serializes");
|
||||
LeeTransaction::Public(PublicTransaction::new(
|
||||
message,
|
||||
WitnessSet::from_raw_parts(vec![]),
|
||||
))
|
||||
}
|
||||
|
||||
/// The dispatch a watcher would inject for a `PEER_BLOCK_ID` emission of `payload`.
|
||||
fn dispatch(payload: &[u8]) -> LeeTransaction {
|
||||
let receiver_id = programs::ping_receiver().id();
|
||||
LeeTransaction::Public(build_dispatch_from_emission(
|
||||
PEER_ZONE,
|
||||
PEER_BLOCK_ID,
|
||||
0,
|
||||
programs::ping_sender().id(),
|
||||
receiver_id,
|
||||
&[ping_record_pda(receiver_id).into_value()],
|
||||
payload.to_vec(),
|
||||
))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn verifies_dispatch_matching_a_peer_emission() {
|
||||
let verifier = verifier();
|
||||
verifier
|
||||
.peers
|
||||
.insert(
|
||||
PEER_ZONE,
|
||||
produce_dummy_block(PEER_BLOCK_ID, None, vec![emission(b"hi")]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let block = produce_dummy_block(9, None, vec![dispatch(b"hi")]);
|
||||
verifier
|
||||
.verify_block(&block)
|
||||
.await
|
||||
.expect("dispatch matching the peer emission verifies");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_dispatch_with_no_matching_emission() {
|
||||
let verifier = verifier();
|
||||
// The peer block carries the real emission, but the block claims a
|
||||
// different payload, so re-derivation does not reproduce it.
|
||||
verifier
|
||||
.peers
|
||||
.insert(
|
||||
PEER_ZONE,
|
||||
produce_dummy_block(PEER_BLOCK_ID, None, vec![emission(b"real")]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let block = produce_dummy_block(9, None, vec![dispatch(b"forged")]);
|
||||
let err = verifier.verify_block(&block).await.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("forged"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn verifies_dispatch_signed_by_the_pinned_peer_key() {
|
||||
// produce_dummy_block signs with PrivateKey([37; 32]); pin its pubkey.
|
||||
let signer = PublicKey::new_from_private_key(&PrivateKey::try_new([37; 32]).unwrap());
|
||||
let mut keys = HashMap::new();
|
||||
keys.insert(PEER_ZONE, signer);
|
||||
let verifier = verifier_with_pinned_keys(keys);
|
||||
verifier
|
||||
.peers
|
||||
.insert(
|
||||
PEER_ZONE,
|
||||
produce_dummy_block(PEER_BLOCK_ID, None, vec![emission(b"hi")]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let block = produce_dummy_block(9, None, vec![dispatch(b"hi")]);
|
||||
verifier
|
||||
.verify_block(&block)
|
||||
.await
|
||||
.expect("a dispatch from the pinned signer verifies");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_dispatch_from_a_block_not_signed_by_the_pinned_key() {
|
||||
// Pin a different key than the one that signed the peer block.
|
||||
let mut keys = HashMap::new();
|
||||
keys.insert(PEER_ZONE, PublicKey::try_new([42; 32]).unwrap());
|
||||
let verifier = verifier_with_pinned_keys(keys);
|
||||
verifier
|
||||
.peers
|
||||
.insert(
|
||||
PEER_ZONE,
|
||||
produce_dummy_block(PEER_BLOCK_ID, None, vec![emission(b"hi")]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let block = produce_dummy_block(9, None, vec![dispatch(b"hi")]);
|
||||
let err = verifier.verify_block(&block).await.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("pinned"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_reference_to_a_block_the_peer_never_finalized() {
|
||||
let verifier = verifier();
|
||||
// The reader has finalized past PEER_BLOCK_ID (it holds a later block) but
|
||||
// never saw PEER_BLOCK_ID itself, so a dispatch referencing it is a forgery
|
||||
// and must be rejected rather than waited on forever.
|
||||
verifier
|
||||
.peers
|
||||
.insert(
|
||||
PEER_ZONE,
|
||||
produce_dummy_block(PEER_BLOCK_ID + 1, None, vec![emission(b"hi")]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let block = produce_dummy_block(9, None, vec![dispatch(b"hi")]);
|
||||
let err = verifier.verify_block(&block).await.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("forged"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_replayed_dispatch() {
|
||||
let verifier = verifier();
|
||||
verifier
|
||||
.peers
|
||||
.insert(
|
||||
PEER_ZONE,
|
||||
produce_dummy_block(PEER_BLOCK_ID, None, vec![emission(b"hi")]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let first = produce_dummy_block(9, None, vec![dispatch(b"hi")]);
|
||||
verifier
|
||||
.verify_block(&first)
|
||||
.await
|
||||
.expect("first delivery verifies");
|
||||
|
||||
let replay = produce_dummy_block(10, None, vec![dispatch(b"hi")]);
|
||||
let err = verifier.verify_block(&replay).await.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("replay"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
80
lez/indexer/core/src/ingest_error.rs
Normal file
80
lez/indexer/core/src/ingest_error.rs
Normal 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
|
||||
}
|
||||
));
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
64
lez/indexer/core/src/retry.rs
Normal file
64
lez/indexer/core/src/retry.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
25
lez/indexer/core/src/stall_reason.rs
Normal file
25
lez/indexer/core/src/stall_reason.rs
Normal 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,
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
*
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
///
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -3,5 +3,6 @@
|
||||
"bedrock_config": {
|
||||
"addr": "http://host.docker.internal:18080"
|
||||
},
|
||||
"channel_id": "0101010101010101010101010101010101010101010101010101010101010101"
|
||||
"channel_id": "0101010101010101010101010101010101010101010101010101010101010101",
|
||||
"allow_chain_reset": true
|
||||
}
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>,
|
||||
}
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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())
|
||||
};
|
||||
|
||||
@ -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() => {
|
||||
|
||||
@ -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(())
|
||||
}
|
||||
|
||||
@ -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>>,
|
||||
}
|
||||
|
||||
|
||||
@ -54,6 +54,36 @@ name = "vault"
|
||||
path = "vault/src/main.rs"
|
||||
required-features = ["programs"]
|
||||
|
||||
[[bin]]
|
||||
name = "cross_zone_outbox"
|
||||
path = "cross_zone_outbox/src/main.rs"
|
||||
required-features = ["programs"]
|
||||
|
||||
[[bin]]
|
||||
name = "cross_zone_inbox"
|
||||
path = "cross_zone_inbox/src/main.rs"
|
||||
required-features = ["programs"]
|
||||
|
||||
[[bin]]
|
||||
name = "ping_sender"
|
||||
path = "ping_sender/src/main.rs"
|
||||
required-features = ["programs"]
|
||||
|
||||
[[bin]]
|
||||
name = "ping_receiver"
|
||||
path = "ping_receiver/src/main.rs"
|
||||
required-features = ["programs"]
|
||||
|
||||
[[bin]]
|
||||
name = "bridge_lock"
|
||||
path = "bridge_lock/src/main.rs"
|
||||
required-features = ["programs"]
|
||||
|
||||
[[bin]]
|
||||
name = "wrapped_token"
|
||||
path = "wrapped_token/src/main.rs"
|
||||
required-features = ["programs"]
|
||||
|
||||
[features]
|
||||
# TODO: Uncomment once https://github.com/risc0/risc0/issues/3772 is resolved.
|
||||
# default = ["artifacts"]
|
||||
@ -78,6 +108,11 @@ programs = [
|
||||
"dep:faucet_core",
|
||||
"dep:token_core",
|
||||
"dep:vault_core",
|
||||
"dep:cross_zone_inbox_core",
|
||||
"dep:cross_zone_outbox_core",
|
||||
"dep:bridge_lock_core",
|
||||
"dep:wrapped_token_core",
|
||||
"dep:ping_core",
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
@ -92,6 +127,11 @@ clock_core = { workspace = true, optional = true }
|
||||
faucet_core = { workspace = true, optional = true }
|
||||
token_core = { workspace = true, optional = true }
|
||||
vault_core = { workspace = true, optional = true }
|
||||
cross_zone_inbox_core = { workspace = true, optional = true }
|
||||
cross_zone_outbox_core = { workspace = true, optional = true }
|
||||
bridge_lock_core = { workspace = true, optional = true }
|
||||
wrapped_token_core = { workspace = true, optional = true }
|
||||
ping_core = { workspace = true, optional = true }
|
||||
|
||||
amm_program = { path = "amm", optional = true }
|
||||
associated_token_account_program = { path = "associated_token_account", optional = true }
|
||||
|
||||
15
lez/programs/bridge_lock/Cargo.toml
Normal file
15
lez/programs/bridge_lock/Cargo.toml
Normal file
@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "bridge_lock_program"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
lee_core.workspace = true
|
||||
bridge_lock_core.workspace = true
|
||||
cross_zone_outbox_core.workspace = true
|
||||
wrapped_token_core.workspace = true
|
||||
risc0-zkvm.workspace = true
|
||||
12
lez/programs/bridge_lock/core/Cargo.toml
Normal file
12
lez/programs/bridge_lock/core/Cargo.toml
Normal file
@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "bridge_lock_core"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
lee_core.workspace = true
|
||||
serde = { workspace = true, features = ["alloc"] }
|
||||
71
lez/programs/bridge_lock/core/src/lib.rs
Normal file
71
lez/programs/bridge_lock/core/src/lib.rs
Normal file
@ -0,0 +1,71 @@
|
||||
//! Core types for the bridge-lock program, the source side of the cross-zone
|
||||
//! token bridge. A holder locks part of their balance into an escrow and emits a
|
||||
//! cross-zone message minting the wrapped token on the target zone.
|
||||
|
||||
use lee_core::{
|
||||
account::AccountId,
|
||||
program::{PdaSeed, ProgramId},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const ESCROW_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/BridgeLockEscrow/0000/";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Instruction {
|
||||
/// Lock `amount` of the holder's balance and emit a cross-zone message
|
||||
/// minting the wrapped token on `target_zone`. The emission fields mirror
|
||||
/// `cross_zone_outbox::Instruction::Emit` so the watcher reads them directly.
|
||||
///
|
||||
/// Required accounts (3): holder holding (authorized), escrow PDA, outbox PDA.
|
||||
Lock {
|
||||
amount: u128,
|
||||
target_zone: [u8; 32],
|
||||
target_program_id: ProgramId,
|
||||
target_accounts: Vec<[u8; 32]>,
|
||||
payload: Vec<u8>,
|
||||
outbox_program_id: ProgramId,
|
||||
ordinal: u32,
|
||||
},
|
||||
}
|
||||
|
||||
/// PDA accumulating all locked balance on this zone.
|
||||
#[must_use]
|
||||
pub fn escrow_account_id(bridge_lock_id: ProgramId) -> AccountId {
|
||||
AccountId::for_public_pda(&bridge_lock_id, &escrow_seed())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn escrow_seed() -> PdaSeed {
|
||||
PdaSeed::new(ESCROW_SEED_DOMAIN)
|
||||
}
|
||||
|
||||
/// Reads a bridgeable balance from account data; empty data is a zero balance.
|
||||
#[must_use]
|
||||
pub fn read_balance(data: &[u8]) -> u128 {
|
||||
if data.len() < 16 {
|
||||
return 0;
|
||||
}
|
||||
u128::from_le_bytes(data[..16].try_into().unwrap_or_else(|_| unreachable!()))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn balance_bytes(amount: u128) -> [u8; 16] {
|
||||
amount.to_le_bytes()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn balance_round_trips() {
|
||||
assert_eq!(read_balance(&balance_bytes(7)), 7);
|
||||
assert_eq!(read_balance(&[]), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escrow_is_stable() {
|
||||
let id: ProgramId = [4; 8];
|
||||
assert_eq!(escrow_account_id(id), escrow_account_id(id));
|
||||
}
|
||||
}
|
||||
121
lez/programs/bridge_lock/src/main.rs
Normal file
121
lez/programs/bridge_lock/src/main.rs
Normal file
@ -0,0 +1,121 @@
|
||||
use bridge_lock_core::{Instruction, balance_bytes, escrow_account_id, escrow_seed, read_balance};
|
||||
use cross_zone_outbox_core::Instruction as OutboxInstruction;
|
||||
use lee_core::{
|
||||
account::AccountWithMetadata,
|
||||
program::{AccountPostState, ChainedCall, Claim, ProgramInput, ProgramOutput, read_lee_inputs},
|
||||
};
|
||||
use wrapped_token_core::Instruction as WrappedInstruction;
|
||||
|
||||
fn main() {
|
||||
let (
|
||||
ProgramInput {
|
||||
self_program_id,
|
||||
caller_program_id,
|
||||
pre_states,
|
||||
instruction,
|
||||
},
|
||||
instruction_words,
|
||||
) = read_lee_inputs::<Instruction>();
|
||||
|
||||
assert!(
|
||||
caller_program_id.is_none(),
|
||||
"bridge_lock is only invoked as a top-level user transaction"
|
||||
);
|
||||
|
||||
let Instruction::Lock {
|
||||
amount,
|
||||
target_zone,
|
||||
target_program_id,
|
||||
target_accounts,
|
||||
payload,
|
||||
outbox_program_id,
|
||||
ordinal,
|
||||
} = instruction;
|
||||
|
||||
// Value conservation: the forwarded payload must mint exactly what is locked.
|
||||
let WrappedInstruction::Mint {
|
||||
amount: mint_amount,
|
||||
..
|
||||
} = decode_mint(&payload);
|
||||
assert_eq!(
|
||||
mint_amount, amount,
|
||||
"locked amount must equal the wrapped mint amount"
|
||||
);
|
||||
|
||||
// pre_states: [holder holding (authorized), escrow PDA, outbox PDA].
|
||||
let [holder, escrow, outbox] = <[AccountWithMetadata; 3]>::try_from(pre_states)
|
||||
.expect("Lock requires holder, escrow, and outbox accounts");
|
||||
|
||||
assert!(holder.is_authorized, "holder must authorize the lock");
|
||||
assert_eq!(
|
||||
holder.account.program_owner, self_program_id,
|
||||
"holder account must be a bridge_lock holding"
|
||||
);
|
||||
assert_eq!(
|
||||
escrow.account_id,
|
||||
escrow_account_id(self_program_id),
|
||||
"second account must be the escrow PDA"
|
||||
);
|
||||
|
||||
let holder_new = read_balance(&holder.account.data.clone().into_inner())
|
||||
.checked_sub(amount)
|
||||
.expect("insufficient balance to lock");
|
||||
let escrow_new = read_balance(&escrow.account.data.clone().into_inner())
|
||||
.checked_add(amount)
|
||||
.expect("escrow balance overflow");
|
||||
|
||||
let mut holder_account = holder.account.clone();
|
||||
holder_account.data = balance_bytes(holder_new)
|
||||
.to_vec()
|
||||
.try_into()
|
||||
.expect("balance fits in account data");
|
||||
let holder_post = AccountPostState::new(holder_account);
|
||||
|
||||
let mut escrow_account = escrow.account.clone();
|
||||
escrow_account.data = balance_bytes(escrow_new)
|
||||
.to_vec()
|
||||
.try_into()
|
||||
.expect("balance fits in account data");
|
||||
let escrow_post =
|
||||
AccountPostState::new_claimed_if_default(escrow_account, Claim::Pda(escrow_seed()));
|
||||
|
||||
let call = ChainedCall::new(
|
||||
outbox_program_id,
|
||||
vec![outbox.clone()],
|
||||
&OutboxInstruction::Emit {
|
||||
target_zone,
|
||||
target_program_id,
|
||||
target_accounts,
|
||||
payload,
|
||||
ordinal,
|
||||
},
|
||||
);
|
||||
|
||||
ProgramOutput::new(
|
||||
self_program_id,
|
||||
caller_program_id,
|
||||
instruction_words,
|
||||
vec![holder, escrow, outbox.clone()],
|
||||
vec![
|
||||
holder_post,
|
||||
escrow_post,
|
||||
AccountPostState::new(outbox.account),
|
||||
],
|
||||
)
|
||||
.with_chained_calls(vec![call])
|
||||
.write();
|
||||
}
|
||||
|
||||
/// Decodes the cross-zone payload (risc0 words, little-endian bytes) into the
|
||||
/// wrapped-token instruction it carries.
|
||||
fn decode_mint(payload: &[u8]) -> WrappedInstruction {
|
||||
assert!(
|
||||
payload.len().is_multiple_of(4),
|
||||
"payload must be u32-aligned instruction words"
|
||||
);
|
||||
let words: Vec<u32> = payload
|
||||
.chunks_exact(4)
|
||||
.map(|chunk| u32::from_le_bytes(chunk.try_into().unwrap_or_else(|_| unreachable!())))
|
||||
.collect();
|
||||
risc0_zkvm::serde::from_slice(&words).expect("payload decodes to a wrapped-token instruction")
|
||||
}
|
||||
12
lez/programs/cross_zone_inbox/Cargo.toml
Normal file
12
lez/programs/cross_zone_inbox/Cargo.toml
Normal file
@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "cross_zone_inbox_program"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
lee_core.workspace = true
|
||||
cross_zone_inbox_core.workspace = true
|
||||
14
lez/programs/cross_zone_inbox/core/Cargo.toml
Normal file
14
lez/programs/cross_zone_inbox/core/Cargo.toml
Normal file
@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "cross_zone_inbox_core"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
lee_core.workspace = true
|
||||
serde = { workspace = true, features = ["alloc"] }
|
||||
risc0-zkvm.workspace = true
|
||||
borsh.workspace = true
|
||||
203
lez/programs/cross_zone_inbox/core/src/lib.rs
Normal file
203
lez/programs/cross_zone_inbox/core/src/lib.rs
Normal file
@ -0,0 +1,203 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use borsh::{BorshDeserialize, BorshSerialize};
|
||||
use lee_core::{
|
||||
account::AccountId,
|
||||
program::{PdaSeed, ProgramId},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Source blocks per seen-set shard, so no single seen account grows without bound.
|
||||
pub const EPOCH_BLOCKS: u64 = 10_000;
|
||||
|
||||
const MESSAGE_KEY_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneMsgKey/00000/";
|
||||
const INBOX_CONFIG_SEED: [u8; 32] = *b"/LEZ/v0.3/CrossZoneInboxCfg/000/";
|
||||
const INBOX_SEEN_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneInboxSeen/00/";
|
||||
|
||||
/// Raw 32-byte zone (channel) id; the host maps it to the zone-sdk `ChannelId`.
|
||||
pub type ZoneId = [u8; 32];
|
||||
|
||||
/// Block-signing public key pinned per peer zone.
|
||||
pub type ExpectedPubkey = [u8; 32];
|
||||
|
||||
/// Content-addressed replay key for a delivered message.
|
||||
pub type MessageKey = [u8; 32];
|
||||
|
||||
/// A peer zone whose outbox a zone watches for inbound cross-zone messages.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct CrossZonePeer {
|
||||
/// The peer's Bedrock channel; its 32 bytes double as the peer's zone id.
|
||||
pub channel_id: ZoneId,
|
||||
/// Programs on the local zone a message from this peer is allowed to target.
|
||||
pub allowed_targets: Vec<ProgramId>,
|
||||
/// The peer's block-signing public key, pinned to reject blocks inscribed by
|
||||
/// anyone other than that zone's sequencer. `None` skips the check (the
|
||||
/// channel signer is still authenticated by the zone-sdk).
|
||||
#[serde(default)]
|
||||
pub expected_block_signing_pubkey: Option<[u8; 32]>,
|
||||
}
|
||||
|
||||
/// Cross-zone configuration shared by a zone's sequencer (watcher) and indexer
|
||||
/// (verifier): the peers it reads from Bedrock and, per peer, the local programs
|
||||
/// they may deliver to.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct CrossZoneConfig {
|
||||
pub peers: Vec<CrossZonePeer>,
|
||||
}
|
||||
|
||||
/// A finalized outbound message observed on a peer zone, addressed to a program
|
||||
/// on this zone. The watcher fills it from the peer's block; it is never
|
||||
/// self-reported by a user.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CrossZoneMessage {
|
||||
pub src_zone: ZoneId,
|
||||
pub src_block_id: u64,
|
||||
pub src_tx_index: u32,
|
||||
pub src_program_id: ProgramId,
|
||||
pub target_program_id: ProgramId,
|
||||
pub payload: Vec<u8>,
|
||||
/// Reserved for a future source-state proof; MUST be `None` in v1.
|
||||
pub l1_inclusion_witness: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Peer and per-peer target allowlists, plus this inbox's own zone id.
|
||||
#[derive(
|
||||
Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, BorshSerialize, BorshDeserialize,
|
||||
)]
|
||||
pub struct InboxConfig {
|
||||
pub self_zone: ZoneId,
|
||||
pub allowed_peers: BTreeMap<ZoneId, ExpectedPubkey>,
|
||||
pub allowed_targets: BTreeMap<ZoneId, Vec<ProgramId>>,
|
||||
}
|
||||
|
||||
impl InboxConfig {
|
||||
/// Borsh-encoded form stored in the inbox config account.
|
||||
#[must_use]
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
borsh::to_vec(self).expect("InboxConfig serializes")
|
||||
}
|
||||
|
||||
/// Decodes an [`InboxConfig`] from account data.
|
||||
pub fn from_bytes(bytes: &[u8]) -> borsh::io::Result<Self> {
|
||||
borsh::from_slice(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/// The replay keys seen for one `(src_zone, epoch)` shard.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
|
||||
pub struct SeenShard(pub BTreeSet<MessageKey>);
|
||||
|
||||
impl SeenShard {
|
||||
/// Decodes a shard from account data; empty data is an empty shard.
|
||||
pub fn from_bytes(bytes: &[u8]) -> borsh::io::Result<Self> {
|
||||
if bytes.is_empty() {
|
||||
return Ok(Self::default());
|
||||
}
|
||||
borsh::from_slice(bytes)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
borsh::to_vec(self).expect("SeenShard serializes")
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn contains(&self, key: &MessageKey) -> bool {
|
||||
self.0.contains(key)
|
||||
}
|
||||
|
||||
/// Inserts a key; returns true if it was newly inserted.
|
||||
pub fn insert(&mut self, key: MessageKey) -> bool {
|
||||
self.0.insert(key)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Instruction {
|
||||
/// Delivers a finalized peer message to its target program.
|
||||
Dispatch(CrossZoneMessage),
|
||||
}
|
||||
|
||||
/// Content-addressed replay key for a delivered message.
|
||||
///
|
||||
/// Hashes `(src_zone, src_block_id, src_tx_index)` under a domain separator.
|
||||
/// Watcher-independent and immune to proof malleability, since it keys on block
|
||||
/// id plus index rather than a tx hash.
|
||||
#[must_use]
|
||||
pub fn message_key(src_zone: &ZoneId, src_block_id: u64, src_tx_index: u32) -> MessageKey {
|
||||
use risc0_zkvm::sha::{Impl, Sha256 as _};
|
||||
|
||||
let mut bytes = [0_u8; 76];
|
||||
bytes[..32].copy_from_slice(&MESSAGE_KEY_DOMAIN);
|
||||
bytes[32..64].copy_from_slice(src_zone);
|
||||
bytes[64..72].copy_from_slice(&src_block_id.to_le_bytes());
|
||||
bytes[72..].copy_from_slice(&src_tx_index.to_le_bytes());
|
||||
|
||||
Impl::hash_bytes(&bytes)
|
||||
.as_bytes()
|
||||
.try_into()
|
||||
.unwrap_or_else(|_| unreachable!())
|
||||
}
|
||||
|
||||
/// The config account holding the allowlists.
|
||||
#[must_use]
|
||||
pub fn inbox_config_account_id(inbox_id: ProgramId) -> AccountId {
|
||||
AccountId::for_public_pda(&inbox_id, &PdaSeed::new(INBOX_CONFIG_SEED))
|
||||
}
|
||||
|
||||
/// The seen-set shard for the `(src_zone, epoch)` the message falls in.
|
||||
#[must_use]
|
||||
pub fn inbox_seen_shard_account_id(
|
||||
inbox_id: ProgramId,
|
||||
src_zone: &ZoneId,
|
||||
src_block_id: u64,
|
||||
) -> AccountId {
|
||||
AccountId::for_public_pda(&inbox_id, &inbox_seen_shard_seed(src_zone, src_block_id))
|
||||
}
|
||||
|
||||
/// Seed of the seen-shard PDA, exposed so the guest can claim the account.
|
||||
#[must_use]
|
||||
pub fn inbox_seen_shard_seed(src_zone: &ZoneId, src_block_id: u64) -> PdaSeed {
|
||||
use risc0_zkvm::sha::{Impl, Sha256 as _};
|
||||
|
||||
let src_epoch = src_block_id.wrapping_div(EPOCH_BLOCKS);
|
||||
let mut bytes = [0_u8; 72];
|
||||
bytes[..32].copy_from_slice(&INBOX_SEEN_SEED_DOMAIN);
|
||||
bytes[32..64].copy_from_slice(src_zone);
|
||||
bytes[64..].copy_from_slice(&src_epoch.to_le_bytes());
|
||||
|
||||
let seed: [u8; 32] = Impl::hash_bytes(&bytes)
|
||||
.as_bytes()
|
||||
.try_into()
|
||||
.unwrap_or_else(|_| unreachable!());
|
||||
PdaSeed::new(seed)
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn zone(b: u8) -> ZoneId {
|
||||
[b; 32]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_key_is_stable_and_content_addressed() {
|
||||
assert_eq!(message_key(&zone(1), 7, 3), message_key(&zone(1), 7, 3));
|
||||
assert_ne!(message_key(&zone(1), 7, 3), message_key(&zone(2), 7, 3));
|
||||
assert_ne!(message_key(&zone(1), 7, 3), message_key(&zone(1), 8, 3));
|
||||
assert_ne!(message_key(&zone(1), 7, 3), message_key(&zone(1), 7, 4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seen_shards_split_on_epoch_boundary() {
|
||||
let id: ProgramId = [9; 8];
|
||||
assert_eq!(
|
||||
inbox_seen_shard_account_id(id, &zone(1), 0),
|
||||
inbox_seen_shard_account_id(id, &zone(1), EPOCH_BLOCKS - 1),
|
||||
);
|
||||
assert_ne!(
|
||||
inbox_seen_shard_account_id(id, &zone(1), EPOCH_BLOCKS - 1),
|
||||
inbox_seen_shard_account_id(id, &zone(1), EPOCH_BLOCKS),
|
||||
);
|
||||
}
|
||||
}
|
||||
125
lez/programs/cross_zone_inbox/src/main.rs
Normal file
125
lez/programs/cross_zone_inbox/src/main.rs
Normal file
@ -0,0 +1,125 @@
|
||||
use cross_zone_inbox_core::{
|
||||
InboxConfig, Instruction, SeenShard, inbox_config_account_id, inbox_seen_shard_account_id,
|
||||
inbox_seen_shard_seed, message_key,
|
||||
};
|
||||
use lee_core::{
|
||||
account::AccountWithMetadata,
|
||||
program::{AccountPostState, ChainedCall, Claim, ProgramInput, ProgramOutput, read_lee_inputs},
|
||||
};
|
||||
|
||||
fn unchanged(pre: &AccountWithMetadata) -> AccountPostState {
|
||||
AccountPostState::new(pre.account.clone())
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let (
|
||||
ProgramInput {
|
||||
self_program_id,
|
||||
caller_program_id,
|
||||
pre_states,
|
||||
instruction,
|
||||
},
|
||||
instruction_words,
|
||||
) = read_lee_inputs::<Instruction>();
|
||||
|
||||
assert!(
|
||||
caller_program_id.is_none(),
|
||||
"Inbox is only invoked as a top-level sequencer-origin transaction"
|
||||
);
|
||||
|
||||
let Instruction::Dispatch(msg) = instruction;
|
||||
|
||||
assert!(
|
||||
msg.l1_inclusion_witness.is_none(),
|
||||
"l1_inclusion_witness must be None in v1"
|
||||
);
|
||||
|
||||
// pre_states layout: [config, seen_shard, then the target accounts].
|
||||
let mut accounts = pre_states.into_iter();
|
||||
let config = accounts.next().expect("config account required");
|
||||
let seen = accounts.next().expect("seen shard account required");
|
||||
let target_accounts: Vec<AccountWithMetadata> = accounts.collect();
|
||||
|
||||
assert_eq!(
|
||||
config.account_id,
|
||||
inbox_config_account_id(self_program_id),
|
||||
"First account must be the inbox config PDA"
|
||||
);
|
||||
assert_eq!(
|
||||
seen.account_id,
|
||||
inbox_seen_shard_account_id(self_program_id, &msg.src_zone, msg.src_block_id),
|
||||
"Second account must be the seen-shard PDA"
|
||||
);
|
||||
|
||||
let cfg = InboxConfig::from_bytes(&config.account.data.clone().into_inner())
|
||||
.expect("inbox config decodes");
|
||||
|
||||
assert!(
|
||||
msg.src_zone != cfg.self_zone,
|
||||
"Source zone must not be this zone"
|
||||
);
|
||||
let allowed_targets = cfg
|
||||
.allowed_targets
|
||||
.get(&msg.src_zone)
|
||||
.expect("Source zone is not an allowed peer");
|
||||
assert!(
|
||||
allowed_targets.contains(&msg.target_program_id),
|
||||
"Target program is not allowed for this peer"
|
||||
);
|
||||
|
||||
let key = message_key(&msg.src_zone, msg.src_block_id, msg.src_tx_index);
|
||||
let mut shard =
|
||||
SeenShard::from_bytes(&seen.account.data.clone().into_inner()).expect("seen shard decodes");
|
||||
let already_seen = shard.contains(&key);
|
||||
|
||||
// On replay this is a no-op: the seen shard is untouched and no call is made.
|
||||
let (seen_post, chained_calls) = if already_seen {
|
||||
(unchanged(&seen), vec![])
|
||||
} else {
|
||||
shard.insert(key);
|
||||
let mut seen_account = seen.account.clone();
|
||||
seen_account.data = shard
|
||||
.to_bytes()
|
||||
.try_into()
|
||||
.expect("seen shard fits in account data");
|
||||
let seen_post = AccountPostState::new_claimed_if_default(
|
||||
seen_account,
|
||||
Claim::Pda(inbox_seen_shard_seed(&msg.src_zone, msg.src_block_id)),
|
||||
);
|
||||
|
||||
// The payload carries the target instruction as risc0 words, little-endian.
|
||||
assert!(
|
||||
msg.payload.len().is_multiple_of(4),
|
||||
"payload must be u32-aligned instruction words"
|
||||
);
|
||||
let instruction_data = msg
|
||||
.payload
|
||||
.chunks_exact(4)
|
||||
.map(|c| u32::from_le_bytes(c.try_into().unwrap_or_else(|_| unreachable!())))
|
||||
.collect();
|
||||
|
||||
let call = ChainedCall {
|
||||
program_id: msg.target_program_id,
|
||||
pre_states: target_accounts.clone(),
|
||||
instruction_data,
|
||||
pda_seeds: vec![],
|
||||
};
|
||||
(seen_post, vec![call])
|
||||
};
|
||||
|
||||
let mut post_states = vec![unchanged(&config), seen_post];
|
||||
post_states.extend(target_accounts.iter().map(unchanged));
|
||||
|
||||
let mut output_pre_states = vec![config, seen];
|
||||
output_pre_states.extend(target_accounts);
|
||||
|
||||
ProgramOutput::new(
|
||||
self_program_id,
|
||||
caller_program_id,
|
||||
instruction_words,
|
||||
output_pre_states,
|
||||
post_states,
|
||||
)
|
||||
.with_chained_calls(chained_calls)
|
||||
.write();
|
||||
}
|
||||
12
lez/programs/cross_zone_outbox/Cargo.toml
Normal file
12
lez/programs/cross_zone_outbox/Cargo.toml
Normal file
@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "cross_zone_outbox_program"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
lee_core.workspace = true
|
||||
cross_zone_outbox_core.workspace = true
|
||||
14
lez/programs/cross_zone_outbox/core/Cargo.toml
Normal file
14
lez/programs/cross_zone_outbox/core/Cargo.toml
Normal file
@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "cross_zone_outbox_core"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
lee_core.workspace = true
|
||||
serde = { workspace = true, features = ["alloc"] }
|
||||
risc0-zkvm.workspace = true
|
||||
borsh.workspace = true
|
||||
93
lez/programs/cross_zone_outbox/core/src/lib.rs
Normal file
93
lez/programs/cross_zone_outbox/core/src/lib.rs
Normal file
@ -0,0 +1,93 @@
|
||||
use borsh::{BorshDeserialize, BorshSerialize};
|
||||
use lee_core::{
|
||||
account::AccountId,
|
||||
program::{PdaSeed, ProgramId},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const OUTBOX_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneOutbox/00000/";
|
||||
|
||||
/// Raw 32-byte zone (channel) id; the host maps it to the zone-sdk `ChannelId`.
|
||||
pub type ZoneId = [u8; 32];
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Instruction {
|
||||
/// Records an outbound cross-zone message as a write to a self-owned PDA.
|
||||
///
|
||||
/// Required accounts (1):
|
||||
/// - Outbox PDA account
|
||||
Emit {
|
||||
target_zone: ZoneId,
|
||||
target_program_id: ProgramId,
|
||||
/// Accounts the destination inbox must hand to the target program's
|
||||
/// chained call. The emitter specifies them; the watcher forwards them
|
||||
/// verbatim so the inbox stays target-agnostic.
|
||||
target_accounts: Vec<[u8; 32]>,
|
||||
payload: Vec<u8>,
|
||||
ordinal: u32,
|
||||
},
|
||||
}
|
||||
|
||||
/// The message as stored in an outbox PDA. The destination zone's watcher reads
|
||||
/// this from the inscribed block; the source coordinates are filled by the
|
||||
/// watcher, not stored here.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
|
||||
pub struct OutboxRecord {
|
||||
pub target_zone: ZoneId,
|
||||
pub target_program_id: ProgramId,
|
||||
pub target_accounts: Vec<[u8; 32]>,
|
||||
pub payload: Vec<u8>,
|
||||
}
|
||||
|
||||
impl OutboxRecord {
|
||||
/// Borsh-encoded form stored in the outbox PDA's account data.
|
||||
#[must_use]
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
borsh::to_vec(self).expect("OutboxRecord serializes")
|
||||
}
|
||||
|
||||
/// Decodes an [`OutboxRecord`] from account data.
|
||||
pub fn from_bytes(bytes: &[u8]) -> borsh::io::Result<Self> {
|
||||
borsh::from_slice(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/// PDA holding one emitted message, keyed by destination zone and a per-zone
|
||||
/// ordinal.
|
||||
#[must_use]
|
||||
pub fn outbox_pda(outbox_id: ProgramId, target_zone: &ZoneId, ordinal: u32) -> AccountId {
|
||||
AccountId::for_public_pda(&outbox_id, &outbox_pda_seed(target_zone, ordinal))
|
||||
}
|
||||
|
||||
/// Seed of an outbox message PDA, exposed so the guest can claim the account.
|
||||
#[must_use]
|
||||
pub fn outbox_pda_seed(target_zone: &ZoneId, ordinal: u32) -> PdaSeed {
|
||||
use risc0_zkvm::sha::{Impl, Sha256 as _};
|
||||
|
||||
let mut bytes = [0_u8; 68];
|
||||
bytes[..32].copy_from_slice(&OUTBOX_SEED_DOMAIN);
|
||||
bytes[32..64].copy_from_slice(target_zone);
|
||||
bytes[64..].copy_from_slice(&ordinal.to_le_bytes());
|
||||
|
||||
let seed: [u8; 32] = Impl::hash_bytes(&bytes)
|
||||
.as_bytes()
|
||||
.try_into()
|
||||
.unwrap_or_else(|_| unreachable!());
|
||||
PdaSeed::new(seed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn outbox_pda_is_unique_per_zone_and_ordinal() {
|
||||
let id: ProgramId = [3; 8];
|
||||
let zone_a = [1; 32];
|
||||
let zone_b = [2; 32];
|
||||
|
||||
assert_eq!(outbox_pda(id, &zone_a, 0), outbox_pda(id, &zone_a, 0));
|
||||
assert_ne!(outbox_pda(id, &zone_a, 0), outbox_pda(id, &zone_a, 1));
|
||||
assert_ne!(outbox_pda(id, &zone_a, 0), outbox_pda(id, &zone_b, 0));
|
||||
}
|
||||
}
|
||||
72
lez/programs/cross_zone_outbox/src/main.rs
Normal file
72
lez/programs/cross_zone_outbox/src/main.rs
Normal file
@ -0,0 +1,72 @@
|
||||
use cross_zone_outbox_core::{Instruction, OutboxRecord, outbox_pda, outbox_pda_seed};
|
||||
use lee_core::{
|
||||
account::AccountWithMetadata,
|
||||
program::{AccountPostState, Claim, ProgramInput, ProgramOutput, read_lee_inputs},
|
||||
};
|
||||
|
||||
fn main() {
|
||||
let (
|
||||
ProgramInput {
|
||||
self_program_id,
|
||||
caller_program_id,
|
||||
pre_states,
|
||||
instruction,
|
||||
},
|
||||
instruction_words,
|
||||
) = read_lee_inputs::<Instruction>();
|
||||
|
||||
assert!(
|
||||
caller_program_id.is_some(),
|
||||
"Outbox is only callable through a chain call from a user program"
|
||||
);
|
||||
|
||||
let (target_zone, target_program_id, target_accounts, payload, ordinal) = match instruction {
|
||||
Instruction::Emit {
|
||||
target_zone,
|
||||
target_program_id,
|
||||
target_accounts,
|
||||
payload,
|
||||
ordinal,
|
||||
} => (
|
||||
target_zone,
|
||||
target_program_id,
|
||||
target_accounts,
|
||||
payload,
|
||||
ordinal,
|
||||
),
|
||||
};
|
||||
|
||||
let [outbox] =
|
||||
<[AccountWithMetadata; 1]>::try_from(pre_states).expect("Emit requires exactly 1 account");
|
||||
|
||||
assert_eq!(
|
||||
outbox.account_id,
|
||||
outbox_pda(self_program_id, &target_zone, ordinal),
|
||||
"Account must be the outbox PDA for (target_zone, ordinal)"
|
||||
);
|
||||
|
||||
let mut post_account = outbox.account.clone();
|
||||
post_account.data = OutboxRecord {
|
||||
target_zone,
|
||||
target_program_id,
|
||||
target_accounts,
|
||||
payload,
|
||||
}
|
||||
.to_bytes()
|
||||
.try_into()
|
||||
.expect("OutboxRecord fits in account data");
|
||||
|
||||
let post = AccountPostState::new_claimed_if_default(
|
||||
post_account,
|
||||
Claim::Pda(outbox_pda_seed(&target_zone, ordinal)),
|
||||
);
|
||||
|
||||
ProgramOutput::new(
|
||||
self_program_id,
|
||||
caller_program_id,
|
||||
instruction_words,
|
||||
vec![outbox],
|
||||
vec![post],
|
||||
)
|
||||
.write();
|
||||
}
|
||||
12
lez/programs/ping_core/Cargo.toml
Normal file
12
lez/programs/ping_core/Cargo.toml
Normal file
@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "ping_core"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
lee_core.workspace = true
|
||||
serde = { workspace = true, features = ["alloc"] }
|
||||
38
lez/programs/ping_core/src/lib.rs
Normal file
38
lez/programs/ping_core/src/lib.rs
Normal file
@ -0,0 +1,38 @@
|
||||
use lee_core::{
|
||||
account::AccountId,
|
||||
program::{PdaSeed, ProgramId},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const PING_RECORD_SEED: [u8; 32] = *b"/LEZ/v0.3/PingRecord/0000000000/";
|
||||
|
||||
/// Instruction delivered to `ping_receiver` by the inbox: record the payload.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ReceiverInstruction {
|
||||
Record { payload: Vec<u8> },
|
||||
}
|
||||
|
||||
/// Instruction to `ping_sender`: forwarded verbatim into `cross_zone_outbox::Instruction::Emit`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum SenderInstruction {
|
||||
Send {
|
||||
outbox_program_id: ProgramId,
|
||||
target_zone: [u8; 32],
|
||||
target_program_id: ProgramId,
|
||||
target_accounts: Vec<[u8; 32]>,
|
||||
payload: Vec<u8>,
|
||||
ordinal: u32,
|
||||
},
|
||||
}
|
||||
|
||||
/// The account a `ping_receiver` records the latest delivered payload into.
|
||||
#[must_use]
|
||||
pub fn ping_record_pda(receiver_id: ProgramId) -> AccountId {
|
||||
AccountId::for_public_pda(&receiver_id, &ping_record_seed())
|
||||
}
|
||||
|
||||
/// Seed of the record PDA, exposed so the guest can claim the account.
|
||||
#[must_use]
|
||||
pub const fn ping_record_seed() -> PdaSeed {
|
||||
PdaSeed::new(PING_RECORD_SEED)
|
||||
}
|
||||
12
lez/programs/ping_receiver/Cargo.toml
Normal file
12
lez/programs/ping_receiver/Cargo.toml
Normal file
@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "ping_receiver_program"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
lee_core.workspace = true
|
||||
ping_core.workspace = true
|
||||
48
lez/programs/ping_receiver/src/main.rs
Normal file
48
lez/programs/ping_receiver/src/main.rs
Normal file
@ -0,0 +1,48 @@
|
||||
use lee_core::{
|
||||
account::AccountWithMetadata,
|
||||
program::{AccountPostState, Claim, ProgramInput, ProgramOutput, read_lee_inputs},
|
||||
};
|
||||
use ping_core::{ReceiverInstruction, ping_record_pda, ping_record_seed};
|
||||
|
||||
fn main() {
|
||||
let (
|
||||
ProgramInput {
|
||||
self_program_id,
|
||||
caller_program_id,
|
||||
pre_states,
|
||||
instruction,
|
||||
},
|
||||
instruction_words,
|
||||
) = read_lee_inputs::<ReceiverInstruction>();
|
||||
|
||||
assert!(
|
||||
caller_program_id.is_some(),
|
||||
"ping_receiver is only callable through a chained call"
|
||||
);
|
||||
|
||||
let payload = match instruction {
|
||||
ReceiverInstruction::Record { payload } => payload,
|
||||
};
|
||||
|
||||
let [record] = <[AccountWithMetadata; 1]>::try_from(pre_states)
|
||||
.expect("Record requires exactly 1 account");
|
||||
assert_eq!(
|
||||
record.account_id,
|
||||
ping_record_pda(self_program_id),
|
||||
"Account must be the ping record PDA"
|
||||
);
|
||||
|
||||
let mut post_account = record.account.clone();
|
||||
post_account.data = payload.try_into().expect("payload fits in account data");
|
||||
let post =
|
||||
AccountPostState::new_claimed_if_default(post_account, Claim::Pda(ping_record_seed()));
|
||||
|
||||
ProgramOutput::new(
|
||||
self_program_id,
|
||||
caller_program_id,
|
||||
instruction_words,
|
||||
vec![record],
|
||||
vec![post],
|
||||
)
|
||||
.write();
|
||||
}
|
||||
13
lez/programs/ping_sender/Cargo.toml
Normal file
13
lez/programs/ping_sender/Cargo.toml
Normal file
@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "ping_sender_program"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
lee_core.workspace = true
|
||||
ping_core.workspace = true
|
||||
cross_zone_outbox_core.workspace = true
|
||||
59
lez/programs/ping_sender/src/main.rs
Normal file
59
lez/programs/ping_sender/src/main.rs
Normal file
@ -0,0 +1,59 @@
|
||||
use cross_zone_outbox_core::Instruction as OutboxInstruction;
|
||||
use lee_core::{
|
||||
account::AccountWithMetadata,
|
||||
program::{AccountPostState, ChainedCall, ProgramInput, ProgramOutput, read_lee_inputs},
|
||||
};
|
||||
use ping_core::SenderInstruction;
|
||||
|
||||
fn main() {
|
||||
let (
|
||||
ProgramInput {
|
||||
self_program_id,
|
||||
caller_program_id,
|
||||
pre_states,
|
||||
instruction,
|
||||
},
|
||||
instruction_words,
|
||||
) = read_lee_inputs::<SenderInstruction>();
|
||||
|
||||
assert!(
|
||||
caller_program_id.is_none(),
|
||||
"ping_sender is only invoked as a top-level user transaction"
|
||||
);
|
||||
|
||||
let SenderInstruction::Send {
|
||||
outbox_program_id,
|
||||
target_zone,
|
||||
target_program_id,
|
||||
target_accounts,
|
||||
payload,
|
||||
ordinal,
|
||||
} = instruction;
|
||||
|
||||
// The single account is the outbox PDA the chained call writes into; the
|
||||
// outbox claims it, so ping_sender forwards it unchanged.
|
||||
let [outbox] =
|
||||
<[AccountWithMetadata; 1]>::try_from(pre_states).expect("Send requires exactly 1 account");
|
||||
|
||||
let call = ChainedCall::new(
|
||||
outbox_program_id,
|
||||
vec![outbox.clone()],
|
||||
&OutboxInstruction::Emit {
|
||||
target_zone,
|
||||
target_program_id,
|
||||
target_accounts,
|
||||
payload,
|
||||
ordinal,
|
||||
},
|
||||
);
|
||||
|
||||
ProgramOutput::new(
|
||||
self_program_id,
|
||||
caller_program_id,
|
||||
instruction_words,
|
||||
vec![outbox.clone()],
|
||||
vec![AccountPostState::new(outbox.account)],
|
||||
)
|
||||
.with_chained_calls(vec![call])
|
||||
.write();
|
||||
}
|
||||
@ -10,9 +10,12 @@ mod inner {
|
||||
|
||||
use guests::{
|
||||
AMM_ELF, AMM_ID, ASSOCIATED_TOKEN_ACCOUNT_ELF, ASSOCIATED_TOKEN_ACCOUNT_ID,
|
||||
AUTHENTICATED_TRANSFER_ELF, AUTHENTICATED_TRANSFER_ID, BRIDGE_ELF, BRIDGE_ID, CLOCK_ELF,
|
||||
CLOCK_ID, FAUCET_ELF, FAUCET_ID, PINATA_ELF, PINATA_ID, PINATA_TOKEN_ELF, PINATA_TOKEN_ID,
|
||||
TOKEN_ELF, TOKEN_ID, VAULT_ELF, VAULT_ID,
|
||||
AUTHENTICATED_TRANSFER_ELF, AUTHENTICATED_TRANSFER_ID, BRIDGE_ELF, BRIDGE_ID,
|
||||
BRIDGE_LOCK_ELF, BRIDGE_LOCK_ID, CLOCK_ELF, CLOCK_ID, CROSS_ZONE_INBOX_ELF,
|
||||
CROSS_ZONE_INBOX_ID, CROSS_ZONE_OUTBOX_ELF, CROSS_ZONE_OUTBOX_ID, FAUCET_ELF, FAUCET_ID,
|
||||
PINATA_ELF, PINATA_ID, PINATA_TOKEN_ELF, PINATA_TOKEN_ID, PING_RECEIVER_ELF,
|
||||
PING_RECEIVER_ID, PING_SENDER_ELF, PING_SENDER_ID, TOKEN_ELF, TOKEN_ID, VAULT_ELF,
|
||||
VAULT_ID, WRAPPED_TOKEN_ELF, WRAPPED_TOKEN_ID,
|
||||
};
|
||||
use lee::program::Program;
|
||||
|
||||
@ -87,6 +90,42 @@ mod inner {
|
||||
Program::new_unchecked(BRIDGE_ID, Cow::Borrowed(BRIDGE_ELF))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
#[inline]
|
||||
pub const fn cross_zone_outbox() -> Program {
|
||||
Program::new_unchecked(CROSS_ZONE_OUTBOX_ID, Cow::Borrowed(CROSS_ZONE_OUTBOX_ELF))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
#[inline]
|
||||
pub const fn cross_zone_inbox() -> Program {
|
||||
Program::new_unchecked(CROSS_ZONE_INBOX_ID, Cow::Borrowed(CROSS_ZONE_INBOX_ELF))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
#[inline]
|
||||
pub const fn ping_sender() -> Program {
|
||||
Program::new_unchecked(PING_SENDER_ID, Cow::Borrowed(PING_SENDER_ELF))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
#[inline]
|
||||
pub const fn ping_receiver() -> Program {
|
||||
Program::new_unchecked(PING_RECEIVER_ID, Cow::Borrowed(PING_RECEIVER_ELF))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
#[inline]
|
||||
pub const fn bridge_lock() -> Program {
|
||||
Program::new_unchecked(BRIDGE_LOCK_ID, Cow::Borrowed(BRIDGE_LOCK_ELF))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
#[inline]
|
||||
pub const fn wrapped_token() -> Program {
|
||||
Program::new_unchecked(WRAPPED_TOKEN_ID, Cow::Borrowed(WRAPPED_TOKEN_ELF))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@ -127,6 +166,12 @@ mod inner {
|
||||
(PINATA_TOKEN_ELF, PINATA_TOKEN_ID),
|
||||
(TOKEN_ELF, TOKEN_ID),
|
||||
(VAULT_ELF, VAULT_ID),
|
||||
(CROSS_ZONE_OUTBOX_ELF, CROSS_ZONE_OUTBOX_ID),
|
||||
(CROSS_ZONE_INBOX_ELF, CROSS_ZONE_INBOX_ID),
|
||||
(PING_SENDER_ELF, PING_SENDER_ID),
|
||||
(PING_RECEIVER_ELF, PING_RECEIVER_ID),
|
||||
(BRIDGE_LOCK_ELF, BRIDGE_LOCK_ID),
|
||||
(WRAPPED_TOKEN_ELF, WRAPPED_TOKEN_ID),
|
||||
];
|
||||
for (elf, expected_id) in cases {
|
||||
let program = Program::new((*elf).into()).unwrap();
|
||||
|
||||
12
lez/programs/wrapped_token/Cargo.toml
Normal file
12
lez/programs/wrapped_token/Cargo.toml
Normal file
@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "wrapped_token_program"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
lee_core.workspace = true
|
||||
wrapped_token_core.workspace = true
|
||||
13
lez/programs/wrapped_token/core/Cargo.toml
Normal file
13
lez/programs/wrapped_token/core/Cargo.toml
Normal file
@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "wrapped_token_core"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
lee_core.workspace = true
|
||||
serde = { workspace = true, features = ["alloc"] }
|
||||
risc0-zkvm.workspace = true
|
||||
121
lez/programs/wrapped_token/core/src/lib.rs
Normal file
121
lez/programs/wrapped_token/core/src/lib.rs
Normal file
@ -0,0 +1,121 @@
|
||||
//! Core types for the wrapped-token program, the destination side of the
|
||||
//! cross-zone bridge. Only the cross-zone inbox may mint; the guest enforces
|
||||
//! this by reading the authorized minter from a genesis-seeded config account.
|
||||
|
||||
use lee_core::{
|
||||
account::AccountId,
|
||||
program::{PdaSeed, ProgramId},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const CONFIG_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/WrappedTokenConfig/00/";
|
||||
const HOLDING_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/WrappedTokenHold/00000";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Instruction {
|
||||
/// Credit `amount` wrapped tokens to `recipient`'s holding. Delivered only by
|
||||
/// the cross-zone inbox.
|
||||
///
|
||||
/// Required accounts (2): the wrapped-token config PDA, then the recipient's
|
||||
/// holding PDA.
|
||||
Mint { recipient: [u8; 32], amount: u128 },
|
||||
}
|
||||
|
||||
/// PDA holding the authorized minter program id (the cross-zone inbox), seeded at
|
||||
/// genesis so the guest can pin its caller without importing the inbox image id.
|
||||
#[must_use]
|
||||
pub fn config_account_id(wrapped_token_id: ProgramId) -> AccountId {
|
||||
AccountId::for_public_pda(&wrapped_token_id, &config_seed())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn config_seed() -> PdaSeed {
|
||||
PdaSeed::new(CONFIG_SEED_DOMAIN)
|
||||
}
|
||||
|
||||
/// PDA holding one recipient's wrapped-token balance.
|
||||
#[must_use]
|
||||
pub fn holding_account_id(wrapped_token_id: ProgramId, recipient: &[u8; 32]) -> AccountId {
|
||||
AccountId::for_public_pda(&wrapped_token_id, &holding_seed(recipient))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn holding_seed(recipient: &[u8; 32]) -> PdaSeed {
|
||||
use risc0_zkvm::sha::{Impl, Sha256 as _};
|
||||
|
||||
let mut bytes = [0_u8; 64];
|
||||
bytes[..32].copy_from_slice(&HOLDING_SEED_DOMAIN);
|
||||
bytes[32..].copy_from_slice(recipient);
|
||||
let seed: [u8; 32] = Impl::hash_bytes(&bytes)
|
||||
.as_bytes()
|
||||
.try_into()
|
||||
.unwrap_or_else(|_| unreachable!());
|
||||
PdaSeed::new(seed)
|
||||
}
|
||||
|
||||
/// Encodes the authorized minter program id for the config account's data.
|
||||
#[must_use]
|
||||
pub fn minter_bytes(minter: ProgramId) -> [u8; 32] {
|
||||
let mut bytes = [0_u8; 32];
|
||||
for (word, chunk) in minter.iter().zip(bytes.chunks_exact_mut(4)) {
|
||||
chunk.copy_from_slice(&word.to_le_bytes());
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
/// Decodes the authorized minter program id from the config account's data.
|
||||
#[must_use]
|
||||
pub fn read_minter(data: &[u8]) -> Option<ProgramId> {
|
||||
if data.len() < 32 {
|
||||
return None;
|
||||
}
|
||||
let mut minter = [0_u32; 8];
|
||||
for (word, chunk) in minter.iter_mut().zip(data[..32].chunks_exact(4)) {
|
||||
*word = u32::from_le_bytes(chunk.try_into().unwrap_or_else(|_| unreachable!()));
|
||||
}
|
||||
Some(minter)
|
||||
}
|
||||
|
||||
/// Reads a wrapped-token balance from account data; empty data is a zero balance.
|
||||
#[must_use]
|
||||
pub fn read_balance(data: &[u8]) -> u128 {
|
||||
if data.len() < 16 {
|
||||
return 0;
|
||||
}
|
||||
u128::from_le_bytes(data[..16].try_into().unwrap_or_else(|_| unreachable!()))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn balance_bytes(amount: u128) -> [u8; 16] {
|
||||
amount.to_le_bytes()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn minter_round_trips() {
|
||||
let minter: ProgramId = [1, 2, 3, 4, 5, 6, 7, 8];
|
||||
assert_eq!(read_minter(&minter_bytes(minter)), Some(minter));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn balance_round_trips() {
|
||||
assert_eq!(read_balance(&balance_bytes(42)), 42);
|
||||
assert_eq!(read_balance(&[]), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn holding_is_unique_per_recipient() {
|
||||
let id: ProgramId = [9; 8];
|
||||
assert_ne!(
|
||||
holding_account_id(id, &[1; 32]),
|
||||
holding_account_id(id, &[2; 32])
|
||||
);
|
||||
assert_eq!(
|
||||
holding_account_id(id, &[1; 32]),
|
||||
holding_account_id(id, &[1; 32])
|
||||
);
|
||||
}
|
||||
}
|
||||
70
lez/programs/wrapped_token/src/main.rs
Normal file
70
lez/programs/wrapped_token/src/main.rs
Normal file
@ -0,0 +1,70 @@
|
||||
use lee_core::{
|
||||
account::AccountWithMetadata,
|
||||
program::{AccountPostState, Claim, ProgramInput, ProgramOutput, read_lee_inputs},
|
||||
};
|
||||
use wrapped_token_core::{
|
||||
Instruction, balance_bytes, config_account_id, holding_account_id, holding_seed, read_balance,
|
||||
read_minter,
|
||||
};
|
||||
|
||||
fn main() {
|
||||
let (
|
||||
ProgramInput {
|
||||
self_program_id,
|
||||
caller_program_id,
|
||||
pre_states,
|
||||
instruction,
|
||||
},
|
||||
instruction_words,
|
||||
) = read_lee_inputs::<Instruction>();
|
||||
|
||||
let Instruction::Mint { recipient, amount } = instruction;
|
||||
|
||||
// pre_states: [config PDA, recipient holding PDA].
|
||||
let [config, holding] = <[AccountWithMetadata; 2]>::try_from(pre_states)
|
||||
.expect("Mint requires the config and recipient holding accounts");
|
||||
|
||||
// The config PDA is genesis-seeded with the authorized minter (the cross-zone
|
||||
// inbox). Pin the caller to it, since the guest cannot import the inbox id.
|
||||
assert_eq!(
|
||||
config.account_id,
|
||||
config_account_id(self_program_id),
|
||||
"first account must be the wrapped-token config PDA"
|
||||
);
|
||||
let minter = read_minter(&config.account.data.clone().into_inner())
|
||||
.expect("config account holds an authorized minter id");
|
||||
assert_eq!(
|
||||
caller_program_id,
|
||||
Some(minter),
|
||||
"Mint is only callable by the authorized minter (the cross-zone inbox)"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
holding.account_id,
|
||||
holding_account_id(self_program_id, &recipient),
|
||||
"second account must be the recipient holding PDA"
|
||||
);
|
||||
|
||||
let new_balance = read_balance(&holding.account.data.clone().into_inner())
|
||||
.checked_add(amount)
|
||||
.expect("wrapped-token balance overflow");
|
||||
let mut holding_account = holding.account.clone();
|
||||
holding_account.data = balance_bytes(new_balance)
|
||||
.to_vec()
|
||||
.try_into()
|
||||
.expect("balance fits in account data");
|
||||
let holding_post = AccountPostState::new_claimed_if_default(
|
||||
holding_account,
|
||||
Claim::Pda(holding_seed(&recipient)),
|
||||
);
|
||||
let config_post = AccountPostState::new(config.account.clone());
|
||||
|
||||
ProgramOutput::new(
|
||||
self_program_id,
|
||||
caller_program_id,
|
||||
instruction_words,
|
||||
vec![config, holding],
|
||||
vec![config_post, holding_post],
|
||||
)
|
||||
.write();
|
||||
}
|
||||
@ -20,6 +20,8 @@ bridge_core.workspace = true
|
||||
vault_core.workspace = true
|
||||
programs.workspace = true
|
||||
system_accounts.workspace = true
|
||||
cross_zone.workspace = true
|
||||
cross_zone_inbox_core.workspace = true
|
||||
|
||||
logos-blockchain-key-management-system-service.workspace = true
|
||||
logos-blockchain-core.workspace = true
|
||||
@ -39,6 +41,7 @@ url.workspace = true
|
||||
num-bigint.workspace = true
|
||||
risc0-zkvm.workspace = true
|
||||
futures.workspace = true
|
||||
itertools.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
@ -10,11 +10,11 @@ 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>,
|
||||
@ -150,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);
|
||||
|
||||
@ -8,6 +8,7 @@ use std::{
|
||||
use anyhow::Result;
|
||||
use bytesize::ByteSize;
|
||||
use common::config::BasicAuth;
|
||||
pub use cross_zone_inbox_core::{CrossZoneConfig, CrossZonePeer};
|
||||
use humantime_serde;
|
||||
use lee::AccountId;
|
||||
use logos_blockchain_core::mantle::ops::channel::ChannelId;
|
||||
@ -25,6 +26,11 @@ pub enum GenesisAction {
|
||||
SupplyBridgeAccount {
|
||||
balance: u128,
|
||||
},
|
||||
/// Seeds a bridge-lock holder's initial bridgeable balance into genesis state.
|
||||
SupplyBridgeLockHolding {
|
||||
holder: AccountId,
|
||||
amount: u128,
|
||||
},
|
||||
}
|
||||
|
||||
// TODO: Provide default values
|
||||
@ -53,6 +59,9 @@ pub struct SequencerConfig {
|
||||
/// Genesis configuration.
|
||||
#[serde(default)]
|
||||
pub genesis: Vec<GenesisAction>,
|
||||
/// Cross-zone messaging configuration. `None` disables the watcher.
|
||||
#[serde(default)]
|
||||
pub cross_zone: Option<CrossZoneConfig>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
|
||||
185
lez/sequencer/core/src/cross_zone_watcher.rs
Normal file
185
lez/sequencer/core/src/cross_zone_watcher.rs
Normal file
@ -0,0 +1,185 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use common::{block::Block, transaction::LeeTransaction};
|
||||
use cross_zone::{build_dispatch_from_emission, extract_emission};
|
||||
use futures::StreamExt as _;
|
||||
use lee::PublicKey;
|
||||
use lee_core::program::ProgramId;
|
||||
use log::{debug, error, info, warn};
|
||||
use logos_blockchain_core::mantle::ops::channel::ChannelId;
|
||||
use logos_blockchain_zone_sdk::{
|
||||
CommonHttpClient, ZoneMessage, adapter::NodeHttpClient, indexer::ZoneIndexer,
|
||||
};
|
||||
use mempool::MemPoolHandle;
|
||||
|
||||
use crate::{
|
||||
TransactionOrigin,
|
||||
config::{BedrockConfig, CrossZoneConfig},
|
||||
};
|
||||
|
||||
/// Spawns one watcher task per configured peer.
|
||||
///
|
||||
/// Each task reads the peer's finalized blocks from Bedrock, recognizes outbound
|
||||
/// messages addressed to this zone, and injects the matching inbox dispatch as a
|
||||
/// sequencer-origin transaction into the local mempool.
|
||||
pub fn spawn_watchers(
|
||||
bedrock_config: &BedrockConfig,
|
||||
cross_zone: &CrossZoneConfig,
|
||||
poll_interval: Duration,
|
||||
mempool_handle: &MemPoolHandle<(TransactionOrigin, LeeTransaction)>,
|
||||
) {
|
||||
let self_zone: [u8; 32] = *bedrock_config.channel_id.as_ref();
|
||||
|
||||
for peer in cross_zone.peers.clone() {
|
||||
let node = NodeHttpClient::new(
|
||||
CommonHttpClient::new(bedrock_config.auth.clone().map(Into::into)),
|
||||
bedrock_config.node_url.clone(),
|
||||
);
|
||||
let expected_pubkey = peer.expected_block_signing_pubkey.map(|bytes| {
|
||||
PublicKey::try_new(bytes).expect("configured peer block-signing pubkey is a valid key")
|
||||
});
|
||||
tokio::spawn(watch_peer(
|
||||
ZoneIndexer::new(ChannelId::from(peer.channel_id), node),
|
||||
peer.channel_id,
|
||||
peer.allowed_targets,
|
||||
expected_pubkey,
|
||||
self_zone,
|
||||
poll_interval,
|
||||
mempool_handle.clone(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::infinite_loop,
|
||||
reason = "the peer watcher runs for the lifetime of the sequencer process"
|
||||
)]
|
||||
async fn watch_peer(
|
||||
zone_indexer: ZoneIndexer<NodeHttpClient>,
|
||||
peer_zone: [u8; 32],
|
||||
allowed_targets: Vec<ProgramId>,
|
||||
expected_pubkey: Option<PublicKey>,
|
||||
self_zone: [u8; 32],
|
||||
poll_interval: Duration,
|
||||
mempool_handle: MemPoolHandle<(TransactionOrigin, LeeTransaction)>,
|
||||
) {
|
||||
info!(
|
||||
"Cross-zone watcher started for peer {}",
|
||||
hex::encode(peer_zone)
|
||||
);
|
||||
|
||||
let mut cursor = None;
|
||||
loop {
|
||||
let stream = match zone_indexer.next_messages(cursor).await {
|
||||
Ok(stream) => stream,
|
||||
Err(err) => {
|
||||
error!(
|
||||
"Watcher next_messages failed for peer {}: {err}",
|
||||
hex::encode(peer_zone)
|
||||
);
|
||||
tokio::time::sleep(poll_interval).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let mut stream = std::pin::pin!(stream);
|
||||
|
||||
while let Some((msg, slot)) = stream.next().await {
|
||||
let zone_block = match msg {
|
||||
ZoneMessage::Block(block) => block,
|
||||
ZoneMessage::Deposit(_) | ZoneMessage::Withdraw(_) => continue,
|
||||
};
|
||||
match borsh::from_slice::<Block>(&zone_block.data) {
|
||||
Ok(block) => {
|
||||
debug!(
|
||||
"Watcher observed finalized peer {} block {}",
|
||||
hex::encode(peer_zone),
|
||||
block.header.block_id
|
||||
);
|
||||
// Reject blocks not signed by the pinned peer key (equivocation):
|
||||
// the channel signer is authenticated by the zone-sdk, but that
|
||||
// does not prove the peer's honest sequencer produced the block.
|
||||
if expected_pubkey
|
||||
.as_ref()
|
||||
.is_some_and(|pk| !block.is_signed_by(pk))
|
||||
{
|
||||
warn!(
|
||||
"Watcher dropping peer {} block {}: block-signing key does not match the pinned key",
|
||||
hex::encode(peer_zone),
|
||||
block.header.block_id
|
||||
);
|
||||
} else {
|
||||
deliver_block(
|
||||
&block,
|
||||
peer_zone,
|
||||
self_zone,
|
||||
&allowed_targets,
|
||||
&mempool_handle,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Err(err) => error!("Watcher failed to deserialize peer block: {err}"),
|
||||
}
|
||||
cursor = Some(slot);
|
||||
}
|
||||
|
||||
// Stream ended (caught up to the peer's last finalized block); poll again.
|
||||
tokio::time::sleep(poll_interval).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Scans one peer block for outbound messages and injects a dispatch per match.
|
||||
async fn deliver_block(
|
||||
block: &Block,
|
||||
peer_zone: [u8; 32],
|
||||
self_zone: [u8; 32],
|
||||
allowed_targets: &[ProgramId],
|
||||
mempool_handle: &MemPoolHandle<(TransactionOrigin, LeeTransaction)>,
|
||||
) {
|
||||
for (index, tx) in block.body.transactions.iter().enumerate() {
|
||||
let LeeTransaction::Public(public_tx) = tx else {
|
||||
continue;
|
||||
};
|
||||
let message = public_tx.message();
|
||||
let Some(emission) = extract_emission(message.program_id, &message.instruction_data) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if emission.target_zone != self_zone {
|
||||
continue;
|
||||
}
|
||||
if !allowed_targets.contains(&emission.target_program_id) {
|
||||
warn!(
|
||||
"Watcher dropping message to disallowed target from peer {}",
|
||||
hex::encode(peer_zone)
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let dispatch = build_dispatch_from_emission(
|
||||
peer_zone,
|
||||
block.header.block_id,
|
||||
u32::try_from(index).unwrap_or(u32::MAX),
|
||||
message.program_id,
|
||||
emission.target_program_id,
|
||||
&emission.target_accounts,
|
||||
emission.payload,
|
||||
);
|
||||
|
||||
match mempool_handle
|
||||
.push((
|
||||
TransactionOrigin::Sequencer,
|
||||
LeeTransaction::Public(dispatch),
|
||||
))
|
||||
.await
|
||||
{
|
||||
Ok(()) => info!(
|
||||
"Watcher injected cross-zone dispatch from peer {} block {} tx {}",
|
||||
hex::encode(peer_zone),
|
||||
block.header.block_id,
|
||||
index
|
||||
),
|
||||
Err(err) => error!("Watcher failed to enqueue inbox dispatch: {err}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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)?;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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::{
|
||||
|
||||
@ -5,6 +5,7 @@ use common::{
|
||||
transaction::{LeeTransaction, clock_invocation},
|
||||
};
|
||||
use lee::{GENESIS_BLOCK_ID, V03State};
|
||||
use log::warn;
|
||||
use rocksdb::{
|
||||
BoundColumnFamily, ColumnFamilyDescriptor, DBWithThreadMode, MultiThreaded, Options,
|
||||
};
|
||||
@ -20,10 +21,12 @@ pub mod write_non_atomic;
|
||||
/// Key base for storing metainformation about id of last observed L1 lib header in db.
|
||||
pub const DB_META_LAST_OBSERVED_L1_LIB_HEADER_ID_IN_DB_KEY: &str =
|
||||
"last_observed_l1_lib_header_in_db";
|
||||
/// Key base for storing metainformation about the last breakpoint.
|
||||
pub const DB_META_LAST_BREAKPOINT_ID: &str = "last_breakpoint_id";
|
||||
/// Key base for storing the zone-sdk indexer cursor (opaque bytes).
|
||||
pub const DB_META_ZONE_SDK_INDEXER_CURSOR_KEY: &str = "zone_sdk_indexer_cursor";
|
||||
/// Key base for storing the persisted `Option<StallReason>` diagnostic record (opaque JSON bytes).
|
||||
pub const DB_META_STALL_REASON_KEY: &str = "stall_reason";
|
||||
/// Key base for storing the L1 inscription slot of the tip block.
|
||||
pub const DB_META_TIP_SLOT_KEY: &str = "tip_slot";
|
||||
|
||||
/// Cell name for a breakpoint.
|
||||
pub const BREAKPOINT_CELL_NAME: &str = "breakpoint";
|
||||
@ -84,9 +87,10 @@ impl RocksDBIO {
|
||||
|
||||
let dbio = Self { db };
|
||||
|
||||
// First breakpoint setup
|
||||
dbio.put_breakpoint(0, initial_state)?;
|
||||
dbio.put_meta_last_breakpoint_id(0)?;
|
||||
// Seed the genesis snapshot once; reopening must not clobber it.
|
||||
if dbio.get_breakpoint_opt(0)?.is_none() {
|
||||
dbio.put_breakpoint(0, initial_state)?;
|
||||
}
|
||||
|
||||
Ok(dbio)
|
||||
}
|
||||
@ -152,8 +156,29 @@ impl RocksDBIO {
|
||||
));
|
||||
}
|
||||
|
||||
let br_id = closest_breakpoint_id(block_id);
|
||||
let mut state = self.get_breakpoint(br_id)?;
|
||||
// walk down to the nearest snapshot that exists
|
||||
let target = closest_breakpoint_id(block_id);
|
||||
let mut br_id = target;
|
||||
let mut state = loop {
|
||||
match self.get_breakpoint_opt(br_id)? {
|
||||
Some(state) => break state,
|
||||
None if br_id == 0 => {
|
||||
return Err(DbError::db_interaction_error(
|
||||
"Breakpoint 0 is missing".to_owned(),
|
||||
));
|
||||
}
|
||||
None => {
|
||||
br_id = br_id
|
||||
.checked_sub(1)
|
||||
.expect("breakpoint_id > 0 checked above");
|
||||
}
|
||||
}
|
||||
};
|
||||
if br_id < target {
|
||||
warn!(
|
||||
"Breakpoint {target} missing; replaying from breakpoint {br_id} for block {block_id}"
|
||||
);
|
||||
}
|
||||
|
||||
let start = u64::from(BREAKPOINT_INTERVAL)
|
||||
.checked_mul(br_id)
|
||||
@ -211,20 +236,7 @@ fn apply_block_transactions(mut block: Block, state: &mut V03State) -> DbResult<
|
||||
})?;
|
||||
} else {
|
||||
transaction
|
||||
.transaction_stateless_check()
|
||||
.map_err(|err| {
|
||||
DbError::db_interaction_error(format!(
|
||||
"transaction pre check failed with err {err:?}"
|
||||
))
|
||||
})?
|
||||
// FIXME: HOT FIX (testnet v0.2): does not check for system account updates due to
|
||||
// sequencer-generated deposit tx'es;
|
||||
// CHANGE ME back to `execute_check_on_state` when the indexer can authenticate deposit transactions
|
||||
.execute_without_system_accounts_check_on_state(
|
||||
state,
|
||||
block.header.block_id,
|
||||
block.header.timestamp,
|
||||
)
|
||||
.execute_on_state(state, block.header.block_id, block.header.timestamp)
|
||||
.map_err(|err| {
|
||||
DbError::db_interaction_error(format!(
|
||||
"transaction execution failed with err {err:?}"
|
||||
|
||||
@ -3,8 +3,8 @@ use crate::{
|
||||
DBIO as _,
|
||||
cells::shared_cells::{BlockCell, FirstBlockCell, FirstBlockSetCell, LastBlockCell},
|
||||
indexer::indexer_cells::{
|
||||
AccNumTxCell, BlockHashToBlockIdMapCell, BreakpointCellOwned, LastBreakpointIdCell,
|
||||
LastObservedL1LibHeaderCell, TxHashToBlockIdMapCell, ZoneSdkIndexerCursorCellOwned,
|
||||
AccNumTxCell, BlockHashToBlockIdMapCell, BreakpointCellOwned, LastObservedL1LibHeaderCell,
|
||||
StallReasonCellOwned, TipSlotCell, TxHashToBlockIdMapCell, ZoneSdkIndexerCursorCellOwned,
|
||||
},
|
||||
};
|
||||
|
||||
@ -31,8 +31,8 @@ impl RocksDBIO {
|
||||
Ok(self.get_opt::<FirstBlockSetCell>(())?.is_some())
|
||||
}
|
||||
|
||||
pub fn get_meta_last_breakpoint_id(&self) -> DbResult<Option<u64>> {
|
||||
self.get_opt::<LastBreakpointIdCell>(())
|
||||
pub fn get_meta_tip_slot_in_db(&self) -> DbResult<Option<u64>> {
|
||||
self.get_opt::<TipSlotCell>(())
|
||||
.map(|opt| opt.map(|cell| cell.0))
|
||||
}
|
||||
|
||||
@ -49,6 +49,11 @@ impl RocksDBIO {
|
||||
self.get::<BreakpointCellOwned>(br_id).map(|cell| cell.0)
|
||||
}
|
||||
|
||||
pub fn get_breakpoint_opt(&self, br_id: u64) -> DbResult<Option<V03State>> {
|
||||
self.get_opt::<BreakpointCellOwned>(br_id)
|
||||
.map(|opt| opt.map(|cell| cell.0))
|
||||
}
|
||||
|
||||
// Mappings
|
||||
|
||||
pub fn get_block_id_by_hash(&self, hash: [u8; 32]) -> DbResult<Option<u64>> {
|
||||
@ -73,4 +78,8 @@ impl RocksDBIO {
|
||||
.get_opt::<ZoneSdkIndexerCursorCellOwned>(())?
|
||||
.map(|cell| cell.0))
|
||||
}
|
||||
|
||||
pub fn get_stall_reason_bytes(&self) -> DbResult<Option<Vec<u8>>> {
|
||||
Ok(self.get_opt::<StallReasonCellOwned>(())?.map(|cell| cell.0))
|
||||
}
|
||||
}
|
||||
|
||||
@ -49,16 +49,16 @@ fn initial_state() -> lee::V03State {
|
||||
|
||||
#[test]
|
||||
fn start_db() {
|
||||
let initial_state = initial_state();
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let temdir_path = temp_dir.path();
|
||||
|
||||
let dbio = RocksDBIO::open_or_create(temdir_path, &initial_state()).unwrap();
|
||||
let dbio = RocksDBIO::open_or_create(temdir_path, &initial_state).unwrap();
|
||||
|
||||
let last_id = dbio.get_meta_last_block_id_in_db().unwrap();
|
||||
let first_id = dbio.get_meta_first_block_id_in_db().unwrap();
|
||||
let is_first_set = dbio.get_meta_is_first_block_set().unwrap();
|
||||
let last_observed_l1_header = dbio.get_meta_last_observed_l1_lib_header_in_db().unwrap();
|
||||
let last_br_id = dbio.get_meta_last_breakpoint_id().unwrap();
|
||||
let last_block = dbio.get_block(1).unwrap();
|
||||
let breakpoint = dbio.get_breakpoint(0).unwrap();
|
||||
let final_state = dbio.final_state().unwrap();
|
||||
@ -67,7 +67,6 @@ fn start_db() {
|
||||
assert_eq!(first_id, None);
|
||||
assert_eq!(last_observed_l1_header, None);
|
||||
assert!(!is_first_set);
|
||||
assert_eq!(last_br_id, Some(0)); // TODO: Will be None after we remove hardcoded testnet state
|
||||
assert!(last_block.is_none());
|
||||
assert_eq!(
|
||||
breakpoint.get_account_by_id(acc1()),
|
||||
@ -81,13 +80,15 @@ fn start_db() {
|
||||
|
||||
#[test]
|
||||
fn one_block_insertion() {
|
||||
let initial_state = initial_state();
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let temdir_path = temp_dir.path();
|
||||
|
||||
let dbio = RocksDBIO::open_or_create(temdir_path, &initial_state()).unwrap();
|
||||
let dbio = RocksDBIO::open_or_create(temdir_path, &initial_state).unwrap();
|
||||
|
||||
let genesis_block = genesis_block();
|
||||
dbio.put_block(&genesis_block, [0; 32]).unwrap();
|
||||
dbio.put_block(&genesis_block, [0; 32], 0, &initial_state)
|
||||
.unwrap();
|
||||
|
||||
let prev_hash = genesis_block.header.hash;
|
||||
let from = acc1();
|
||||
@ -98,7 +99,7 @@ fn one_block_insertion() {
|
||||
common::test_utils::create_transaction_native_token_transfer(from, 0, to, 1, &sign_key);
|
||||
let block = produce_dummy_block(2, Some(prev_hash), vec![transfer_tx]);
|
||||
|
||||
dbio.put_block(&block, [1; 32]).unwrap();
|
||||
dbio.put_block(&block, [1; 32], 0, &initial_state).unwrap();
|
||||
|
||||
let last_id = dbio.get_meta_last_block_id_in_db().unwrap().unwrap();
|
||||
let first_id = dbio.get_meta_first_block_id_in_db().unwrap();
|
||||
@ -107,7 +108,6 @@ fn one_block_insertion() {
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let is_first_set = dbio.get_meta_is_first_block_set().unwrap();
|
||||
let last_br_id = dbio.get_meta_last_breakpoint_id().unwrap();
|
||||
let last_block = dbio.get_block(last_id).unwrap().unwrap();
|
||||
let breakpoint = dbio.get_breakpoint(0).unwrap();
|
||||
let final_state = dbio.final_state().unwrap();
|
||||
@ -116,7 +116,6 @@ fn one_block_insertion() {
|
||||
assert_eq!(first_id, Some(1));
|
||||
assert_eq!(last_observed_l1_header, [1; 32]);
|
||||
assert!(is_first_set);
|
||||
assert_eq!(last_br_id, Some(0));
|
||||
assert_eq!(last_block.header.hash, block.header.hash);
|
||||
assert_eq!(
|
||||
breakpoint.get_account_by_id(acc1()).balance
|
||||
@ -131,17 +130,44 @@ fn one_block_insertion() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_breakpoint() {
|
||||
fn put_block_records_tip_inscription_slot() {
|
||||
let initial_state = initial_state();
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let temdir_path = temp_dir.path();
|
||||
let dbio = RocksDBIO::open_or_create(temp_dir.path(), &initial_state).unwrap();
|
||||
|
||||
let dbio = RocksDBIO::open_or_create(temdir_path, &initial_state()).unwrap();
|
||||
assert_eq!(dbio.get_meta_tip_slot_in_db().unwrap(), None);
|
||||
|
||||
let genesis_block = genesis_block();
|
||||
dbio.put_block(&genesis_block, [0; 32], 1_000, &initial_state)
|
||||
.unwrap();
|
||||
assert_eq!(dbio.get_meta_tip_slot_in_db().unwrap(), Some(1_000));
|
||||
|
||||
let block = produce_dummy_block(2, Some(genesis_block.header.hash), vec![]);
|
||||
dbio.put_block(&block, [1; 32], 1_005, &initial_state)
|
||||
.unwrap();
|
||||
assert_eq!(dbio.get_meta_tip_slot_in_db().unwrap(), Some(1_005));
|
||||
|
||||
// Re-inserting a block at/below the tip must not move the tip slot.
|
||||
dbio.put_block(&genesis_block, [0; 32], 1_010, &initial_state)
|
||||
.unwrap();
|
||||
assert_eq!(dbio.get_meta_tip_slot_in_db().unwrap(), Some(1_005));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn put_block_stores_breakpoint_in_same_batch() {
|
||||
let initial_state = initial_state();
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let dbio = RocksDBIO::open_or_create(temp_dir.path(), &initial_state).unwrap();
|
||||
|
||||
let from = acc1();
|
||||
let to = acc2();
|
||||
let sign_key = acc1_sign_key();
|
||||
|
||||
for i in 1..=BREAKPOINT_INTERVAL + 1 {
|
||||
// Chain blocks 1..=BREAKPOINT_INTERVAL. The snapshot is scheduled internally
|
||||
// by put_block at the boundary block; every call passes the same recognizable
|
||||
// marker state (the initial one), proving it's stored verbatim rather than
|
||||
// recomputed.
|
||||
for i in 1..=BREAKPOINT_INTERVAL {
|
||||
let prev_hash = dbio.get_meta_last_block_id_in_db().unwrap().map(|last_id| {
|
||||
let last_block = dbio.get_block(last_id).unwrap().unwrap();
|
||||
last_block.header.hash
|
||||
@ -155,51 +181,70 @@ fn new_breakpoint() {
|
||||
&sign_key,
|
||||
);
|
||||
let block = produce_dummy_block(i.into(), prev_hash, vec![transfer_tx]);
|
||||
dbio.put_block(&block, [i; 32]).unwrap();
|
||||
|
||||
dbio.put_block(&block, [i; 32], 0, &initial_state).unwrap();
|
||||
}
|
||||
|
||||
let last_id = dbio.get_meta_last_block_id_in_db().unwrap().unwrap();
|
||||
let first_id = dbio.get_meta_first_block_id_in_db().unwrap();
|
||||
let is_first_set = dbio.get_meta_is_first_block_set().unwrap();
|
||||
let last_br_id = dbio.get_meta_last_breakpoint_id().unwrap();
|
||||
let last_block = dbio.get_block(last_id).unwrap().unwrap();
|
||||
let prev_breakpoint = dbio.get_breakpoint(0).unwrap();
|
||||
let breakpoint = dbio.get_breakpoint(1).unwrap();
|
||||
let final_state = dbio.final_state().unwrap();
|
||||
let bp1 = dbio.get_breakpoint(1).unwrap();
|
||||
assert_eq!(bp1.get_account_by_id(acc1()).balance, 10000);
|
||||
assert_eq!(bp1.get_account_by_id(acc2()).balance, 20000);
|
||||
// Only the boundary block schedules a write: breakpoint 0 must be the only other one.
|
||||
assert_eq!(
|
||||
dbio.get_breakpoint(0)
|
||||
.unwrap()
|
||||
.get_account_by_id(acc1())
|
||||
.balance,
|
||||
10000
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(last_id, 101);
|
||||
assert_eq!(first_id, Some(1));
|
||||
assert!(is_first_set);
|
||||
assert_eq!(last_br_id, Some(1));
|
||||
assert_ne!(last_block.header.hash, genesis_block().header.hash);
|
||||
#[test]
|
||||
fn state_replay_falls_back_over_missing_breakpoints() {
|
||||
let initial_state = initial_state();
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let dbio = RocksDBIO::open_or_create(temp_dir.path(), &initial_state).unwrap();
|
||||
|
||||
let from = acc1();
|
||||
let to = acc2();
|
||||
let sign_key = acc1_sign_key();
|
||||
|
||||
for i in 1..=u64::from(BREAKPOINT_INTERVAL) + 1 {
|
||||
let prev_hash = dbio.get_meta_last_block_id_in_db().unwrap().map(|last_id| {
|
||||
let last_block = dbio.get_block(last_id).unwrap().unwrap();
|
||||
last_block.header.hash
|
||||
});
|
||||
let transfer_tx = common::test_utils::create_transaction_native_token_transfer(
|
||||
from,
|
||||
(i - 1).into(),
|
||||
to,
|
||||
1,
|
||||
&sign_key,
|
||||
);
|
||||
let block = produce_dummy_block(i, prev_hash, vec![transfer_tx]);
|
||||
dbio.put_block(&block, [0; 32], 0, &initial_state).unwrap();
|
||||
}
|
||||
|
||||
// Simulate a store whose boundary snapshot was lost (#605).
|
||||
dbio.delete_breakpoint(1).unwrap();
|
||||
assert!(dbio.get_breakpoint_opt(1).unwrap().is_none());
|
||||
let final_state = dbio.final_state().unwrap();
|
||||
assert_eq!(
|
||||
prev_breakpoint.get_account_by_id(acc1()).balance
|
||||
- final_state.get_account_by_id(acc1()).balance,
|
||||
101
|
||||
10000 - final_state.get_account_by_id(acc1()).balance,
|
||||
u128::from(BREAKPOINT_INTERVAL) + 1
|
||||
);
|
||||
assert_eq!(
|
||||
final_state.get_account_by_id(acc2()).balance
|
||||
- prev_breakpoint.get_account_by_id(acc2()).balance,
|
||||
101
|
||||
);
|
||||
assert_eq!(
|
||||
breakpoint.get_account_by_id(acc1()).balance
|
||||
- final_state.get_account_by_id(acc1()).balance,
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
final_state.get_account_by_id(acc2()).balance
|
||||
- breakpoint.get_account_by_id(acc2()).balance,
|
||||
1
|
||||
final_state.get_account_by_id(acc2()).balance - 20000,
|
||||
u128::from(BREAKPOINT_INTERVAL) + 1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simple_maps() {
|
||||
let initial_state = initial_state();
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let temdir_path = temp_dir.path();
|
||||
|
||||
let dbio = RocksDBIO::open_or_create(temdir_path, &initial_state()).unwrap();
|
||||
let dbio = RocksDBIO::open_or_create(temdir_path, &initial_state).unwrap();
|
||||
|
||||
let from = acc1();
|
||||
let to = acc2();
|
||||
@ -211,7 +256,7 @@ fn simple_maps() {
|
||||
|
||||
let control_hash1 = block.header.hash;
|
||||
|
||||
dbio.put_block(&block, [1; 32]).unwrap();
|
||||
dbio.put_block(&block, [1; 32], 0, &initial_state).unwrap();
|
||||
|
||||
let last_id = dbio.get_meta_last_block_id_in_db().unwrap().unwrap();
|
||||
let last_block = dbio.get_block(last_id).unwrap().unwrap();
|
||||
@ -223,7 +268,7 @@ fn simple_maps() {
|
||||
|
||||
let control_hash2 = block.header.hash;
|
||||
|
||||
dbio.put_block(&block, [2; 32]).unwrap();
|
||||
dbio.put_block(&block, [2; 32], 0, &initial_state).unwrap();
|
||||
|
||||
let last_id = dbio.get_meta_last_block_id_in_db().unwrap().unwrap();
|
||||
let last_block = dbio.get_block(last_id).unwrap().unwrap();
|
||||
@ -235,7 +280,7 @@ fn simple_maps() {
|
||||
let control_tx_hash1 = transfer_tx.hash();
|
||||
|
||||
let block = produce_dummy_block(3, Some(prev_hash), vec![transfer_tx]);
|
||||
dbio.put_block(&block, [3; 32]).unwrap();
|
||||
dbio.put_block(&block, [3; 32], 0, &initial_state).unwrap();
|
||||
|
||||
let last_id = dbio.get_meta_last_block_id_in_db().unwrap().unwrap();
|
||||
let last_block = dbio.get_block(last_id).unwrap().unwrap();
|
||||
@ -247,7 +292,7 @@ fn simple_maps() {
|
||||
let control_tx_hash2 = transfer_tx.hash();
|
||||
|
||||
let block = produce_dummy_block(4, Some(prev_hash), vec![transfer_tx]);
|
||||
dbio.put_block(&block, [4; 32]).unwrap();
|
||||
dbio.put_block(&block, [4; 32], 0, &initial_state).unwrap();
|
||||
|
||||
let control_block_id1 = dbio.get_block_id_by_hash(control_hash1.0).unwrap().unwrap();
|
||||
let control_block_id2 = dbio.get_block_id_by_hash(control_hash2.0).unwrap().unwrap();
|
||||
@ -268,12 +313,13 @@ fn simple_maps() {
|
||||
|
||||
#[test]
|
||||
fn block_batch() {
|
||||
let initial_state = initial_state();
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let temdir_path = temp_dir.path();
|
||||
|
||||
let mut block_res = vec![];
|
||||
|
||||
let dbio = RocksDBIO::open_or_create(temdir_path, &initial_state()).unwrap();
|
||||
let dbio = RocksDBIO::open_or_create(temdir_path, &initial_state).unwrap();
|
||||
|
||||
let from = acc1();
|
||||
let to = acc2();
|
||||
@ -284,7 +330,7 @@ fn block_batch() {
|
||||
let block = produce_dummy_block(1, None, vec![transfer_tx]);
|
||||
|
||||
block_res.push(block.clone());
|
||||
dbio.put_block(&block, [1; 32]).unwrap();
|
||||
dbio.put_block(&block, [1; 32], 0, &initial_state).unwrap();
|
||||
|
||||
let last_id = dbio.get_meta_last_block_id_in_db().unwrap().unwrap();
|
||||
let last_block = dbio.get_block(last_id).unwrap().unwrap();
|
||||
@ -295,7 +341,7 @@ fn block_batch() {
|
||||
let block = produce_dummy_block(2, Some(prev_hash), vec![transfer_tx]);
|
||||
|
||||
block_res.push(block.clone());
|
||||
dbio.put_block(&block, [2; 32]).unwrap();
|
||||
dbio.put_block(&block, [2; 32], 0, &initial_state).unwrap();
|
||||
|
||||
let last_id = dbio.get_meta_last_block_id_in_db().unwrap().unwrap();
|
||||
let last_block = dbio.get_block(last_id).unwrap().unwrap();
|
||||
@ -306,7 +352,7 @@ fn block_batch() {
|
||||
|
||||
let block = produce_dummy_block(3, Some(prev_hash), vec![transfer_tx]);
|
||||
block_res.push(block.clone());
|
||||
dbio.put_block(&block, [3; 32]).unwrap();
|
||||
dbio.put_block(&block, [3; 32], 0, &initial_state).unwrap();
|
||||
|
||||
let last_id = dbio.get_meta_last_block_id_in_db().unwrap().unwrap();
|
||||
let last_block = dbio.get_block(last_id).unwrap().unwrap();
|
||||
@ -317,7 +363,7 @@ fn block_batch() {
|
||||
|
||||
let block = produce_dummy_block(4, Some(prev_hash), vec![transfer_tx]);
|
||||
block_res.push(block.clone());
|
||||
dbio.put_block(&block, [4; 32]).unwrap();
|
||||
dbio.put_block(&block, [4; 32], 0, &initial_state).unwrap();
|
||||
|
||||
let block_hashes_mem: Vec<[u8; 32]> =
|
||||
block_res.into_iter().map(|bl| bl.header.hash.0).collect();
|
||||
@ -356,10 +402,11 @@ fn block_batch() {
|
||||
|
||||
#[test]
|
||||
fn account_map() {
|
||||
let initial_state = initial_state();
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let temdir_path = temp_dir.path();
|
||||
|
||||
let dbio = RocksDBIO::open_or_create(temdir_path, &initial_state()).unwrap();
|
||||
let dbio = RocksDBIO::open_or_create(temdir_path, &initial_state).unwrap();
|
||||
|
||||
let from = acc1();
|
||||
let to = acc2();
|
||||
@ -376,7 +423,7 @@ fn account_map() {
|
||||
|
||||
let block = produce_dummy_block(1, None, vec![transfer_tx1, transfer_tx2]);
|
||||
|
||||
dbio.put_block(&block, [1; 32]).unwrap();
|
||||
dbio.put_block(&block, [1; 32], 0, &initial_state).unwrap();
|
||||
|
||||
let last_id = dbio.get_meta_last_block_id_in_db().unwrap().unwrap();
|
||||
let last_block = dbio.get_block(last_id).unwrap().unwrap();
|
||||
@ -391,7 +438,7 @@ fn account_map() {
|
||||
|
||||
let block = produce_dummy_block(2, Some(prev_hash), vec![transfer_tx1, transfer_tx2]);
|
||||
|
||||
dbio.put_block(&block, [2; 32]).unwrap();
|
||||
dbio.put_block(&block, [2; 32], 0, &initial_state).unwrap();
|
||||
|
||||
let last_id = dbio.get_meta_last_block_id_in_db().unwrap().unwrap();
|
||||
let last_block = dbio.get_block(last_id).unwrap().unwrap();
|
||||
@ -406,7 +453,7 @@ fn account_map() {
|
||||
|
||||
let block = produce_dummy_block(3, Some(prev_hash), vec![transfer_tx1, transfer_tx2]);
|
||||
|
||||
dbio.put_block(&block, [3; 32]).unwrap();
|
||||
dbio.put_block(&block, [3; 32], 0, &initial_state).unwrap();
|
||||
|
||||
let last_id = dbio.get_meta_last_block_id_in_db().unwrap().unwrap();
|
||||
let last_block = dbio.get_block(last_id).unwrap().unwrap();
|
||||
@ -418,7 +465,7 @@ fn account_map() {
|
||||
|
||||
let block = produce_dummy_block(4, Some(prev_hash), vec![transfer_tx]);
|
||||
|
||||
dbio.put_block(&block, [4; 32]).unwrap();
|
||||
dbio.put_block(&block, [4; 32], 0, &initial_state).unwrap();
|
||||
|
||||
let acc1_tx = dbio.get_acc_transactions(*acc1().value(), 0, 7).unwrap();
|
||||
let acc1_tx_hashes: Vec<[u8; 32]> = acc1_tx.into_iter().map(|tx| tx.hash().0).collect();
|
||||
@ -431,3 +478,15 @@ fn account_map() {
|
||||
|
||||
assert_eq!(acc1_tx_limited_hashes.as_slice(), &tx_hash_res[1..5]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reopen_preserves_seeded_breakpoint() {
|
||||
let initial_state = initial_state();
|
||||
let temp_dir = tempdir().unwrap();
|
||||
{
|
||||
let dbio = RocksDBIO::open_or_create(temp_dir.path(), &initial_state).unwrap();
|
||||
assert!(dbio.get_breakpoint_opt(0).unwrap().is_some());
|
||||
} // drop releases the RocksDB lock
|
||||
let dbio = RocksDBIO::open_or_create(temp_dir.path(), &initial_state).unwrap();
|
||||
assert!(dbio.get_breakpoint_opt(0).unwrap().is_some());
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user