diff --git a/Cargo.lock b/Cargo.lock index ca8c2a0f..8c8a961f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1368,6 +1368,26 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "chain_state" +version = "0.1.0" +dependencies = [ + "anyhow", + "borsh", + "common", + "futures", + "lee", + "lee_core", + "log", + "logos-blockchain-core", + "logos-blockchain-zone-sdk", + "serde", + "serde_json", + "testnet_initial_state", + "thiserror 2.0.18", + "tokio", +] + [[package]] name = "chkstk_stub" version = "0.1.0" @@ -3939,6 +3959,7 @@ dependencies = [ "arc-swap", "async-stream", "borsh", + "chain_state", "common", "cross_zone", "cross_zone_inbox_core", @@ -3958,7 +3979,6 @@ dependencies = [ "storage", "tempfile", "testnet_initial_state", - "thiserror 2.0.18", "tokio", "url", ] @@ -4138,6 +4158,7 @@ dependencies = [ "tempfile", "test_fixtures", "test_programs", + "testnet_initial_state", "token_core", "tokio", "vault_core", @@ -9023,6 +9044,7 @@ dependencies = [ "borsh", "bridge_core", "bytesize", + "chain_state", "chrono", "common", "cross_zone", @@ -9053,6 +9075,7 @@ dependencies = [ "testnet_initial_state", "token_core", "tokio", + "tokio-util", "url", "vault_core", ] @@ -9068,6 +9091,7 @@ dependencies = [ "common", "env_logger", "futures", + "hex", "jsonrpsee", "lee", "log", diff --git a/Cargo.toml b/Cargo.toml index 164b64cc..11956920 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ members = [ "lez", "lez/system_accounts", + "lez/chain_state", "lez/sequencer/core", "lez/sequencer/service", "lez/sequencer/service/protocol", @@ -72,6 +73,7 @@ members = [ lee = { path = "lee/state_machine" } lee_core = { path = "lee/state_machine/core" } common = { path = "lez/common" } +chain_state = { path = "lez/chain_state" } mempool = { path = "lez/mempool" } storage = { path = "lez/storage" } key_protocol = { path = "lee/key_protocol" } diff --git a/Justfile b/Justfile index 277403c7..b65c7ff3 100644 --- a/Justfile +++ b/Justfile @@ -11,13 +11,19 @@ ARTIFACTS := "artifacts" # Linux/CI, which is unaffected. DEMO_ENV := if os() == "macos" { "DYLD_FALLBACK_FRAMEWORK_PATH=/Library/Developer/CommandLineTools/Library/Frameworks" } else { "" } -# Build risc0 program artifacts. +# Build risc0 program artifacts and test fixture. build-artifacts: @echo "๐Ÿ”จ Building artifacts" @rm -rf {{ARTIFACTS}} @just build-artifact lee/privacy_preserving_circuit @just build-artifact lez/programs programs + @if [ "${GITHUB_ACTIONS:-}" = "true" ]; then \ + echo "Skipping test fixture regeneration because CI doesn't need it"; \ + else \ + just regenerate-test-fixture; \ + fi + build-artifact methods_path features="": @echo "Building artifacts for {{methods_path}}" @rm -rf target/{{methods_path}}/riscv32im-risc0-zkvm-elf/docker/*.bin @@ -42,7 +48,7 @@ test: # Regenerate the prebuilt sequencer db dump for fast TestContext::new() (needs Docker; commit the dump). regenerate-test-fixture: - @echo "๐Ÿงช Regenerating test fixtures" + @echo "๐Ÿงช Regenerating test fixture" 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). @@ -58,15 +64,17 @@ run-bedrock: docker compose up # Run Sequencer. Run with RISC0_DEV_MODE=1 to disable proof verification for faster iteration. +# Optional home/port let a second instance run off the same config, e.g. +# `just run-sequencer "" "$TMPDIR/lez-sequencer2" 3041` for the multi-sequencer demo. [working-directory: 'lez/sequencer/service'] -run-sequencer standalone="": +run-sequencer standalone="" home="" port="3040": @echo "๐Ÿง  Running sequencer" @if [ "{{standalone}}" = "standalone" ]; then \ echo "๐Ÿงช Running in standalone mode"; \ - RUST_LOG=info cargo run --features standalone --release -p sequencer_service configs/debug/sequencer_config.json; \ + RUST_LOG=info cargo run --features standalone --release -p sequencer_service -- configs/debug/sequencer_config.json --port {{port}} {{ if home != "" { "--home " + quote(home) } else { "" } }}; \ else \ echo "๐Ÿš€ Running in normal mode"; \ - RUST_LOG=info cargo run --release -p sequencer_service configs/debug/sequencer_config.json; \ + RUST_LOG=info cargo run --release -p sequencer_service -- configs/debug/sequencer_config.json --port {{port}} {{ if home != "" { "--home " + quote(home) } else { "" } }}; \ fi # Run Indexer. Run with RISC0_DEV_MODE=1 to disable proof verification for faster iteration. diff --git a/artifacts/lee/privacy_preserving_circuit/privacy_preserving_circuit.bin b/artifacts/lee/privacy_preserving_circuit/privacy_preserving_circuit.bin index 4eafd160..58454db6 100644 Binary files a/artifacts/lee/privacy_preserving_circuit/privacy_preserving_circuit.bin and b/artifacts/lee/privacy_preserving_circuit/privacy_preserving_circuit.bin differ diff --git a/artifacts/lez/programs/amm.bin b/artifacts/lez/programs/amm.bin index ce19ce6f..18152451 100644 Binary files a/artifacts/lez/programs/amm.bin and b/artifacts/lez/programs/amm.bin differ diff --git a/artifacts/lez/programs/associated_token_account.bin b/artifacts/lez/programs/associated_token_account.bin index f5b7b02a..cc0689a3 100644 Binary files a/artifacts/lez/programs/associated_token_account.bin and b/artifacts/lez/programs/associated_token_account.bin differ diff --git a/artifacts/lez/programs/authenticated_transfer.bin b/artifacts/lez/programs/authenticated_transfer.bin index 2b90722d..94b93d26 100644 Binary files a/artifacts/lez/programs/authenticated_transfer.bin and b/artifacts/lez/programs/authenticated_transfer.bin differ diff --git a/artifacts/lez/programs/bridge.bin b/artifacts/lez/programs/bridge.bin index 3ac001d7..9424499c 100644 Binary files a/artifacts/lez/programs/bridge.bin and b/artifacts/lez/programs/bridge.bin differ diff --git a/artifacts/lez/programs/bridge_lock.bin b/artifacts/lez/programs/bridge_lock.bin index b0b68094..2c143d37 100644 Binary files a/artifacts/lez/programs/bridge_lock.bin and b/artifacts/lez/programs/bridge_lock.bin differ diff --git a/artifacts/lez/programs/cross_zone_inbox.bin b/artifacts/lez/programs/cross_zone_inbox.bin index 02af9294..610b15f3 100644 Binary files a/artifacts/lez/programs/cross_zone_inbox.bin and b/artifacts/lez/programs/cross_zone_inbox.bin differ diff --git a/artifacts/lez/programs/cross_zone_outbox.bin b/artifacts/lez/programs/cross_zone_outbox.bin index a548a31e..d10ce8c5 100644 Binary files a/artifacts/lez/programs/cross_zone_outbox.bin and b/artifacts/lez/programs/cross_zone_outbox.bin differ diff --git a/artifacts/lez/programs/faucet.bin b/artifacts/lez/programs/faucet.bin index be4e884c..9b58cb09 100644 Binary files a/artifacts/lez/programs/faucet.bin and b/artifacts/lez/programs/faucet.bin differ diff --git a/artifacts/lez/programs/pinata.bin b/artifacts/lez/programs/pinata.bin index d717d9d6..9cb14837 100644 Binary files a/artifacts/lez/programs/pinata.bin and b/artifacts/lez/programs/pinata.bin differ diff --git a/artifacts/lez/programs/pinata_token.bin b/artifacts/lez/programs/pinata_token.bin index cddd3eb1..6a0d9341 100644 Binary files a/artifacts/lez/programs/pinata_token.bin and b/artifacts/lez/programs/pinata_token.bin differ diff --git a/artifacts/lez/programs/ping_receiver.bin b/artifacts/lez/programs/ping_receiver.bin index 9463fa1c..8b6e83ff 100644 Binary files a/artifacts/lez/programs/ping_receiver.bin and b/artifacts/lez/programs/ping_receiver.bin differ diff --git a/artifacts/lez/programs/ping_sender.bin b/artifacts/lez/programs/ping_sender.bin index dfc27e67..52195fad 100644 Binary files a/artifacts/lez/programs/ping_sender.bin and b/artifacts/lez/programs/ping_sender.bin differ diff --git a/artifacts/lez/programs/token.bin b/artifacts/lez/programs/token.bin index b76eaadd..3bc7e839 100644 Binary files a/artifacts/lez/programs/token.bin and b/artifacts/lez/programs/token.bin differ diff --git a/artifacts/lez/programs/vault.bin b/artifacts/lez/programs/vault.bin index ae08f453..bc821716 100644 Binary files a/artifacts/lez/programs/vault.bin and b/artifacts/lez/programs/vault.bin differ diff --git a/artifacts/lez/programs/wrapped_token.bin b/artifacts/lez/programs/wrapped_token.bin index dc9e3454..b0f37414 100644 Binary files a/artifacts/lez/programs/wrapped_token.bin and b/artifacts/lez/programs/wrapped_token.bin differ diff --git a/integration_tests/Cargo.toml b/integration_tests/Cargo.toml index a225d657..060e10f3 100644 --- a/integration_tests/Cargo.toml +++ b/integration_tests/Cargo.toml @@ -36,6 +36,7 @@ indexer_service_protocol.workspace = true system_accounts.workspace = true programs.workspace = true test_programs.workspace = true +testnet_initial_state.workspace = true logos-blockchain-http-api-common.workspace = true logos-blockchain-core.workspace = true diff --git a/integration_tests/tests/cross_zone_bridge.rs b/integration_tests/tests/cross_zone_bridge.rs index 63b14c95..9107314a 100644 --- a/integration_tests/tests/cross_zone_bridge.rs +++ b/integration_tests/tests/cross_zone_bridge.rs @@ -9,7 +9,7 @@ //! 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 std::time::Duration; use anyhow::{Context as _, Result}; use common::transaction::LeeTransaction; @@ -17,14 +17,14 @@ use cross_zone_outbox_core::outbox_pda; use integration_tests::{ config::{self, SequencerPartialConfig}, indexer_client::IndexerClient, - setup::{setup_bedrock_node, setup_indexer, setup_sequencer}, + setup::{SequencerSetup, indexer_client, sequencer_client, setup_bedrock_node, setup_indexer}, }; 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 sequencer_service_rpc::RpcClient as _; use tokio::test; const DELIVERY_TIMEOUT: Duration = Duration::from_secs(600); @@ -62,18 +62,19 @@ async fn lock_on_zone_a_mints_wrapped_token_on_zone_b() -> Result<()> { holder: holder_id, amount: INITIAL_BALANCE, }]; - let (seq_a, _seq_a_home) = setup_sequencer(partial, bedrock_addr, genesis_a, channel_a, None) + let (seq_a, _seq_a_home) = SequencerSetup::new(partial, bedrock_addr) + .with_channel_id(channel_a) + .with_genesis(genesis_a) + .setup() .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 (_seq_b, _seq_b_home) = SequencerSetup::new(partial, bedrock_addr) + .with_channel_id(channel_b) + .with_genesis(vec![]) + .with_cross_zone(cross_zone.clone()) + .setup() + .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")?; @@ -87,9 +88,7 @@ async fn lock_on_zone_a_mints_wrapped_token_on_zone_b() -> Result<()> { // 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) + let indexer = indexer_client(idx_b.addr()) .await .context("Failed to build indexer client")?; @@ -167,14 +166,6 @@ fn build_lock_tx( LeeTransaction::Public(PublicTransaction::new(message, witness)) } -fn sequencer_client(addr: SocketAddr) -> Result { - 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 { let account_id = indexer_service_protocol::AccountId { diff --git a/integration_tests/tests/cross_zone_ingress_guard.rs b/integration_tests/tests/cross_zone_ingress_guard.rs index 5f401869..86731477 100644 --- a/integration_tests/tests/cross_zone_ingress_guard.rs +++ b/integration_tests/tests/cross_zone_ingress_guard.rs @@ -9,8 +9,6 @@ //! 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::{ @@ -18,13 +16,13 @@ use cross_zone_inbox_core::{ }; use integration_tests::{ config::{self, SequencerPartialConfig}, - setup::{setup_bedrock_node, setup_sequencer}, + setup::{SequencerSetup, sequencer_client, setup_bedrock_node}, }; use lee::{ PublicTransaction, public_transaction::{Message, WitnessSet}, }; -use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder}; +use sequencer_service_rpc::RpcClient as _; use tokio::test; #[test] @@ -34,7 +32,10 @@ async fn user_origin_inbox_call_rejected() -> Result<()> { .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) + let (seq, _seq_home) = SequencerSetup::new(partial, bedrock_addr) + .with_channel_id(channel) + .with_genesis(vec![]) + .setup() .await .context("Failed to set up sequencer")?; @@ -70,11 +71,3 @@ async fn user_origin_inbox_call_rejected() -> Result<()> { ); Ok(()) } - -fn sequencer_client(addr: SocketAddr) -> Result { - 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") -} diff --git a/integration_tests/tests/cross_zone_ping.rs b/integration_tests/tests/cross_zone_ping.rs index 19223b5f..f106bda1 100644 --- a/integration_tests/tests/cross_zone_ping.rs +++ b/integration_tests/tests/cross_zone_ping.rs @@ -11,20 +11,20 @@ //! 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 std::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}, + setup::{SequencerSetup, sequencer_client, setup_bedrock_node}, }; 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 sequencer_service_rpc::{RpcClient as _, SequencerClient}; use tokio::test; const DELIVERY_TIMEOUT: Duration = Duration::from_secs(480); @@ -54,13 +54,19 @@ async fn ping_crosses_from_zone_a_to_zone_b() -> Result<()> { }], }; - let (seq_a, _seq_a_home) = setup_sequencer(partial, bedrock_addr, vec![], channel_a, None) + let (seq_a, _seq_a_home) = SequencerSetup::new(partial, bedrock_addr) + .with_channel_id(channel_a) + .with_genesis(vec![]) + .setup() .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")?; + let (seq_b, _seq_b_home) = SequencerSetup::new(partial, bedrock_addr) + .with_channel_id(channel_b) + .with_genesis(vec![]) + .with_cross_zone(cross_zone) + .setup() + .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); @@ -117,14 +123,6 @@ fn build_ping_tx(target_zone: [u8; 32], receiver_id: ProgramId) -> LeeTransactio )) } -fn sequencer_client(addr: SocketAddr) -> Result { - 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> { let wait = async { diff --git a/integration_tests/tests/cross_zone_verified.rs b/integration_tests/tests/cross_zone_verified.rs index e2f016c0..cc21c42a 100644 --- a/integration_tests/tests/cross_zone_verified.rs +++ b/integration_tests/tests/cross_zone_verified.rs @@ -9,7 +9,7 @@ //! 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 std::time::Duration; use anyhow::{Context as _, Result}; use common::transaction::LeeTransaction; @@ -17,13 +17,13 @@ use cross_zone_outbox_core::outbox_pda; use integration_tests::{ config::{self, SequencerPartialConfig}, indexer_client::IndexerClient, - setup::{setup_bedrock_node, setup_indexer, setup_sequencer}, + setup::{SequencerSetup, indexer_client, sequencer_client, setup_bedrock_node, setup_indexer}, }; 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 sequencer_service_rpc::RpcClient as _; use tokio::test; const DELIVERY_TIMEOUT: Duration = Duration::from_secs(600); @@ -53,21 +53,22 @@ async fn indexer_verifies_and_delivers_cross_zone_ping() -> Result<()> { // 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) + let (seq_a, _seq_a_home) = SequencerSetup::new(partial, bedrock_addr) + .with_channel_id(channel_a) + .with_genesis(vec![]) + .setup() .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 (_seq_b, _seq_b_home) = SequencerSetup::new(partial, bedrock_addr) + .with_channel_id(channel_b) + .with_genesis(vec![]) + .with_cross_zone(cross_zone.clone()) + .setup() + .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")?; @@ -82,9 +83,7 @@ async fn indexer_verifies_and_delivers_cross_zone_ping() -> Result<()> { // 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) + let indexer = indexer_client(idx_b.addr()) .await .context("Failed to build indexer client")?; @@ -129,14 +128,6 @@ fn build_ping_tx(target_zone: [u8; 32], receiver_id: ProgramId) -> LeeTransactio )) } -fn sequencer_client(addr: SocketAddr) -> Result { - 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, diff --git a/integration_tests/tests/multi_sequencer.rs b/integration_tests/tests/multi_sequencer.rs new file mode 100644 index 00000000..dfca2cf2 --- /dev/null +++ b/integration_tests/tests/multi_sequencer.rs @@ -0,0 +1,236 @@ +#![expect( + clippy::tests_outside_test_module, + reason = "top-level test functions are conventional for integration tests" +)] + +//! Two sequencers share one channel: A starts solo as channel admin, live- +//! accredits `[A, B]` with round-robin rotation, B joins and syncs, both +//! produce on their turns, and A, B and an indexer converge on the same chain. + +use std::time::Duration; + +use anyhow::{Context as _, Result, ensure}; +use indexer_service_rpc::RpcClient as _; +use integration_tests::{ + config::{self, SequencerPartialConfig}, + indexer_client::IndexerClient, + setup::{SequencerSetup, indexer_client, sequencer_client, setup_bedrock_node, setup_indexer}, +}; +use logos_blockchain_key_management_system_service::keys::{ED25519_SECRET_KEY_SIZE, Ed25519Key}; +use sequencer_core::{block_publisher::post_channel_config, config::BedrockConfig}; +use sequencer_service_rpc::{RpcClient as _, SequencerClient}; +use testnet_initial_state::{initial_pub_accounts_private_keys, initial_public_user_accounts}; +use tokio::test; + +/// 1 s bedrock slots: rotate the turn every ~20 s of tenure; steal a stalled +/// turn after ~30 s (bounds the stall while B is accredited but not started). +const POSTING_TIMEFRAME_SLOTS: u32 = 20; +const POSTING_TIMEOUT_SLOTS: u32 = 30; +const PHASE_TIMEOUT: Duration = Duration::from_secs(360); +const POLL_INTERVAL: Duration = Duration::from_secs(2); +const TRANSFER_AMOUNT: u128 = 10; +/// โ‰ˆ4 turn windows past B's join (5 s blocks, ~20 s turns โ†’ ~4 blocks/window). +const ROTATION_BLOCKS: u64 = 8; + +#[test] +async fn multi_sequencer_committee_converges() -> Result<()> { + let (_bedrock, bedrock_addr) = setup_bedrock_node() + .await + .context("Failed to set up Bedrock node")?; + + // Fixed seeds so A can accredit B's public key before B exists. + let key_a = [0xA1_u8; ED25519_SECRET_KEY_SIZE]; + let key_b = [0xB2_u8; ED25519_SECRET_KEY_SIZE]; + let pub_a = Ed25519Key::from_bytes(&key_a).public_key(); + let pub_b = Ed25519Key::from_bytes(&key_b).public_key(); + + let partial = SequencerPartialConfig { + block_create_timeout: Duration::from_secs(5), + ..SequencerPartialConfig::default() + }; + + // Phase 1: A solo (its first inscription creates the channel), plus an indexer. + let (seq_a, _a_home) = SequencerSetup::new(partial, bedrock_addr) + .with_genesis(vec![]) + .with_bedrock_signing_key(key_a) + .setup() + .await + .context("Failed to set up sequencer A")?; + let a = sequencer_client(seq_a.addr())?; + let (idx, _idx_home) = setup_indexer(bedrock_addr, config::bedrock_channel_id(), None) + .await + .context("Failed to set up indexer")?; + let indexer = indexer_client(idx.addr()).await?; + + wait_for_height(&a, 2, "sequencer A to produce past genesis").await?; + + // Phase 2: live roster change to [A, B] with rotation enabled, posted + // straight to bedrock with A's admin key (the operator one-shot path). + post_channel_config( + &BedrockConfig { + channel_id: config::bedrock_channel_id(), + node_url: config::addr_to_url(config::UrlProtocol::Http, bedrock_addr)?, + auth: None, + }, + &Ed25519Key::from_bytes(&key_a), + vec![pub_a, pub_b], + POSTING_TIMEFRAME_SLOTS, + POSTING_TIMEOUT_SLOTS, + 1, + 1, + ) + .await + .context("Failed to configure the channel committee")?; + + let height_at_config = a.get_last_block_id().await?; + wait_for_height( + &a, + height_at_config + 1, + "A to produce after the roster change", + ) + .await?; + + // Phase 3: B joins live and syncs the existing chain. + let (seq_b, _b_home) = SequencerSetup::new(partial, bedrock_addr) + .with_genesis(vec![]) + .with_bedrock_signing_key(key_b) + .setup() + .await + .context("Failed to set up sequencer B")?; + let b = sequencer_client(seq_b.addr())?; + + let join_height = a.get_last_block_id().await?; + wait_for_height(&b, join_height, "B to sync to A's height at join").await?; + + // Phase 4: rotation + convergence over โ‰ˆ4 turn windows. + let rotation_target = join_height + ROTATION_BLOCKS; + wait_for_height( + &a, + rotation_target, + "the chain to advance across turn windows", + ) + .await?; + wait_for_height(&b, rotation_target, "B to follow across turn windows").await?; + assert_same_chain(&a, &b).await?; + + // Phase 5: a tx submitted only to B is included by B and visible on A. + let accounts = initial_public_user_accounts(); + let from = accounts[0].account_id; + let to = accounts[1].account_id; + let sign_key = initial_pub_accounts_private_keys()[0].pub_sign_key.clone(); + + let to_balance_before = a.get_account_balance(to).await?; + let nonce = b.get_accounts_nonces(vec![from]).await?[0]; + let tx = common::test_utils::create_transaction_native_token_transfer( + from, + nonce.0, + to, + TRANSFER_AMOUNT, + &sign_key, + ); + b.send_transaction(tx) + .await + .context("Failed to submit the transfer to B")?; + + wait_for_balance(&a, to, to_balance_before + TRANSFER_AMOUNT).await?; + + // Phase 6: the indexer finalizes the same chain, with no stall. + wait_for_finalized(&indexer, join_height).await?; + let finalized = indexer.get_last_finalized_block_id().await?.unwrap_or(0); + for id in 1..=finalized { + let block_i = indexer + .get_block_by_id(id) + .await? + .with_context(|| format!("Indexer is missing finalized block {id}"))?; + let block_a = a + .get_block(id) + .await? + .with_context(|| format!("A is missing block {id}"))?; + ensure!( + block_i.header.hash == indexer_service_protocol::HashType::from(block_a.header.hash), + "Indexer diverges from A at block {id}" + ); + } + let status = indexer.get_status().await?; + ensure!( + status.stall_reason.is_none(), + "Indexer is stalled: {:?}", + status.stall_reason + ); + + Ok(()) +} + +/// Polls the sequencer until its chain height reaches `target`. +async fn wait_for_height(client: &SequencerClient, target: u64, what: &str) -> Result<()> { + let wait = async { + loop { + if client.get_last_block_id().await? >= target { + return Ok::<(), anyhow::Error>(()); + } + tokio::time::sleep(POLL_INTERVAL).await; + } + }; + tokio::time::timeout(PHASE_TIMEOUT, wait) + .await + .with_context(|| format!("Timed out waiting for {what} (target height {target})"))? +} + +/// Polls the sequencer until `account`'s balance reaches `expected`. +async fn wait_for_balance( + client: &SequencerClient, + account: lee::AccountId, + expected: u128, +) -> Result<()> { + let wait = async { + loop { + if client.get_account_balance(account).await? == expected { + return Ok::<(), anyhow::Error>(()); + } + tokio::time::sleep(POLL_INTERVAL).await; + } + }; + tokio::time::timeout(PHASE_TIMEOUT, wait) + .await + .context("Timed out waiting for the cross-sequencer transfer to reach A")? +} + +/// Polls the indexer until its finalized height reaches `target`. +async fn wait_for_finalized(indexer: &IndexerClient, target: u64) -> Result<()> { + let wait = async { + loop { + if indexer.get_last_finalized_block_id().await?.unwrap_or(0) >= target { + return Ok::<(), anyhow::Error>(()); + } + tokio::time::sleep(POLL_INTERVAL).await; + } + }; + tokio::time::timeout(PHASE_TIMEOUT, wait) + .await + .context("Timed out waiting for the indexer to finalize")? +} + +/// Asserts A and B hold byte-identical block hashes over their common prefix. +async fn assert_same_chain(a: &SequencerClient, b: &SequencerClient) -> Result<()> { + let common = a + .get_last_block_id() + .await? + .min(b.get_last_block_id().await?); + for id in 1..=common { + let block_a = a + .get_block(id) + .await? + .with_context(|| format!("A is missing block {id}"))?; + let block_b = b + .get_block(id) + .await? + .with_context(|| format!("B is missing block {id}"))?; + ensure!( + block_a.header.hash == block_b.header.hash, + "Chain divergence at block {id}: A {:?} vs B {:?}", + block_a.header.hash, + block_b.header.hash + ); + } + Ok(()) +} diff --git a/integration_tests/tests/sequencer_bootstrap.rs b/integration_tests/tests/sequencer_bootstrap.rs new file mode 100644 index 00000000..9dd2b586 --- /dev/null +++ b/integration_tests/tests/sequencer_bootstrap.rs @@ -0,0 +1,538 @@ +//! End-to-end tests for the sequencer's startup bootstrap/reconstruction from +//! Bedrock (verify-and-reconstruct). Each test drives a real Bedrock node and one +//! or more sequencer instances sharing the same channel, exercising how a +//! sequencer reconciles its local store against what the channel serves. + +#![expect( + clippy::tests_outside_test_module, + reason = "Integration tests live at crate root and don't care about these lints" +)] + +use std::{path::Path, time::Duration}; + +use anyhow::{Context as _, Result, bail}; +use indexer_service_rpc::RpcClient as _; +use integration_tests::L2_TO_L1_TIMEOUT; +use lee::{AccountId, PrivateKey, PublicKey}; +use logos_blockchain_core::mantle::ops::channel::ChannelId; +use sequencer_core::config::GenesisAction; +use sequencer_service_rpc::{RpcClient as _, SequencerClient}; +use test_fixtures::{ + config::{SequencerPartialConfig, UrlProtocol, addr_to_url}, + indexer_client::IndexerClient, + setup::{SequencerSetup, sequencer_client, setup_bedrock_node, setup_indexer}, +}; +use tokio::test; + +/// Block cadence for the tests: short so we don't wait long for local production. +fn fast_blocks() -> SequencerPartialConfig { + SequencerPartialConfig { + block_create_timeout: Duration::from_secs(2), + ..SequencerPartialConfig::default() + } +} + +/// Block cadence for a sequencer we don't want producing during the inspection +/// window, so its post-reconstruction tip stays put while we read it. +fn slow_blocks() -> SequencerPartialConfig { + SequencerPartialConfig { + block_create_timeout: Duration::from_secs(30), + ..SequencerPartialConfig::default() + } +} + +/// Polls the indexer's last finalized block id until it reaches `target`. The +/// indexer reads finalized channel history, so this is our oracle for "block +/// `target` is finalized on Bedrock" โ€” exactly what a reconstructing sequencer +/// can read back. +async fn wait_for_finalized( + indexer: &IndexerClient, + target: u64, + timeout: Duration, +) -> Result { + let poll = async { + loop { + let finalized = indexer + .get_last_finalized_block_id() + .await + .context("Failed to read indexer last finalized block id")? + .unwrap_or(0); + if finalized >= target { + return Ok::<_, anyhow::Error>(finalized); + } + tokio::time::sleep(Duration::from_secs(1)).await; + } + }; + tokio::time::timeout(timeout, poll) + .await + .with_context(|| format!("Timed out waiting for indexer to finalize block {target}"))? +} + +/// Polls the sequencer's last block id until it reaches `target` or `timeout` elapses. +async fn wait_for_block_id( + client: &SequencerClient, + target: u64, + timeout: Duration, +) -> Result { + let poll = async { + loop { + let id = client + .get_last_block_id() + .await + .context("Failed to read sequencer last block id")?; + if id >= target { + return Ok::<_, anyhow::Error>(id); + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + }; + tokio::time::timeout(timeout, poll) + .await + .with_context(|| format!("Timed out waiting for block id {target}"))? +} + +/// Best-effort extraction of a panic payload's message (panics carry a `String` +/// or `&str`), for asserting a startup aborted for the *expected* reason. +fn panic_message(payload: &(dyn std::any::Any + Send)) -> String { + payload + .downcast_ref::() + .cloned() + .or_else(|| payload.downcast_ref::<&str>().map(|s| (*s).to_owned())) + .unwrap_or_else(|| "".to_owned()) +} + +/// A `SupplyAccount` genesis action for a fresh account, returning the vault +/// account id the funds land in (genesis supply goes into a claimable vault, not +/// the account directly), so tests can assert genesis state is present. +fn supplied_account(balance: u128) -> (AccountId, GenesisAction) { + let account_id = AccountId::from(&PublicKey::new_from_private_key( + &PrivateKey::new_os_random(), + )); + let vault_id = vault_core::compute_vault_account_id(programs::vault().id(), account_id); + ( + vault_id, + GenesisAction::SupplyAccount { + account_id, + balance, + }, + ) +} + +/// Recursively copies the contents of `src` into `dst` (used to snapshot/restore a +/// sequencer's rocksdb directory while it is stopped). +fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> { + std::fs::create_dir_all(dst) + .with_context(|| format!("Failed to create dir {}", dst.display()))?; + for entry in + std::fs::read_dir(src).with_context(|| format!("Failed to read dir {}", src.display()))? + { + let entry = entry.context("Failed to read dir entry")?; + let target = dst.join(entry.file_name()); + if entry + .file_type() + .context("Failed to read file type")? + .is_dir() + { + copy_dir_recursive(&entry.path(), &target)?; + } else { + std::fs::copy(entry.path(), &target) + .with_context(|| format!("Failed to copy into {}", target.display()))?; + } + } + Ok(()) +} + +/// Case 1: local store is empty and the Bedrock channel is empty. +/// +/// The sequencer bootstraps genesis state, finds nothing to reconstruct, publishes +/// its own genesis to open the channel, and starts producing. +#[test] +async fn empty_local_and_empty_bedrock_bootstraps_from_genesis() -> Result<()> { + let (_bedrock, bedrock_addr) = setup_bedrock_node() + .await + .context("Failed to setup Bedrock")?; + let home = tempfile::tempdir().context("Failed to create sequencer home")?; + + let (vault_id, supply) = supplied_account(12_345); + let genesis = vec![ + supply, + GenesisAction::SupplyBridgeAccount { balance: 1_000_000 }, + ]; + + let handle = SequencerSetup::new(fast_blocks(), bedrock_addr) + .with_genesis(genesis) + .setup_at(home.path()) + .await + .context("Failed to start sequencer")?; + let client = sequencer_client(handle.addr())?; + + // Fresh store + empty channel: startup bootstrapped genesis state directly. + assert_eq!( + client.get_account_balance(vault_id).await?, + 12_345, + "genesis-supplied vault balance must be present after bootstrap" + ); + + // The sequencer is live and producing on the freshly opened channel. + let last = wait_for_block_id(&client, 3, Duration::from_secs(60)).await?; + assert!( + last >= 3, + "sequencer should keep producing blocks, last={last}" + ); + assert!(handle.is_healthy(), "sequencer must stay healthy"); + + Ok(()) +} + +/// Case 2: local store is empty, but the Bedrock channel already has blocks. +/// +/// A first sequencer opens the channel and produces blocks; a second sequencer +/// starts from an empty store on the same channel (reusing the first's bedrock +/// signing key) and reconstructs the finalized history into its state. +#[test] +async fn empty_local_reconstructs_from_populated_bedrock() -> Result<()> { + const PRODUCED_TARGET: u64 = 3; + + let (_bedrock, bedrock_addr) = setup_bedrock_node() + .await + .context("Failed to setup Bedrock")?; + let (indexer_handle, _indexer_dir) = setup_indexer( + bedrock_addr, + test_fixtures::config::bedrock_channel_id(), + None, + ) + .await + .context("Failed to setup indexer")?; + let indexer_url = addr_to_url(UrlProtocol::Ws, indexer_handle.addr()) + .context("Failed to build indexer URL")?; + let indexer = IndexerClient::new(&indexer_url) + .await + .context("Failed to build indexer client")?; + + let (vault_id, supply) = supplied_account(7_777); + let genesis = vec![ + supply, + GenesisAction::SupplyBridgeAccount { balance: 1_000_000 }, + ]; + + // Sequencer A opens the channel and produces a few blocks. + let home_a = tempfile::tempdir().context("Failed to create sequencer A home")?; + let handle_a = SequencerSetup::new(fast_blocks(), bedrock_addr) + .with_genesis(genesis.clone()) + .setup_at(home_a.path()) + .await + .context("Failed to start sequencer A")?; + let client_a = sequencer_client(handle_a.addr())?; + wait_for_block_id(&client_a, PRODUCED_TARGET, Duration::from_secs(60)).await?; + + // Wait until those blocks are finalized on Bedrock โ€” reconstruction only + // reads finalized history. A stays alive so its publish task keeps flushing. + let finalized = wait_for_finalized(&indexer, PRODUCED_TARGET, L2_TO_L1_TIMEOUT).await?; + + // Stop A, then wipe just its L2 store (keeping the bedrock signing key) so it + // restarts from an empty store on the same channel/identity โ€” a sequencer that + // lost its local DB. + drop(handle_a); + tokio::time::sleep(Duration::from_secs(2)).await; + std::fs::remove_dir_all(home_a.path().join("rocksdb")) + .context("Failed to wipe sequencer L2 store")?; + + // Sequencer B restarts on the same home from that empty store and reconstructs. + let handle_b = SequencerSetup::new(slow_blocks(), bedrock_addr) + .with_genesis(genesis) + .setup_at(home_a.path()) + .await + .context("Failed to start sequencer B")?; + let client_b = sequencer_client(handle_b.addr())?; + + // Reconstruction ran synchronously during B's startup: even though its local + // store was empty, its tip is past genesis, matching the finalized channel. + let tip_b = client_b.get_last_block_id().await?; + assert!( + tip_b >= finalized, + "B should reconstruct at least the finalized blocks; tip_b={tip_b}, finalized={finalized}" + ); + assert!( + tip_b > 1, + "B should have reconstructed blocks beyond genesis; tip_b={tip_b}" + ); + + // Genesis state was rebuilt as part of the reconstruction. + assert_eq!( + client_b.get_account_balance(vault_id).await?, + 7_777, + "reconstructed genesis vault balance must be present" + ); + assert!(handle_b.is_healthy(), "sequencer B must stay healthy"); + + Ok(()) +} + +/// Case 3: local store is not empty, but the Bedrock channel is empty. +/// +/// A sequencer produces blocks (committing to a channel), is stopped, and is +/// restarted against a fresh/empty channel โ€” i.e. the channel it committed to +/// was wiped or the node points at a different chain. Startup must fail rather +/// than silently resume onto a foreign channel. Crucially this must hold even +/// though the sequencer only ever *produced* (so it never recorded a per-block +/// anchor): the committed-but-missing-channel invariant catches it. +#[test] +async fn nonempty_local_against_empty_channel_fails_startup() -> Result<()> { + const PRODUCED_TARGET: u64 = 3; + + let (_bedrock, bedrock_addr) = setup_bedrock_node() + .await + .context("Failed to setup Bedrock")?; + + let (_vault_id, supply) = supplied_account(1); + let genesis = vec![ + supply, + GenesisAction::SupplyBridgeAccount { balance: 1_000_000 }, + ]; + + // A opens the channel and produces blocks. They land in its local store + // immediately, so no need to wait for finalization. + let home_a = tempfile::tempdir().context("Failed to create sequencer A home")?; + let handle_a = SequencerSetup::new(fast_blocks(), bedrock_addr) + .with_genesis(genesis.clone()) + .setup_at(home_a.path()) + .await + .context("Failed to start sequencer A")?; + wait_for_block_id( + &sequencer_client(handle_a.addr())?, + PRODUCED_TARGET, + Duration::from_secs(60), + ) + .await?; + drop(handle_a); + tokio::time::sleep(Duration::from_secs(2)).await; + + // Restart on the SAME home (A's committed store: blocks + checkpoint) but + // pointed at a fresh, never-used channel โ€” the channel it committed to is gone. + let empty_channel = ChannelId::from([0x5a_u8; 32]); + + // Startup aborts on the missing-channel invariant (a panic in + // `start_from_config`). Run it on a dedicated OS thread with its own runtime + // so the panic is isolated to `join()` instead of failing the test thread. + // The `timeout` future must be created *inside* `block_on` (it needs a running + // reactor), so build it in an `async` block rather than as an eager argument. + let home_a_path = home_a.path().to_owned(); + let outcome = std::thread::spawn(move || { + let runtime = tokio::runtime::Runtime::new().expect("Failed to build runtime"); + runtime.block_on(async { + tokio::time::timeout( + Duration::from_secs(90), + SequencerSetup::new(slow_blocks(), bedrock_addr) + .with_channel_id(empty_channel) + .with_genesis(genesis) + .setup_at(&home_a_path), + ) + .await + }) + }) + .join(); + + match outcome { + // Expected: `start_from_config` panicked on the missing-channel invariant. + // Assert the *reason*, so an unrelated panic fails the test rather than + // masquerading as success. + Err(panic) => { + let message = panic_message(&*panic); + assert!( + message.contains("Refusing to resume onto a foreign channel"), + "startup panicked for an unexpected reason: {message}" + ); + } + Ok(Err(_elapsed)) => { + bail!("Sequencer startup hung instead of failing against an empty channel") + } + Ok(Ok(Err(err))) => { + bail!("Sequencer expected to panic, but it failed with error: {err:#?}") + } + Ok(Ok(Ok(_handle))) => { + bail!("Sequencer startup unexpectedly succeeded against an empty channel") + } + } + + Ok(()) +} + +/// Case 4: both non-empty, but the local store is *ahead* of the finalized channel. +/// +/// A sequencer produces blocks faster than Bedrock finalizes them (the normal +/// steady state), so on restart its local tip leads the channel's finalized tip. +/// Startup must succeed: reconstruction re-verifies the finalized blocks it already +/// holds and leaves the extra, not-yet-finalized local blocks untouched. +#[test] +async fn local_ahead_of_channel_resumes() -> Result<()> { + const FINALIZED_TARGET: u64 = 2; + + let (_bedrock, bedrock_addr) = setup_bedrock_node() + .await + .context("Failed to setup Bedrock")?; + let (indexer_handle, _indexer_dir) = setup_indexer( + bedrock_addr, + test_fixtures::config::bedrock_channel_id(), + None, + ) + .await + .context("Failed to setup indexer")?; + let indexer_url = addr_to_url(UrlProtocol::Ws, indexer_handle.addr()) + .context("Failed to build indexer URL")?; + let indexer = IndexerClient::new(&indexer_url) + .await + .context("Failed to build indexer client")?; + + let (vault_id, supply) = supplied_account(4_242); + let genesis = vec![ + supply, + GenesisAction::SupplyBridgeAccount { balance: 1_000_000 }, + ]; + + // A produces continuously (fast cadence) while Bedrock finalizes slowly, so + // its local tip runs well ahead of the channel's finalized tip. + let home = tempfile::tempdir().context("Failed to create sequencer home")?; + let handle_a = SequencerSetup::new(fast_blocks(), bedrock_addr) + .with_genesis(genesis.clone()) + .setup_at(home.path()) + .await + .context("Failed to start sequencer A")?; + let client_a = sequencer_client(handle_a.addr())?; + let finalized = wait_for_finalized(&indexer, FINALIZED_TARGET, L2_TO_L1_TIMEOUT).await?; + let tip_before = client_a.get_last_block_id().await?; + assert!( + tip_before > finalized, + "local tip {tip_before} should lead the finalized tip {finalized}" + ); + + // Restart on the same home; slow cadence so its tip stays put while we inspect. + drop(handle_a); + tokio::time::sleep(Duration::from_secs(2)).await; + let handle_b = SequencerSetup::new(slow_blocks(), bedrock_addr) + .with_genesis(genesis) + .setup_at(home.path()) + .await + .context("Failed to restart sequencer")?; + let client_b = sequencer_client(handle_b.addr())?; + + // Reconstruction verified the finalized prefix and preserved the extra blocks. + let tip_b = client_b.get_last_block_id().await?; + assert!( + tip_b >= tip_before, + "restart must not lose locally-produced blocks; tip_b={tip_b}, before={tip_before}" + ); + assert_eq!( + client_b.get_account_balance(vault_id).await?, + 4_242, + "genesis state must survive the restart" + ); + assert!(handle_b.is_healthy(), "sequencer must stay healthy"); + + Ok(()) +} + +/// Case 5: both non-empty, but the local store is *behind* the finalized channel. +/// +/// We snapshot a sequencer's store at an early tip, let it keep extending and +/// finalizing the channel, then restore the early snapshot and restart. Startup +/// must reconstruct forward โ€” replay the finalized blocks the local store is +/// missing โ€” catching the local tip up to the channel. +/// +/// The snapshot/restore is essential here (not a gratuitous copy): a live +/// sequencer's local tip always leads finalization, so the only way to obtain a +/// local store that *lags* the finalized channel is to preserve an earlier state +/// while the same channel advances past it. +#[test] +async fn local_behind_channel_reconstructs_forward() -> Result<()> { + const SNAPSHOT_TIP: u64 = 2; + const FINALIZED_TARGET: u64 = 4; + + let (_bedrock, bedrock_addr) = setup_bedrock_node() + .await + .context("Failed to setup Bedrock")?; + let (indexer_handle, _indexer_dir) = setup_indexer( + bedrock_addr, + test_fixtures::config::bedrock_channel_id(), + None, + ) + .await + .context("Failed to setup indexer")?; + let indexer_url = addr_to_url(UrlProtocol::Ws, indexer_handle.addr()) + .context("Failed to build indexer URL")?; + let indexer = IndexerClient::new(&indexer_url) + .await + .context("Failed to build indexer client")?; + + let (vault_id, supply) = supplied_account(5_005); + let genesis = vec![ + supply, + GenesisAction::SupplyBridgeAccount { balance: 1_000_000 }, + ]; + + let home = tempfile::tempdir().context("Failed to create sequencer home")?; + let rocksdb = home.path().join("rocksdb"); + + // Bring the sequencer up to an early tip, then stop it so its store is at rest. + { + let handle = SequencerSetup::new(slow_blocks(), bedrock_addr) + .with_genesis(genesis.clone()) + .setup_at(home.path()) + .await + .context("Failed to start sequencer")?; + wait_for_block_id( + &sequencer_client(handle.addr())?, + SNAPSHOT_TIP, + Duration::from_secs(120), + ) + .await?; + drop(handle); + tokio::time::sleep(Duration::from_secs(2)).await; + } + + // Snapshot the early store (tip == SNAPSHOT_TIP), safe because it is at rest. + let snapshot = tempfile::tempdir().context("Failed to create snapshot dir")?; + copy_dir_recursive(&rocksdb, snapshot.path()).context("Failed to snapshot store")?; + + // Resume the sequencer (fast cadence) so it extends and finalizes the channel + // beyond the snapshot. + let finalized = { + let handle = SequencerSetup::new(fast_blocks(), bedrock_addr) + .with_genesis(genesis.clone()) + .setup_at(home.path()) + .await + .context("Failed to resume sequencer")?; + let finalized = wait_for_finalized(&indexer, FINALIZED_TARGET, L2_TO_L1_TIMEOUT).await?; + drop(handle); + tokio::time::sleep(Duration::from_secs(2)).await; + finalized + }; + + // Restore the early snapshot: the local store now lags the finalized channel. + std::fs::remove_dir_all(&rocksdb).context("Failed to remove store before restore")?; + copy_dir_recursive(snapshot.path(), &rocksdb).context("Failed to restore snapshot")?; + + // Restart: reconstruction must catch the lagging store up to the channel. + let handle = SequencerSetup::new(slow_blocks(), bedrock_addr) + .with_genesis(genesis) + .setup_at(home.path()) + .await + .context("Failed to restart sequencer from a lagging store")?; + let client = sequencer_client(handle.addr())?; + let tip = client.get_last_block_id().await?; + assert!( + tip >= finalized, + "lagging store must reconstruct forward to the finalized tip; tip={tip}, finalized={finalized}" + ); + assert!( + tip > SNAPSHOT_TIP, + "reconstruction must advance beyond the snapshot tip; tip={tip}" + ); + assert_eq!( + client.get_account_balance(vault_id).await?, + 5_005, + "genesis state must be intact after reconstruction" + ); + assert!(handle.is_healthy(), "sequencer must stay healthy"); + + Ok(()) +} diff --git a/integration_tests/tests/tps.rs b/integration_tests/tests/tps.rs index 71fb132b..7141303e 100644 --- a/integration_tests/tests/tps.rs +++ b/integration_tests/tests/tps.rs @@ -294,6 +294,7 @@ fn build_privacy_transaction() -> PrivacyPreservingTransaction { InputAccountIdentity::PrivateAuthorizedUpdate { vpk: sender_vpk, random_seed: [0; 32], + view_tag: 0, nsk: sender_nsk, membership_proof: proof, identifier: 0, diff --git a/integration_tests/tests/two_zone.rs b/integration_tests/tests/two_zone.rs index c895acd1..8f4b2697 100644 --- a/integration_tests/tests/two_zone.rs +++ b/integration_tests/tests/two_zone.rs @@ -13,7 +13,7 @@ use indexer_service_rpc::RpcClient as _; use integration_tests::{ config::{self, SequencerPartialConfig}, indexer_client::IndexerClient, - setup::{setup_bedrock_node, setup_indexer, setup_sequencer}, + setup::{SequencerSetup, setup_bedrock_node, setup_indexer}, }; use sequencer_service_rpc::{RpcClient as _, SequencerClientBuilder}; use tokio::test; @@ -35,13 +35,19 @@ async fn two_zones_share_one_bedrock_and_both_advance() -> Result<()> { 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) + let (seq_a, _seq_a_home) = SequencerSetup::new(partial, bedrock_addr) + .with_channel_id(channel_a) + .with_genesis(vec![]) + .setup() .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) + let (seq_b, _seq_b_home) = SequencerSetup::new(partial, bedrock_addr) + .with_channel_id(channel_b) + .with_genesis(vec![]) + .setup() .await .context("Failed to set up zone B sequencer")?; let (idx_b, _idx_b_home) = setup_indexer(bedrock_addr, channel_b, None) diff --git a/lee/privacy_preserving_circuit/src/output.rs b/lee/privacy_preserving_circuit/src/output.rs index c9543e05..c5d962d6 100644 --- a/lee/privacy_preserving_circuit/src/output.rs +++ b/lee/privacy_preserving_circuit/src/output.rs @@ -4,7 +4,7 @@ use lee_core::{ PrivacyPreservingCircuitOutput, PrivateAccountKind, SharedSecretKey, account::{Account, AccountId, Nonce}, compute_digest_for_path, - encryption::ViewingPublicKey, + encryption::{ViewTag, ViewingPublicKey}, }; use crate::execution_state::ExecutionState; @@ -66,6 +66,7 @@ pub fn compute_circuit_output( *commitment_root, ); let new_nonce = Nonce::private_account_nonce_init(&account_id); + let view_tag = EncryptedAccountData::compute_view_tag(&npk, vpk); emit_private_output( &mut output, @@ -73,7 +74,7 @@ pub fn compute_circuit_output( post_state, &account_id, &PrivateAccountKind::Regular(*identifier), - &npk, + view_tag, vpk, random_seed, new_nullifier, @@ -83,6 +84,7 @@ pub fn compute_circuit_output( InputAccountIdentity::PrivateAuthorizedUpdate { vpk, random_seed, + view_tag, nsk, membership_proof, identifier, @@ -110,7 +112,7 @@ pub fn compute_circuit_output( post_state, &account_id, &PrivateAccountKind::Regular(*identifier), - &npk, + *view_tag, vpk, random_seed, new_nullifier, @@ -142,6 +144,7 @@ pub fn compute_circuit_output( *commitment_root, ); let new_nonce = Nonce::private_account_nonce_init(&account_id); + let view_tag = EncryptedAccountData::compute_view_tag(npk, vpk); emit_private_output( &mut output, @@ -149,7 +152,7 @@ pub fn compute_circuit_output( post_state, &account_id, &PrivateAccountKind::Regular(*identifier), - npk, + view_tag, vpk, random_seed, new_nullifier, @@ -190,6 +193,7 @@ pub fn compute_circuit_output( let (authority_program_id, seed) = pda_seed_by_position .get(&pos) .expect("PrivatePdaInit position must be in pda_seed_by_position"); + let view_tag = EncryptedAccountData::compute_view_tag(npk, vpk); emit_private_output( &mut output, &mut output_index, @@ -200,7 +204,7 @@ pub fn compute_circuit_output( seed: *seed, identifier: *identifier, }, - npk, + view_tag, vpk, random_seed, new_nullifier, @@ -210,6 +214,7 @@ pub fn compute_circuit_output( InputAccountIdentity::PrivatePdaUpdate { vpk, random_seed, + view_tag, nsk, membership_proof, identifier, @@ -234,7 +239,6 @@ pub fn compute_circuit_output( let new_nonce = pre_state.account.nonce.private_account_nonce_increment(nsk); let account_id = pre_state.account_id; - let npk = NullifierPublicKey::from(nsk); let (authority_program_id, seed) = pda_seed_by_position .get(&pos) .expect("PrivatePdaUpdate position must be in pda_seed_by_position"); @@ -248,7 +252,7 @@ pub fn compute_circuit_output( seed: *seed, identifier: *identifier, }, - &npk, + *view_tag, vpk, random_seed, new_nullifier, @@ -271,7 +275,7 @@ fn emit_private_output( post_state: Account, account_id: &AccountId, kind: &PrivateAccountKind, - npk: &NullifierPublicKey, + view_tag: ViewTag, vpk: &ViewingPublicKey, random_seed: &[u8; 32], new_nullifier: (Nullifier, CommitmentSetDigest), @@ -287,15 +291,6 @@ fn emit_private_output( let esk = EphemeralSecretKey::new(account_id, random_seed, &new_nonce); let (shared_secret, epk) = SharedSecretKey::encapsulate_deterministic(vpk, &esk); - // Currently the view tag is properlty generated for all accounts. - // To increase privacy, this will be changed in the later version - // to only be generated explicitly for initialized accounts and - // fed by the prover directly for updated accounts. - // - // See issue 573: - // https://github.com/logos-blockchain/logos-execution-zone/issues/573 - let view_tag = EncryptedAccountData::compute_view_tag(npk, vpk); - let encrypted_account = EncryptionScheme::encrypt( &post_with_updated_nonce, kind, diff --git a/lee/state_machine/core/src/circuit_io.rs b/lee/state_machine/core/src/circuit_io.rs index d441ff19..8db29342 100644 --- a/lee/state_machine/core/src/circuit_io.rs +++ b/lee/state_machine/core/src/circuit_io.rs @@ -4,7 +4,7 @@ use crate::{ Commitment, CommitmentSetDigest, Identifier, MembershipProof, Nullifier, NullifierPublicKey, NullifierSecretKey, account::{Account, AccountWithMetadata}, - encryption::{EncryptedAccountData, ViewingPublicKey}, + encryption::{EncryptedAccountData, ViewTag, ViewingPublicKey}, program::{BlockValidityWindow, PdaSeed, ProgramId, ProgramOutput, TimestampValidityWindow}, }; @@ -42,6 +42,7 @@ pub enum InputAccountIdentity { PrivateAuthorizedUpdate { vpk: ViewingPublicKey, random_seed: [u8; 32], + view_tag: ViewTag, nsk: NullifierSecretKey, membership_proof: MembershipProof, identifier: Identifier, @@ -79,6 +80,7 @@ pub enum InputAccountIdentity { PrivatePdaUpdate { vpk: ViewingPublicKey, random_seed: [u8; 32], + view_tag: ViewTag, nsk: NullifierSecretKey, membership_proof: MembershipProof, identifier: Identifier, diff --git a/lee/state_machine/src/merkle_tree/mod.rs b/lee/state_machine/src/merkle_tree/mod.rs index ee8106c5..37e706f7 100644 --- a/lee/state_machine/src/merkle_tree/mod.rs +++ b/lee/state_machine/src/merkle_tree/mod.rs @@ -8,8 +8,8 @@ mod default_values; type Value = [u8; 32]; type Node = [u8; 32]; -#[cfg_attr(test, derive(Debug, PartialEq, Eq))] -#[derive(Clone, BorshSerialize, BorshDeserialize)] +#[cfg_attr(test, derive(Debug))] +#[derive(Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] pub struct MerkleTree { nodes: Vec, capacity: usize, diff --git a/lee/state_machine/src/privacy_preserving_transaction/circuit/tests.rs b/lee/state_machine/src/privacy_preserving_transaction/circuit/tests.rs index 005a9230..fb66186b 100644 --- a/lee/state_machine/src/privacy_preserving_transaction/circuit/tests.rs +++ b/lee/state_machine/src/privacy_preserving_transaction/circuit/tests.rs @@ -1,8 +1,8 @@ #![expect(clippy::shadow_unrelated, reason = "We don't care about it in tests")] use lee_core::{ - Commitment, DUMMY_COMMITMENT_HASH, EncryptionScheme, EphemeralSecretKey, Nullifier, - PrivacyPreservingCircuitOutput, SharedSecretKey, + Commitment, DUMMY_COMMITMENT_HASH, EncryptedAccountData, EncryptionScheme, EphemeralSecretKey, + Nullifier, PrivacyPreservingCircuitOutput, SharedSecretKey, account::{Account, AccountId, AccountWithMetadata, Nonce, data::Data}, program::{PdaSeed, PrivateAccountKind}, }; @@ -198,6 +198,7 @@ fn prove_privacy_preserving_execution_circuit_fully_private() { InputAccountIdentity::PrivateAuthorizedUpdate { vpk: sender_keys.vpk(), random_seed: [0; 32], + view_tag: 0, nsk: sender_keys.nsk, membership_proof: commitment_set .get_proof_for(&commitment_sender) @@ -242,6 +243,76 @@ fn prove_privacy_preserving_execution_circuit_fully_private() { assert_eq!(recipient_post, expected_private_account_2); } +#[test] +fn init_note_view_tag_is_derived_from_account_keys() { + let program = crate::test_methods::noop(); + let keys = test_private_account_keys_1(); + let identifier: u128 = 0; + let account_id = AccountId::for_regular_private_account(&keys.npk(), &keys.vpk(), identifier); + let account = AccountWithMetadata::new(Account::default(), false, account_id); + + let (output, proof) = execute_and_prove( + vec![account], + Program::serialize_instruction(()).unwrap(), + vec![InputAccountIdentity::PrivateUnauthorized { + vpk: keys.vpk(), + random_seed: [0; 32], + npk: keys.npk(), + identifier, + commitment_root: DUMMY_COMMITMENT_HASH, + }], + &program.into(), + ) + .unwrap(); + + assert!(proof.is_valid_for(&output)); + assert_eq!(output.encrypted_private_post_states.len(), 1); + assert_eq!( + output.encrypted_private_post_states[0].view_tag, + EncryptedAccountData::compute_view_tag(&keys.npk(), &keys.vpk()), + ); +} + +#[test] +fn update_note_view_tag_is_the_supplied_value() { + let program = crate::test_methods::noop(); + let keys = test_private_account_keys_1(); + let identifier: u128 = 99; + let account_id = AccountId::for_regular_private_account(&keys.npk(), &keys.vpk(), identifier); + let account = Account { + program_owner: program.id(), + balance: 1, + ..Account::default() + }; + let commitment = Commitment::new(&account_id, &account); + let mut commitment_set = CommitmentSet::with_capacity(1); + commitment_set.extend(std::slice::from_ref(&commitment)); + let sender = AccountWithMetadata::new(account, true, account_id); + + // A tag deliberately different from the address-derived one, so a passthrough is + // distinguishable from re-derivation. + let fed_tag = EncryptedAccountData::compute_view_tag(&keys.npk(), &keys.vpk()).wrapping_add(1); + + let (output, proof) = execute_and_prove( + vec![sender], + Program::serialize_instruction(()).unwrap(), + vec![InputAccountIdentity::PrivateAuthorizedUpdate { + vpk: keys.vpk(), + random_seed: [0; 32], + view_tag: fed_tag, + nsk: keys.nsk, + membership_proof: commitment_set.get_proof_for(&commitment).unwrap(), + identifier, + }], + &program.into(), + ) + .unwrap(); + + assert!(proof.is_valid_for(&output)); + assert_eq!(output.encrypted_private_post_states.len(), 1); + assert_eq!(output.encrypted_private_post_states[0].view_tag, fed_tag); +} + #[test] fn circuit_fails_when_chained_validity_windows_have_empty_intersection() { let account_keys = test_private_account_keys_1(); @@ -574,6 +645,7 @@ fn private_authorized_update_encrypts_regular_kind_with_identifier() { vec![InputAccountIdentity::PrivateAuthorizedUpdate { vpk: keys.vpk(), random_seed: [0; 32], + view_tag: 0, nsk: keys.nsk, membership_proof: commitment_set.get_proof_for(&commitment).unwrap(), identifier, @@ -630,6 +702,7 @@ fn private_pda_update_encrypts_pda_kind_with_identifier() { InputAccountIdentity::PrivatePdaUpdate { vpk: keys.vpk(), random_seed: [0; 32], + view_tag: 0, nsk: keys.nsk, membership_proof: commitment_set.get_proof_for(&pda_commitment).unwrap(), identifier, @@ -708,6 +781,7 @@ fn private_pda_update_identifier_mismatch_fails() { InputAccountIdentity::PrivatePdaUpdate { vpk: keys.vpk(), random_seed: [0; 32], + view_tag: 0, nsk: keys.nsk, membership_proof: commitment_set.get_proof_for(&pda_commitment).unwrap(), identifier: 99, diff --git a/lee/state_machine/src/state/mod.rs b/lee/state_machine/src/state/mod.rs index 94d919ca..6a59cadb 100644 --- a/lee/state_machine/src/state/mod.rs +++ b/lee/state_machine/src/state/mod.rs @@ -20,8 +20,8 @@ use crate::{ pub const MAX_NUMBER_CHAINED_CALLS: usize = 10; -#[derive(Clone, BorshSerialize, BorshDeserialize)] -#[cfg_attr(test, derive(Debug, PartialEq, Eq))] +#[derive(Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +#[cfg_attr(test, derive(Debug))] pub struct CommitmentSet { merkle_tree: MerkleTree, commitments: HashMap, @@ -67,8 +67,8 @@ impl CommitmentSet { } } -#[cfg_attr(test, derive(Debug, PartialEq, Eq))] -#[derive(Clone)] +#[cfg_attr(test, derive(Debug))] +#[derive(Clone, PartialEq, Eq)] struct NullifierSet(BTreeSet); impl NullifierSet { @@ -109,8 +109,8 @@ impl BorshDeserialize for NullifierSet { } } -#[derive(Clone, BorshSerialize, BorshDeserialize)] -#[cfg_attr(test, derive(Debug, PartialEq, Eq))] +#[derive(Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +#[cfg_attr(test, derive(Debug))] pub struct V03State { public_state: HashMap, private_state: (CommitmentSet, NullifierSet), diff --git a/lee/state_machine/src/state/tests/changer_claimer.rs b/lee/state_machine/src/state/tests/changer_claimer.rs index 9a581c63..f6bb4932 100644 --- a/lee/state_machine/src/state/tests/changer_claimer.rs +++ b/lee/state_machine/src/state/tests/changer_claimer.rs @@ -74,6 +74,7 @@ fn private_changer_claimer_no_data_change_no_claim_succeeds() { vec![InputAccountIdentity::PrivateAuthorizedUpdate { vpk: sender_keys.vpk(), random_seed: [0; 32], + view_tag: 0, nsk: sender_keys.nsk, membership_proof: (0, vec![]), identifier: 0, @@ -104,6 +105,7 @@ fn private_changer_claimer_data_change_no_claim_fails() { vec![InputAccountIdentity::PrivateAuthorizedUpdate { vpk: sender_keys.vpk(), random_seed: [0; 32], + view_tag: 0, nsk: sender_keys.nsk, membership_proof: (0, vec![]), identifier: 0, diff --git a/lee/state_machine/src/state/tests/circuit.rs b/lee/state_machine/src/state/tests/circuit.rs index c13a7370..0b234b30 100644 --- a/lee/state_machine/src/state/tests/circuit.rs +++ b/lee/state_machine/src/state/tests/circuit.rs @@ -64,6 +64,7 @@ fn circuit_fails_if_invalid_auth_keys_are_provided() { InputAccountIdentity::PrivateAuthorizedUpdate { vpk: sender_keys.vpk(), random_seed: [0; 32], + view_tag: 0, nsk: recipient_keys.nsk, membership_proof: (0, vec![]), identifier: 0, @@ -113,6 +114,7 @@ fn circuit_should_fail_if_new_private_account_with_non_default_balance_is_provid InputAccountIdentity::PrivateAuthorizedUpdate { vpk: sender_keys.vpk(), random_seed: [0; 32], + view_tag: 0, nsk: sender_keys.nsk, membership_proof: (0, vec![]), identifier: 0, @@ -162,6 +164,7 @@ fn circuit_should_fail_if_new_private_account_with_non_default_program_owner_is_ InputAccountIdentity::PrivateAuthorizedUpdate { vpk: sender_keys.vpk(), random_seed: [0; 32], + view_tag: 0, nsk: sender_keys.nsk, membership_proof: (0, vec![]), identifier: 0, @@ -211,6 +214,7 @@ fn circuit_should_fail_if_new_private_account_with_non_default_data_is_provided( InputAccountIdentity::PrivateAuthorizedUpdate { vpk: sender_keys.vpk(), random_seed: [0; 32], + view_tag: 0, nsk: sender_keys.nsk, membership_proof: (0, vec![]), identifier: 0, @@ -260,6 +264,7 @@ fn circuit_should_fail_if_new_private_account_with_non_default_nonce_is_provided InputAccountIdentity::PrivateAuthorizedUpdate { vpk: sender_keys.vpk(), random_seed: [0; 32], + view_tag: 0, nsk: sender_keys.nsk, membership_proof: (0, vec![]), identifier: 0, @@ -307,6 +312,7 @@ fn circuit_should_fail_if_new_private_account_is_provided_with_default_values_bu InputAccountIdentity::PrivateAuthorizedUpdate { vpk: sender_keys.vpk(), random_seed: [0; 32], + view_tag: 0, nsk: sender_keys.nsk, membership_proof: (0, vec![]), identifier: 0, @@ -694,6 +700,7 @@ fn circuit_should_fail_if_there_are_repeated_ids() { InputAccountIdentity::PrivateAuthorizedUpdate { vpk: sender_keys.vpk(), random_seed: [0; 32], + view_tag: 0, nsk: sender_keys.nsk, membership_proof: (1, vec![]), identifier: 0, @@ -701,6 +708,7 @@ fn circuit_should_fail_if_there_are_repeated_ids() { InputAccountIdentity::PrivateAuthorizedUpdate { vpk: sender_keys.vpk(), random_seed: [0; 32], + view_tag: 0, nsk: sender_keys.nsk, membership_proof: (1, vec![]), identifier: 0, @@ -1020,6 +1028,7 @@ fn two_private_pda_family_members_receive_and_spend() { InputAccountIdentity::PrivatePdaUpdate { vpk: alice_keys.vpk(), random_seed: [0; 32], + view_tag: 0, nsk: alice_keys.nsk, membership_proof: state .get_proof_for_commitment(&commitment_pda_0) @@ -1057,6 +1066,7 @@ fn two_private_pda_family_members_receive_and_spend() { InputAccountIdentity::PrivatePdaUpdate { vpk: alice_keys.vpk(), random_seed: [0; 32], + view_tag: 0, nsk: alice_keys.nsk, membership_proof: state .get_proof_for_commitment(&commitment_pda_1) @@ -1108,6 +1118,7 @@ fn two_private_pda_family_members_receive_and_spend() { InputAccountIdentity::PrivatePdaUpdate { vpk: alice_keys.vpk(), random_seed: [0; 32], + view_tag: 0, nsk: alice_keys.nsk, membership_proof: state .get_proof_for_commitment(&commitment_pda_1_after_spend) diff --git a/lee/state_machine/src/state/tests/claiming.rs b/lee/state_machine/src/state/tests/claiming.rs index d45f39bd..3bdc315c 100644 --- a/lee/state_machine/src/state/tests/claiming.rs +++ b/lee/state_machine/src/state/tests/claiming.rs @@ -328,6 +328,7 @@ fn authorized_public_account_claiming_succeeds_when_executed_privately() { InputAccountIdentity::PrivateAuthorizedUpdate { vpk: sender_keys.vpk(), random_seed: [0; 32], + view_tag: 0, nsk: sender_keys.nsk, membership_proof: state .get_proof_for_commitment(&sender_commitment) @@ -443,6 +444,7 @@ fn private_chained_call(number_of_calls: u32) { InputAccountIdentity::PrivateAuthorizedUpdate { vpk: from_keys.vpk(), random_seed: [0; 32], + view_tag: 0, nsk: from_keys.nsk, membership_proof: state .get_proof_for_commitment(&from_commitment) @@ -452,6 +454,7 @@ fn private_chained_call(number_of_calls: u32) { InputAccountIdentity::PrivateAuthorizedUpdate { vpk: to_keys.vpk(), random_seed: [0; 32], + view_tag: 0, nsk: to_keys.nsk, membership_proof: state .get_proof_for_commitment(&to_commitment) diff --git a/lee/state_machine/src/state/tests/mod.rs b/lee/state_machine/src/state/tests/mod.rs index 55a99ead..96e08dc2 100644 --- a/lee/state_machine/src/state/tests/mod.rs +++ b/lee/state_machine/src/state/tests/mod.rs @@ -330,6 +330,7 @@ fn private_balance_transfer_for_tests( InputAccountIdentity::PrivateAuthorizedUpdate { vpk: sender_keys.vpk(), random_seed: [0; 32], + view_tag: 0, nsk: sender_keys.nsk, membership_proof: state .get_proof_for_commitment(&sender_commitment) @@ -384,6 +385,7 @@ fn deshielded_balance_transfer_for_tests( InputAccountIdentity::PrivateAuthorizedUpdate { vpk: sender_keys.vpk(), random_seed: [0; 32], + view_tag: 0, nsk: sender_keys.nsk, membership_proof: state .get_proof_for_commitment(&sender_commitment) diff --git a/lee/state_machine/src/state/tests/privacy_preserving.rs b/lee/state_machine/src/state/tests/privacy_preserving.rs index 174aa1b3..bc51ce4f 100644 --- a/lee/state_machine/src/state/tests/privacy_preserving.rs +++ b/lee/state_machine/src/state/tests/privacy_preserving.rs @@ -524,6 +524,7 @@ fn malicious_authorization_changer_should_fail_in_privacy_preserving_circuit() { InputAccountIdentity::PrivateAuthorizedUpdate { vpk: recipient_keys.vpk(), random_seed: [0; 32], + view_tag: 0, nsk: recipient_keys.nsk, membership_proof: state .get_proof_for_commitment(&recipient_commitment) diff --git a/lee/state_machine/src/validated_state_diff/tests.rs b/lee/state_machine/src/validated_state_diff/tests.rs index c471676c..e8bb3f14 100644 --- a/lee/state_machine/src/validated_state_diff/tests.rs +++ b/lee/state_machine/src/validated_state_diff/tests.rs @@ -167,6 +167,7 @@ fn privacy_malicious_programs_cannot_drain_public_victim() { InputAccountIdentity::PrivateAuthorizedUpdate { vpk: attacker_keys.vpk(), random_seed: [0; 32], + view_tag: 0, nsk: attacker_keys.nsk, membership_proof, identifier: 0, @@ -327,6 +328,7 @@ fn privacy_malicious_programs_cannot_drain_private_victim() { InputAccountIdentity::PrivateAuthorizedUpdate { vpk: attacker_keys.vpk(), random_seed: [0; 32], + view_tag: 0, nsk: attacker_keys.nsk, membership_proof, identifier: 0, diff --git a/lez/chain_state/Cargo.toml b/lez/chain_state/Cargo.toml new file mode 100644 index 00000000..0a0610a5 --- /dev/null +++ b/lez/chain_state/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "chain_state" +version = "0.1.0" +edition = "2024" +license = { workspace = true } + +[lints] +workspace = true + +[dependencies] +common.workspace = true +lee.workspace = true +lee_core.workspace = true +logos-blockchain-core.workspace = true +logos-blockchain-zone-sdk.workspace = true + +anyhow.workspace = true +borsh.workspace = true +futures.workspace = true +log.workspace = true +serde.workspace = true +thiserror.workspace = true +tokio.workspace = true + +[dev-dependencies] +testnet_initial_state.workspace = true +serde_json.workspace = true diff --git a/lez/chain_state/src/apply.rs b/lez/chain_state/src/apply.rs new file mode 100644 index 00000000..05134b88 --- /dev/null +++ b/lez/chain_state/src/apply.rs @@ -0,0 +1,308 @@ +//! The single validate-then-apply entry point shared by the sequencer and the +//! indexer. Pure and storage-free: callers apply on a scratch clone of state and +//! commit only on `Ok`. + +use common::{ + HashType, + block::{Block, BlockMeta}, + transaction::{LeeTransaction, clock_invocation}, +}; +use lee::{GENESIS_BLOCK_ID, V03State}; + +use crate::ingest_error::BlockIngestError; + +/// The parent the next block must chain on. +// `l1_slot` will be added here when the `ChainState` anchor layer lands. +#[derive(Debug, Clone)] +pub struct Tip { + pub block_id: u64, + pub hash: HashType, +} + +impl From<&Block> for Tip { + fn from(block: &Block) -> Self { + Self { + block_id: block.header.block_id, + hash: block.header.hash, + } + } +} + +impl From for Tip { + fn from(meta: BlockMeta) -> Self { + Self { + block_id: meta.id, + hash: meta.hash, + } + } +} + +impl From<&Tip> for BlockMeta { + fn from(tip: &Tip) -> Self { + Self { + id: tip.block_id, + hash: tip.hash, + } + } +} + +/// Outcome of feeding a parsed L2 block to a validated tip. +pub enum AcceptOutcome { + /// Chained and applied; the tip advances. + Applied, + /// A duplicate re-delivery of an already-applied block. No state change. + AlreadyApplied, + /// Did not chain or failed to apply; the tip stays frozen. + Parked(BlockIngestError), + /// Chained but failed to apply, possibly transiently + /// ([`BlockIngestError::is_retryable`]); nothing recorded, tip and state + /// untouched. The caller retries and parks once it gives up. + /// + /// TODO: Only the indexer's `accept_block` emits this today; the sequencer's + /// `ChainState` parks on all failures without retrying (see `on_follow`). + RetryableFailure(BlockIngestError), +} + +/// Validates `block` against `tip`, then applies it to `state`. +/// +/// Mutates `state` in place, so callers pass a scratch clone and commit on `Ok`. +pub fn apply_block( + tip: Option<&Tip>, + block: &Block, + state: &mut V03State, +) -> Result<(), BlockIngestError> { + validate_against_tip(tip, block)?; + apply_block_to_state(block, state)?; + Ok(()) +} + +/// Checks that `block` is the valid continuation of `tip`: hash integrity, +/// then block-id continuity, then `prev_block_hash` linkage. A `None` tip +/// (cold state) expects the genesis block. +pub fn validate_against_tip(tip: Option<&Tip>, block: &Block) -> Result<(), BlockIngestError> { + let computed = block.recompute_hash(); + if computed != block.header.hash { + return Err(BlockIngestError::HashMismatch { + computed, + header: block.header.hash, + }); + } + + match tip { + None => { + if block.header.block_id != GENESIS_BLOCK_ID { + return Err(BlockIngestError::UnexpectedBlockId { + expected: GENESIS_BLOCK_ID, + got: block.header.block_id, + }); + } + } + Some(tip) => { + let expected = tip + .block_id + .checked_add(1) + .expect("block id should not overflow"); + if block.header.block_id != expected { + return Err(BlockIngestError::UnexpectedBlockId { + expected, + got: block.header.block_id, + }); + } + if block.header.prev_block_hash != tip.hash { + return Err(BlockIngestError::BrokenChainLink { + expected_prev: tip.hash, + got_prev: block.header.prev_block_hash, + }); + } + } + } + Ok(()) +} + +/// Applies a block's transactions to `state`, mapping every failure to a +/// [`BlockIngestError`] so the caller can park rather than crash. Operates in +/// place; the caller commits only on `Ok`. +pub fn apply_block_to_state(block: &Block, state: &mut V03State) -> Result<(), BlockIngestError> { + let (clock_tx, user_txs) = block + .body + .transactions + .split_last() + .ok_or(BlockIngestError::EmptyBlock)?; + + let LeeTransaction::Public(clock_tx) = clock_tx else { + return Err(BlockIngestError::InvalidClockTransaction); + }; + if *clock_tx != clock_invocation(block.header.timestamp) { + return Err(BlockIngestError::InvalidClockTransaction); + } + + let is_genesis = block.header.block_id == GENESIS_BLOCK_ID; + for (tx_index, transaction) in user_txs.iter().enumerate() { + let state_transition = |err: anyhow::Error| BlockIngestError::StateTransition { + tx_index: tx_index.try_into().expect("tx index fits in u64"), + reason: format!("{err:#}"), + }; + if is_genesis { + let LeeTransaction::Public(public_tx) = transaction else { + return Err(BlockIngestError::NonPublicGenesisTransaction); + }; + state + .transition_from_public_transaction( + public_tx, + block.header.block_id, + block.header.timestamp, + ) + .map_err(|err| state_transition(err.into()))?; + } else { + transaction + .clone() + .execute_on_state(state, block.header.block_id, block.header.timestamp) + .map_err(|err| state_transition(err.into()))?; + } + } + + state + .transition_from_public_transaction(clock_tx, block.header.block_id, block.header.timestamp) + .map_err(|err| BlockIngestError::StateTransition { + tx_index: user_txs.len().try_into().expect("tx index fits in u64"), + reason: format!("{:#}", anyhow::Error::from(err)), + })?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use common::{ + block::HashableBlockData, + test_utils::{ + create_transaction_native_token_transfer, produce_dummy_block, + produce_dummy_empty_transaction, sequencer_sign_key_for_testing, + }, + }; + use testnet_initial_state::{initial_pub_accounts_private_keys, initial_state}; + + use super::*; + + fn tip_of(block: &Block) -> Tip { + Tip::from(block) + } + + #[test] + fn genesis_applies_on_empty_tip() { + let mut state = initial_state(); + let genesis = produce_dummy_block(1, None, vec![]); + apply_block(None, &genesis, &mut state).expect("genesis applies"); + } + + #[test] + fn non_genesis_first_block_is_unexpected_id() { + let mut state = initial_state(); + let block = produce_dummy_block(2, None, vec![]); + let err = apply_block(None, &block, &mut state).expect_err("should reject"); + assert!(matches!( + err, + BlockIngestError::UnexpectedBlockId { + expected: 1, + got: 2 + } + )); + } + + #[test] + fn skip_ahead_block_is_unexpected_id() { + let mut state = initial_state(); + let genesis = produce_dummy_block(1, None, vec![]); + apply_block(None, &genesis, &mut state).expect("genesis applies"); + + // Tip is at 1; a block with id 3 skips ahead. + let bad = produce_dummy_block(3, Some(genesis.header.hash), vec![]); + let err = + apply_block(Some(&tip_of(&genesis)), &bad, &mut state).expect_err("should reject"); + assert!(matches!( + err, + BlockIngestError::UnexpectedBlockId { + expected: 2, + got: 3 + } + )); + } + + #[test] + fn broken_chain_link_detected() { + let mut state = initial_state(); + let genesis = produce_dummy_block(1, None, vec![]); + apply_block(None, &genesis, &mut state).expect("genesis applies"); + + // Correct id (2), wrong parent hash. + let block2 = produce_dummy_block(2, Some(HashType([9_u8; 32])), vec![]); + let err = + apply_block(Some(&tip_of(&genesis)), &block2, &mut state).expect_err("should reject"); + assert!(matches!(err, BlockIngestError::BrokenChainLink { .. })); + } + + #[test] + fn hash_mismatch_detected() { + let mut state = initial_state(); + let mut genesis = produce_dummy_block(1, None, vec![]); + // Tampering with the header invalidates the stored hash. + genesis.header.timestamp = 999; + let err = apply_block(None, &genesis, &mut state).expect_err("should reject"); + assert!(matches!(err, BlockIngestError::HashMismatch { .. })); + } + + #[test] + fn empty_block_rejected() { + let mut state = initial_state(); + // A block with no transactions at all (not even the mandatory clock tx). + let block = HashableBlockData { + block_id: 1, + prev_block_hash: HashType([0_u8; 32]), + timestamp: 0, + transactions: vec![], + } + .into_pending_block(&sequencer_sign_key_for_testing()); + let err = apply_block(None, &block, &mut state).expect_err("should reject"); + assert!(matches!(err, BlockIngestError::EmptyBlock)); + } + + #[test] + fn missing_clock_tail_is_invalid_clock() { + let mut state = initial_state(); + // Last tx is not the expected clock invocation for the timestamp. + let block = HashableBlockData { + block_id: 1, + prev_block_hash: HashType([0_u8; 32]), + timestamp: 50, + transactions: vec![produce_dummy_empty_transaction()], + } + .into_pending_block(&sequencer_sign_key_for_testing()); + let err = apply_block(None, &block, &mut state).expect_err("should reject"); + assert!(matches!(err, BlockIngestError::InvalidClockTransaction)); + } + + #[test] + fn applies_transfers_and_advances_state() { + let mut state = initial_state(); + let accounts = initial_pub_accounts_private_keys(); + let from = accounts[0].account_id; + let to = accounts[1].account_id; + let sign_key = accounts[0].pub_sign_key.clone(); + + // Genesis (block 1): clock-only. + let genesis = produce_dummy_block(1, None, vec![]); + apply_block(None, &genesis, &mut state).expect("genesis applies"); + let mut tip = tip_of(&genesis); + + // Blocks 2..=11: one native transfer of 10 each (nonces 0..=9). + for i in 0..10_u64 { + let tx = create_transaction_native_token_transfer(from, i.into(), to, 10, &sign_key); + let block = produce_dummy_block(i + 2, Some(tip.hash), vec![tx]); + apply_block(Some(&tip), &block, &mut state).expect("transfer applies"); + tip = tip_of(&block); + } + + assert_eq!(state.get_account_by_id(from).balance, 9900); + assert_eq!(state.get_account_by_id(to).balance, 20100); + } +} diff --git a/lez/chain_state/src/chain.rs b/lez/chain_state/src/chain.rs new file mode 100644 index 00000000..39e1efba --- /dev/null +++ b/lez/chain_state/src/chain.rs @@ -0,0 +1,1034 @@ +//! Two-tier chain state: a reorg-able `head` the sequencer builds on, plus an +//! irreversible `final` tier. + +use common::block::Block; +use lee::V03State; +use log::warn; +use logos_blockchain_core::mantle::ops::channel::MsgId; +use logos_blockchain_zone_sdk::Slot; + +use crate::{ + AcceptOutcome, BlockIngestError, StallReason, + apply::{Tip, apply_block}, +}; + +/// A head block plus the channel message that carried it. +pub struct HeadEntry { + pub this_msg: MsgId, + pub block: Block, +} + +/// The head tier (reorg-able, from `adopted`/`orphaned`) over the final tier +/// (irreversible, from `finalized`). +/// +/// `head_state` is given by `final_state` replayed through `head_blocks`. +/// +/// Only the final tier stalls: an invalid `adopted` block just freezes the +/// head tip and self-heals via reorg or finalization. +pub struct ChainState { + final_state: V03State, + final_tip: Option, + final_stall: Option, + + head_state: V03State, + head_blocks: Vec, +} + +impl ChainState { + /// Fresh state anchored at the genesis/initial state, no blocks applied. + #[must_use] + pub fn new(initial_state: V03State) -> Self { + Self::from_final(initial_state, None) + } + + /// State restored from a persisted final tier; head mirrors final. + #[must_use] + pub fn from_final(final_state: V03State, final_tip: Option) -> Self { + Self { + head_state: final_state.clone(), + final_state, + final_tip, + head_blocks: Vec::new(), + final_stall: None, + } + } + + /// State the sequencer builds its next block on. + #[must_use] + pub const fn head_state(&self) -> &V03State { + &self.head_state + } + + /// Mutable access to the head state. Bypasses the `head_blocks` invariant, so + /// it is meant for tests and low-level callers. + #[must_use] + pub const fn head_state_mut(&mut self) -> &mut V03State { + &mut self.head_state + } + + #[must_use] + pub const fn final_state(&self) -> &V03State { + &self.final_state + } + + /// Parent the next produced block must chain on. + #[must_use] + pub fn head_tip(&self) -> Option { + self.head_blocks + .last() + .map(|entry| Tip::from(&entry.block)) + .or_else(|| self.final_tip.clone()) + } + + #[must_use] + pub fn final_tip(&self) -> Option { + self.final_tip.clone() + } + + #[must_use] + pub const fn final_stall(&self) -> Option<&StallReason> { + self.final_stall.as_ref() + } + + /// Position of a head entry, matched by `MsgId` or block hash (restored + /// entries carry sentinel `MsgId`s; re-inscriptions arrive under fresh ones) + /// โ€” always at the same claimed height: a hash or `MsgId` collision with a + /// different `block_id` is malformed and must fall through to validation. + fn head_position_of(&self, this_msg: MsgId, block: &Block) -> Option { + self.head_blocks.iter().position(|entry| { + entry.block.header.block_id == block.header.block_id + && (entry.this_msg == this_msg || entry.block.header.hash == block.header.hash) + }) + } + + /// Applies an adopted head block. + /// + /// The adopted stream is authoritative: a competitor at a height + /// the head already holds reorgs the head back to that height with + /// no orphan event required. + /// + /// On failure the head stays unchanged and no stall is recorded. + pub fn apply_adopted(&mut self, this_msg: MsgId, block: &Block) -> AcceptOutcome { + if self.head_position_of(this_msg, block).is_some() { + return AcceptOutcome::AlreadyApplied; + } + + // If we receive a pre-final adoption, its an SDK fault; just log and ignore it + if let Some(final_tip) = &self.final_tip + && block.header.block_id <= final_tip.block_id + { + // The final tier is irreversible: a matching block here is a stale + // re-delivery, a conflicting one an SDK contract breach. + if block.header.block_id == final_tip.block_id && block.header.hash != final_tip.hash { + warn!( + "Ignoring adopted block {} with hash {} conflicting with the \ + finalized block ({}) at this height", + block.header.block_id, block.header.hash, final_tip.hash + ); + } + return AcceptOutcome::AlreadyApplied; + } + + // A tip extension applies on the current head state, a lower-or-equal + // id rebuilds the state at the competitor's parent instead. + // + // If adoptions are over the current head, `reorg_at` is None. + let reorg_at = self + .head_blocks + .iter() + .position(|entry| entry.block.header.block_id >= block.header.block_id); + let (mut scratch, tip) = match reorg_at { + // continue from the tip + None => (self.head_state.clone(), self.head_tip()), + // reorg upto `idx` + Some(idx) => self.replay_head_prefix(idx), + }; + + match apply_block(tip.as_ref(), block, &mut scratch) { + Ok(()) => { + // now that `apply_block` succeeded, actually reorg the head + if let Some(idx) = reorg_at { + self.head_blocks.truncate(idx); + } + self.head_state = scratch; + self.head_blocks.push(HeadEntry { + this_msg, + block: block.to_owned(), + }); + AcceptOutcome::Applied + } + Err(err) => AcceptOutcome::Parked(err), + } + } + + /// Applies a block we produced ourselves. + /// + /// Unlike [`Self::apply_adopted`] this never reorgs: our block is not on + /// the channel yet, so it may only *extend* the head. A head already at + /// (or past) this height means a peer's block won the race on the + /// channel โ€” ours is stale and the caller drops it. + pub fn apply_produced(&mut self, this_msg: MsgId, block: &Block) -> AcceptOutcome { + if self + .head_tip() + .is_some_and(|tip| block.header.block_id <= tip.block_id) + { + return AcceptOutcome::AlreadyApplied; + } + self.apply_adopted(this_msg, block) + } + + /// Reverts an orphaned head block and everything after it, then re-derives head. + pub fn revert_orphan(&mut self, this_msg: MsgId, block: &Block) { + if let Some(idx) = self.head_position_of(this_msg, block) { + self.head_blocks.truncate(idx); + self.rederive_head(); + } + } + + /// One channel update: revert every `orphaned` (one truncate + re-derive), + /// then apply every `adopted` in order. Outcomes align with `adopted`. + pub fn apply_channel_update( + &mut self, + orphaned: &[(MsgId, Block)], + adopted: &[(MsgId, Block)], + ) -> Vec { + let earliest = orphaned + .iter() + .filter_map(|(msg, block)| self.head_position_of(*msg, block)) + .min(); + if let Some(idx) = earliest { + self.head_blocks.truncate(idx); + self.rederive_head(); + } + adopted + .iter() + .map(|(msg, block)| self.apply_adopted(*msg, block)) + .collect() + } + + /// Rebuilds one head entry from a persisted block, applying it in place (the + /// caller treats `Err` as fatal). + /// + /// The entry gets a hash-derived sentinel `MsgId` (the real one is not + /// persisted โ€” that would need a sidecar `block_id -> MsgId` cell); later + /// orphan/finalize events correlate by block hash. + pub fn restore_head_block(&mut self, block: Block) -> Result<(), BlockIngestError> { + apply_block(self.head_tip().as_ref(), &block, &mut self.head_state)?; + let this_msg = MsgId::from(block.header.hash.0); + self.head_blocks.push(HeadEntry { this_msg, block }); + Ok(()) + } + + /// A finalized inscription. In steady state the block is already in head and is + /// moved into `final`; on backfill (not in head) it is applied directly and may + /// set `final_stall`. + pub fn apply_finalized( + &mut self, + this_msg: MsgId, + block: &Block, + l1_slot: Slot, + ) -> AcceptOutcome { + // Match by `MsgId` or block hash (re-inscriptions, restored entries). + if let Some(idx) = self.head_position_of(this_msg, block) { + self.finalize_through(idx); + return AcceptOutcome::Applied; + } + + // Finality is prefix-monotone: a finalized block chaining on an + // unfinalized head entry finalizes that prefix too. + if let Some(idx) = self + .head_blocks + .iter() + .position(|entry| entry.block.header.hash == block.header.prev_block_hash) + { + self.finalize_through(idx); + } + self.apply_finalized_direct(block, l1_slot) + } + + /// Moves `head_blocks[0..=idx]` into the final tier (already validated in head). + fn finalize_through(&mut self, idx: usize) { + let finalized: Vec = self.head_blocks.drain(0..=idx).collect(); + for entry in finalized { + apply_block(self.final_tip.as_ref(), &entry.block, &mut self.final_state) + .expect("validated head block must apply to the final tier"); + self.final_tip = Some(Tip::from(&entry.block)); + } + self.final_stall = None; + } + + /// Applies a finalized block straight to the final tier. On success the + /// finalized chain is authoritative, so head rebases onto it. + fn apply_finalized_direct(&mut self, block: &Block, l1_slot: Slot) -> AcceptOutcome { + // A finalized block at or below the final tip is a re-delivery: + // idempotent. A *different* block at the tip height falls through + // to validation and parks. + if let Some(tip) = &self.final_tip + && (block.header.block_id < tip.block_id + || (block.header.block_id == tip.block_id && block.header.hash == tip.hash)) + { + return AcceptOutcome::AlreadyApplied; + } + + let mut scratch = self.final_state.clone(); + match apply_block(self.final_tip.as_ref(), block, &mut scratch) { + Ok(()) => { + self.final_state = scratch; + self.final_tip = Some(Tip::from(block)); + self.final_stall = None; + // Any head suffix dropped here was already reverted as + // `orphaned` earlier in the same channel update (the sdk + // orders orphans before their finalized replacement), so its + // txs are back in the caller's mempool. + self.head_blocks.clear(); + self.head_state = self.final_state.clone(); + AcceptOutcome::Applied + } + Err(err) => { + self.record_final_stall(block, l1_slot, err.clone()); + AcceptOutcome::Parked(err) + } + } + } + + /// Rebuilds `head_state` from the final tier plus the current `head_blocks`. + fn rederive_head(&mut self) { + self.head_state = self.replay_head_prefix(self.head_blocks.len()).0; + } + + /// State and tip after replaying `head_blocks[..count]` on the final tier. + fn replay_head_prefix(&self, count: usize) -> (V03State, Option) { + let mut state = self.final_state.clone(); + let mut tip = self.final_tip.clone(); + for entry in &self.head_blocks[..count] { + apply_block(tip.as_ref(), &entry.block, &mut state) + .expect("validated head blocks must replay"); + tip = Some(Tip::from(&entry.block)); + } + (state, tip) + } + + /// First stall is stored verbatim; later ones only bump `orphans_since`. + fn record_final_stall(&mut self, block: &Block, l1_slot: Slot, error: BlockIngestError) { + self.final_stall = Some(self.final_stall.take().map_or_else( + || StallReason::new(Some(&block.header), l1_slot, error), + StallReason::escalate, + )); + } +} + +#[cfg(test)] +mod tests { + use common::{ + HashType, + test_utils::{create_transaction_native_token_transfer, produce_dummy_block}, + }; + use testnet_initial_state::{initial_pub_accounts_private_keys, initial_state}; + + use super::*; + + fn msg(n: u8) -> MsgId { + MsgId::from([n; 32]) + } + + fn slot(n: u64) -> Slot { + Slot::from(n) + } + + /// `head_state` equals `final_state` replayed through `head_blocks`. + fn assert_head_matches_replay(chain: &ChainState) { + let mut state = chain.final_state.clone(); + let mut tip = chain.final_tip.clone(); + for entry in &chain.head_blocks { + apply_block(tip.as_ref(), &entry.block, &mut state).expect("head blocks must replay"); + tip = Some(Tip::from(&entry.block)); + } + assert_eq!( + borsh::to_vec(&state).expect("state serializes"), + borsh::to_vec(chain.head_state()).expect("state serializes"), + "head_state must equal final_state replayed through head_blocks" + ); + } + + #[test] + fn adopted_blocks_advance_head() { + let mut chain = ChainState::new(initial_state()); + + let genesis = produce_dummy_block(1, None, vec![]); + assert!(matches!( + chain.apply_adopted(msg(1), &genesis), + AcceptOutcome::Applied + )); + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + assert!(matches!( + chain.apply_adopted(msg(2), &block2), + AcceptOutcome::Applied + )); + + assert_eq!(chain.head_tip().expect("head tip").block_id, 2); + // Nothing finalized yet. + assert!(chain.final_tip().is_none()); + } + + #[test] + fn adopted_bad_block_freezes_head_without_stall() { + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + chain.apply_adopted(msg(1), &genesis); + + // Skips ahead (id 3 while head tip is 1). + let bad = produce_dummy_block(3, Some(genesis.header.hash), vec![]); + assert!(matches!( + chain.apply_adopted(msg(3), &bad), + AcceptOutcome::Parked(BlockIngestError::UnexpectedBlockId { + expected: 2, + got: 3 + }) + )); + assert_eq!(chain.head_tip().expect("head tip").block_id, 1); + assert!( + chain.final_stall().is_none(), + "head freeze records no stall" + ); + } + + #[test] + fn adopted_is_idempotent() { + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + chain.apply_adopted(msg(1), &genesis); + + assert!(matches!( + chain.apply_adopted(msg(1), &genesis), + AcceptOutcome::AlreadyApplied + )); + assert_eq!(chain.head_tip().expect("head tip").block_id, 1); + } + + #[test] + fn orphan_reverts_head() { + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![]); + chain.apply_adopted(msg(1), &genesis); + chain.apply_adopted(msg(2), &block2); + chain.apply_adopted(msg(3), &block3); + + chain.revert_orphan(msg(3), &block3); + assert_eq!(chain.head_tip().expect("head tip").block_id, 2); + + // A competing block 3 now applies cleanly on block 2. + let block3_prime = produce_dummy_block(3, Some(block2.header.hash), vec![]); + assert!(matches!( + chain.apply_adopted(msg(13), &block3_prime), + AcceptOutcome::Applied + )); + assert_eq!(chain.head_tip().expect("head tip").block_id, 3); + } + + #[test] + fn channel_update_reverts_then_applies() { + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![]); + chain.apply_adopted(msg(1), &genesis); + chain.apply_adopted(msg(2), &block2); + chain.apply_adopted(msg(3), &block3); + + let block3_prime = produce_dummy_block(3, Some(block2.header.hash), vec![]); + let outcomes = chain.apply_channel_update(&[(msg(3), block3)], &[(msg(13), block3_prime)]); + assert!(matches!(outcomes.as_slice(), [AcceptOutcome::Applied])); + assert_eq!(chain.head_tip().expect("head tip").block_id, 3); + } + + #[test] + fn finalize_moves_head_into_final() { + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![]); + chain.apply_adopted(msg(1), &genesis); + chain.apply_adopted(msg(2), &block2); + chain.apply_adopted(msg(3), &block3); + + // Finalize through block 2. + assert!(matches!( + chain.apply_finalized(msg(2), &block2, slot(100)), + AcceptOutcome::Applied + )); + assert_eq!(chain.final_tip().expect("final tip").block_id, 2); + // Head tip unchanged; head still ends at 3. + assert_eq!(chain.head_tip().expect("head tip").block_id, 3); + } + + #[test] + fn backfill_applies_directly_to_final() { + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + assert!(matches!( + chain.apply_finalized(msg(1), &genesis, slot(10)), + AcceptOutcome::Applied + )); + assert_eq!(chain.final_tip().expect("final tip").block_id, 1); + // Head mirrors final during backfill. + assert_eq!(chain.head_tip().expect("head tip").block_id, 1); + } + + #[test] + fn invalid_finalized_block_sets_final_stall() { + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + chain.apply_finalized(msg(1), &genesis, slot(10)); + + // Skip-ahead finalized block, not in head: parks the final tier. + let bad = produce_dummy_block(3, Some(genesis.header.hash), vec![]); + assert!(matches!( + chain.apply_finalized(msg(3), &bad, slot(20)), + AcceptOutcome::Parked(_) + )); + let stall = chain.final_stall().expect("final stall recorded"); + assert_eq!(stall.block_id, Some(3)); + } + + #[test] + fn orphaning_a_suffix_rederives_head_state() { + let accounts = initial_pub_accounts_private_keys(); + let from = accounts[0].account_id; + let to = accounts[1].account_id; + let sign_key = accounts[0].pub_sign_key.clone(); + + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + chain.apply_adopted(msg(1), &genesis); + + let tx2 = create_transaction_native_token_transfer(from, 0, to, 10, &sign_key); + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![tx2]); + chain.apply_adopted(msg(2), &block2); + let tx3 = create_transaction_native_token_transfer(from, 1, to, 10, &sign_key); + let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![tx3]); + chain.apply_adopted(msg(3), &block3); + let tx4 = create_transaction_native_token_transfer(from, 2, to, 10, &sign_key); + let block4 = produce_dummy_block(4, Some(block3.header.hash), vec![tx4]); + chain.apply_adopted(msg(4), &block4); + + // Orphaning block 3 drops the whole suffix (3 and 4). + chain.revert_orphan(msg(3), &block3); + + assert_eq!(chain.head_tip().expect("head tip").block_id, 2); + assert_eq!(chain.head_state().get_account_by_id(from).balance, 9990); + assert_eq!(chain.head_state().get_account_by_id(to).balance, 20010); + assert_head_matches_replay(&chain); + } + + #[test] + fn channel_update_replaces_multi_block_suffix() { + let accounts = initial_pub_accounts_private_keys(); + let from = accounts[0].account_id; + let to = accounts[1].account_id; + let sign_key = accounts[0].pub_sign_key.clone(); + + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + chain.apply_adopted(msg(1), &genesis); + + let tx2 = create_transaction_native_token_transfer(from, 0, to, 10, &sign_key); + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![tx2]); + chain.apply_adopted(msg(2), &block2); + let tx3 = create_transaction_native_token_transfer(from, 1, to, 10, &sign_key); + let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![tx3]); + chain.apply_adopted(msg(3), &block3); + let tx4 = create_transaction_native_token_transfer(from, 2, to, 10, &sign_key); + let block4 = produce_dummy_block(4, Some(block3.header.hash), vec![tx4]); + chain.apply_adopted(msg(4), &block4); + + // A competing branch replaces blocks 3 and 4; orphans arrive unordered. + let tx3_prime = create_transaction_native_token_transfer(from, 1, to, 20, &sign_key); + let block3_prime = produce_dummy_block(3, Some(block2.header.hash), vec![tx3_prime]); + let tx4_prime = create_transaction_native_token_transfer(from, 2, to, 30, &sign_key); + let block4_prime = produce_dummy_block(4, Some(block3_prime.header.hash), vec![tx4_prime]); + + let outcomes = chain.apply_channel_update( + &[(msg(4), block4), (msg(3), block3)], + &[(msg(13), block3_prime), (msg(14), block4_prime)], + ); + + assert!(matches!( + outcomes.as_slice(), + [AcceptOutcome::Applied, AcceptOutcome::Applied] + )); + assert_eq!(chain.head_tip().expect("head tip").block_id, 4); + assert_eq!(chain.head_state().get_account_by_id(from).balance, 9940); + assert_eq!(chain.head_state().get_account_by_id(to).balance, 20060); + assert_head_matches_replay(&chain); + } + + #[test] + fn adopted_only_channel_update_replaces_suffix() { + let accounts = initial_pub_accounts_private_keys(); + let from = accounts[0].account_id; + let to = accounts[1].account_id; + let sign_key = accounts[0].pub_sign_key.clone(); + + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + chain.apply_adopted(msg(1), &genesis); + let tx2 = create_transaction_native_token_transfer(from, 0, to, 10, &sign_key); + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![tx2]); + chain.apply_adopted(msg(2), &block2); + let tx3 = create_transaction_native_token_transfer(from, 1, to, 10, &sign_key); + let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![tx3]); + chain.apply_adopted(msg(3), &block3); + + // The replacement branch arrives with no orphan events: the adopted + // list alone reorgs the head. + let tx2_prime = create_transaction_native_token_transfer(from, 0, to, 20, &sign_key); + let block2_prime = produce_dummy_block(2, Some(genesis.header.hash), vec![tx2_prime]); + let tx3_prime = create_transaction_native_token_transfer(from, 1, to, 30, &sign_key); + let block3_prime = produce_dummy_block(3, Some(block2_prime.header.hash), vec![tx3_prime]); + + let outcomes = + chain.apply_channel_update(&[], &[(msg(12), block2_prime), (msg(13), block3_prime)]); + + assert!(matches!( + outcomes.as_slice(), + [AcceptOutcome::Applied, AcceptOutcome::Applied] + )); + assert_eq!(chain.head_tip().expect("head tip").block_id, 3); + assert_eq!(chain.head_state().get_account_by_id(to).balance, 20050); + assert_head_matches_replay(&chain); + } + + #[test] + fn channel_update_ignores_unknown_orphan() { + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + chain.apply_adopted(msg(1), &genesis); + chain.apply_adopted(msg(2), &block2); + + let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![]); + let unknown = produce_dummy_block(9, Some(HashType([7; 32])), vec![]); + let outcomes = chain.apply_channel_update(&[(msg(99), unknown)], &[(msg(3), block3)]); + + assert!(matches!(outcomes.as_slice(), [AcceptOutcome::Applied])); + assert_eq!(chain.head_tip().expect("head tip").block_id, 3); + assert_head_matches_replay(&chain); + } + + #[test] + fn adopted_competitor_reorgs_head_without_orphan_event() { + let accounts = initial_pub_accounts_private_keys(); + let from = accounts[0].account_id; + let to = accounts[1].account_id; + let sign_key = accounts[0].pub_sign_key.clone(); + + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + chain.apply_adopted(msg(1), &genesis); + let tx2 = create_transaction_native_token_transfer(from, 0, to, 10, &sign_key); + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![tx2]); + chain.apply_adopted(msg(2), &block2); + let tx3 = create_transaction_native_token_transfer(from, 1, to, 10, &sign_key); + let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![tx3]); + chain.apply_adopted(msg(3), &block3); + + // A valid competitor at height 2, no orphan events: the head reorgs + // back onto it, dropping the old 2..=3 suffix and its transfers. + let block2_prime = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + assert!(matches!( + chain.apply_adopted(msg(12), &block2_prime), + AcceptOutcome::Applied + )); + let tip = chain.head_tip().expect("head tip"); + assert_eq!(tip.block_id, 2); + assert_eq!(tip.hash, block2_prime.header.hash); + assert_eq!(chain.head_state().get_account_by_id(to).balance, 20000); + assert_head_matches_replay(&chain); + } + + #[test] + fn produced_block_losing_a_race_does_not_reorg_the_head() { + let accounts = initial_pub_accounts_private_keys(); + let from = accounts[0].account_id; + let to = accounts[1].account_id; + let sign_key = accounts[0].pub_sign_key.clone(); + + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + chain.apply_adopted(msg(1), &genesis); + + // A peer's block wins height 2 on the channel. + let peer = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + chain.apply_adopted(msg(2), &peer); + + // Our own block at that height is not on the channel, so โ€” unlike an + // adopted competitor โ€” it must not reorg the head onto itself. + let tx = create_transaction_native_token_transfer(from, 0, to, 10, &sign_key); + let ours = produce_dummy_block(2, Some(genesis.header.hash), vec![tx]); + assert!(matches!( + chain.apply_produced(msg(12), &ours), + AcceptOutcome::AlreadyApplied + )); + assert_eq!(chain.head_tip().expect("head tip").hash, peer.header.hash); + assert_eq!(chain.head_state().get_account_by_id(to).balance, 20000); + assert_head_matches_replay(&chain); + } + + #[test] + fn produced_block_extending_the_head_applies() { + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + chain.apply_adopted(msg(1), &genesis); + + let ours = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + assert!(matches!( + chain.apply_produced(msg(2), &ours), + AcceptOutcome::Applied + )); + assert_eq!(chain.head_tip().expect("head tip").hash, ours.header.hash); + assert_head_matches_replay(&chain); + } + + #[test] + fn invalid_adopted_competitor_leaves_head_intact() { + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![]); + chain.apply_adopted(msg(1), &genesis); + chain.apply_adopted(msg(2), &block2); + chain.apply_adopted(msg(3), &block3); + + // A competitor at height 2 with a bogus parent parks; the truncation + // is not committed, so the 2..=3 suffix survives. + let bad = produce_dummy_block(2, Some(HashType([9; 32])), vec![]); + assert!(matches!( + chain.apply_adopted(msg(12), &bad), + AcceptOutcome::Parked(BlockIngestError::BrokenChainLink { .. }) + )); + assert_eq!(chain.head_tip().expect("head tip").block_id, 3); + assert_head_matches_replay(&chain); + } + + #[test] + fn adopted_conflicting_with_final_tip_is_ignored() { + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + chain.apply_finalized(msg(1), &genesis, slot(10)); + chain.apply_finalized(msg(2), &block2, slot(20)); + + // Finalized is irreversible: an adopted competitor at (or below) the + // final tip is ignored, not reorged onto. + let block2_prime = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + assert!(matches!( + chain.apply_adopted(msg(12), &block2_prime), + AcceptOutcome::AlreadyApplied + )); + assert_eq!( + chain.final_tip().expect("final tip").hash, + block2.header.hash + ); + assert_eq!(chain.head_tip().expect("head tip").hash, block2.header.hash); + assert_head_matches_replay(&chain); + } + + #[test] + fn restore_head_block_rebuilds_head_and_correlates_by_hash() { + let accounts = initial_pub_accounts_private_keys(); + let from = accounts[0].account_id; + let to = accounts[1].account_id; + let sign_key = accounts[0].pub_sign_key.clone(); + + // Restart shape: final tier from a persisted snapshot, head rebuilt from + // stored blocks under hash-derived sentinel MsgIds. + let mut state = initial_state(); + let genesis = produce_dummy_block(1, None, vec![]); + apply_block(None, &genesis, &mut state).expect("genesis applies"); + let mut chain = ChainState::from_final(state, Some(Tip::from(&genesis))); + + let tx2 = create_transaction_native_token_transfer(from, 0, to, 10, &sign_key); + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![tx2]); + let tx3 = create_transaction_native_token_transfer(from, 1, to, 10, &sign_key); + let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![tx3]); + for block in [&block2, &block3] { + chain + .restore_head_block(block.clone()) + .expect("stored blocks must replay"); + } + assert_eq!(chain.head_tip().expect("head tip").block_id, 3); + assert_head_matches_replay(&chain); + + // The L1 orphans restored block 3 under its real (unknown-to-us) MsgId: + // correlated by hash, the revert works and a competitor applies. + chain.revert_orphan(msg(33), &block3); + assert_eq!(chain.head_tip().expect("head tip").block_id, 2); + + let block3_prime = produce_dummy_block(3, Some(block2.header.hash), vec![]); + assert!(matches!( + chain.apply_adopted(msg(13), &block3_prime), + AcceptOutcome::Applied + )); + assert_eq!(chain.head_state().get_account_by_id(to).balance, 20010); + assert_head_matches_replay(&chain); + } + + #[test] + fn restore_head_block_rejects_non_chaining_block() { + let mut chain = ChainState::new(initial_state()); + let skipped = produce_dummy_block(3, Some(HashType([9; 32])), vec![]); + assert!(chain.restore_head_block(skipped).is_err()); + } + + #[test] + fn finalized_hash_alias_with_wrong_id_is_not_absorbed() { + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + chain.apply_adopted(msg(1), &genesis); + + // A malformed message reusing genesis's hash under a different claimed + // id must not match the held entry as a re-delivery; it falls through + // to validation and parks. + let mut alias = genesis.clone(); + alias.header.block_id = 6; + assert!(matches!( + chain.apply_finalized(msg(66), &alias, slot(10)), + AcceptOutcome::Parked(_) + )); + assert_eq!(chain.head_tip().expect("head tip").block_id, 1); + assert!(chain.final_tip().is_none()); + assert_head_matches_replay(&chain); + } + + #[test] + fn finalized_reinscription_matches_by_block_hash() { + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![]); + chain.apply_adopted(msg(1), &genesis); + chain.apply_adopted(msg(2), &block2); + chain.apply_adopted(msg(3), &block3); + + // Block 2 finalizes re-inscribed under a fresh MsgId: matched by hash, + // finalized through, and the head above it survives. + assert!(matches!( + chain.apply_finalized(msg(42), &block2, slot(5)), + AcceptOutcome::Applied + )); + assert_eq!(chain.final_tip().expect("final tip").block_id, 2); + assert_eq!(chain.head_tip().expect("head tip").block_id, 3); + assert!(chain.final_stall().is_none()); + assert_head_matches_replay(&chain); + } + + #[test] + fn finalize_through_preserves_head_state_and_advances_final_state() { + let accounts = initial_pub_accounts_private_keys(); + let from = accounts[0].account_id; + let to = accounts[1].account_id; + let sign_key = accounts[0].pub_sign_key.clone(); + + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + chain.apply_adopted(msg(1), &genesis); + let tx2 = create_transaction_native_token_transfer(from, 0, to, 10, &sign_key); + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![tx2]); + chain.apply_adopted(msg(2), &block2); + let tx3 = create_transaction_native_token_transfer(from, 1, to, 10, &sign_key); + let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![tx3]); + chain.apply_adopted(msg(3), &block3); + + chain.apply_finalized(msg(2), &block2, slot(10)); + + // Head still reflects both transfers + assert_eq!(chain.head_state().get_account_by_id(to).balance, 20020); + // ...while final reflects only the finalized prefix. + assert_eq!(chain.final_state().get_account_by_id(to).balance, 20010); + assert_head_matches_replay(&chain); + } + + #[test] + fn head_self_heals_with_valid_competitor_after_park() { + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + chain.apply_adopted(msg(1), &genesis); + + // Correct id, wrong parent: parked, head frozen at 1, no stall. + let bad = produce_dummy_block(2, Some(HashType([9; 32])), vec![]); + assert!(matches!( + chain.apply_adopted(msg(2), &bad), + AcceptOutcome::Parked(BlockIngestError::BrokenChainLink { .. }) + )); + assert_eq!(chain.head_tip().expect("head tip").block_id, 1); + assert!(chain.final_stall().is_none()); + + // A valid competitor at the same height applies without any reorg event. + let good = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + assert!(matches!( + chain.apply_adopted(msg(12), &good), + AcceptOutcome::Applied + )); + assert_eq!(chain.head_tip().expect("head tip").block_id, 2); + assert_head_matches_replay(&chain); + } + + #[test] + fn repeated_invalid_finalized_bumps_orphans_since() { + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + chain.apply_finalized(msg(1), &genesis, slot(10)); + + let bad3 = produce_dummy_block(3, Some(genesis.header.hash), vec![]); + chain.apply_finalized(msg(3), &bad3, slot(20)); + let bad5 = produce_dummy_block(5, Some(bad3.header.hash), vec![]); + assert!(matches!( + chain.apply_finalized(msg(5), &bad5, slot(30)), + AcceptOutcome::Parked(_) + )); + + let stall = chain.final_stall().expect("final stall recorded"); + assert_eq!(stall.block_id, Some(3), "first stall reason is preserved"); + assert_eq!(stall.orphans_since, 1); + } + + #[test] + fn valid_finalized_successor_clears_final_stall() { + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + chain.apply_finalized(msg(1), &genesis, slot(10)); + + let bad = produce_dummy_block(3, Some(genesis.header.hash), vec![]); + chain.apply_finalized(msg(3), &bad, slot(20)); + assert!(chain.final_stall().is_some()); + + // The valid successor of the frozen final tip finalizes: stall clears. + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + assert!(matches!( + chain.apply_finalized(msg(2), &block2, slot(30)), + AcceptOutcome::Applied + )); + assert!(chain.final_stall().is_none()); + assert_eq!(chain.final_tip().expect("final tip").block_id, 2); + assert_head_matches_replay(&chain); + } + + #[test] + fn finalized_successor_of_head_entry_finalizes_the_prefix() { + // Head holds unfinalized blocks 1..=2 (e.g. restored after a restart); + // a peer block 3 we never saw adopted arrives finalized. Its ancestry + // finalizes our prefix implicitly, then 3 applies to final directly. + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + chain.apply_adopted(msg(1), &genesis); + chain.apply_adopted(msg(2), &block2); + assert!(chain.final_tip().is_none()); + + let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![]); + assert!(matches!( + chain.apply_finalized(msg(3), &block3, slot(10)), + AcceptOutcome::Applied + )); + assert_eq!(chain.final_tip().expect("final tip").block_id, 3); + assert_eq!(chain.head_tip().expect("head tip").block_id, 3); + assert!(chain.final_stall().is_none()); + assert_head_matches_replay(&chain); + } + + #[test] + fn finalized_redelivery_at_or_below_final_tip_is_already_applied() { + // Restart shape: the store's tip (incl. not-yet-finalized blocks) is + // restored as the final tier, so their later finalization arrives for + // blocks that were never in `head_blocks`. + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + chain.apply_finalized(msg(1), &genesis, slot(10)); + chain.apply_finalized(msg(2), &block2, slot(20)); + + // Below the tip, and at the tip with a matching hash: idempotent. + assert!(matches!( + chain.apply_finalized(msg(41), &genesis, slot(30)), + AcceptOutcome::AlreadyApplied + )); + assert!(matches!( + chain.apply_finalized(msg(42), &block2, slot(30)), + AcceptOutcome::AlreadyApplied + )); + assert!(chain.final_stall().is_none()); + assert_eq!(chain.final_tip().expect("final tip").block_id, 2); + assert_head_matches_replay(&chain); + } + + #[test] + fn conflicting_finalized_at_final_tip_parks() { + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + chain.apply_finalized(msg(1), &genesis, slot(10)); + chain.apply_finalized(msg(2), &block2, slot(20)); + + // A different finalized block at the final height: finalized is + // irreversible, so this is a genuine stall, not a re-delivery. + let block2_prime = produce_dummy_block(2, Some(HashType([9; 32])), vec![]); + assert!(matches!( + chain.apply_finalized(msg(22), &block2_prime, slot(30)), + AcceptOutcome::Parked(_) + )); + assert!(chain.final_stall().is_some()); + assert_eq!(chain.final_tip().expect("final tip").block_id, 2); + } + + #[test] + fn finalized_unknown_block_rebases_head() { + let accounts = initial_pub_accounts_private_keys(); + let from = accounts[0].account_id; + let to = accounts[1].account_id; + let sign_key = accounts[0].pub_sign_key.clone(); + + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + chain.apply_adopted(msg(1), &genesis); + chain.apply_finalized(msg(1), &genesis, slot(10)); + + // Head advances on a competing branchโ€ฆ + let block2a = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + chain.apply_adopted(msg(2), &block2a); + + // โ€ฆbut a different block 2 finalizes. The finalized chain is + // authoritative, so head rebases onto it. + let tx = create_transaction_native_token_transfer(from, 0, to, 10, &sign_key); + let block2b = produce_dummy_block(2, Some(genesis.header.hash), vec![tx]); + assert!(matches!( + chain.apply_finalized(msg(22), &block2b, slot(20)), + AcceptOutcome::Applied + )); + + assert_eq!(chain.final_tip().expect("final tip").block_id, 2); + assert_eq!(chain.head_tip().expect("head tip").block_id, 2); + assert_eq!(chain.head_state().get_account_by_id(to).balance, 20010); + assert_head_matches_replay(&chain); + } + + #[test] + fn head_state_reflects_applied_transfers() { + let accounts = initial_pub_accounts_private_keys(); + let from = accounts[0].account_id; + let to = accounts[1].account_id; + let sign_key = accounts[0].pub_sign_key.clone(); + + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + chain.apply_adopted(msg(1), &genesis); + + let tx = create_transaction_native_token_transfer(from, 0, to, 10, &sign_key); + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![tx]); + chain.apply_adopted(msg(2), &block2); + + assert_eq!(chain.head_state().get_account_by_id(from).balance, 9990); + assert_eq!(chain.head_state().get_account_by_id(to).balance, 20010); + } +} diff --git a/lez/indexer/core/src/chain_consistency.rs b/lez/chain_state/src/consistency.rs similarity index 53% rename from lez/indexer/core/src/chain_consistency.rs rename to lez/chain_state/src/consistency.rs index df4b5ba6..5c87fcad 100644 --- a/lez/indexer/core/src/chain_consistency.rs +++ b/lez/chain_state/src/consistency.rs @@ -1,18 +1,18 @@ -//! Startup check that the local store still belongs to the chain the -//! connected channel serves. +//! Startup check that a local store still belongs to the chain the connected +//! channel serves. use anyhow::Result; use common::{HashType, block::Block}; use futures::StreamExt as _; +use lee_core::BlockId; use log::warn; -use logos_blockchain_zone_sdk::{Slot, ZoneMessage, adapter::Node as _}; - -use crate::IndexerCore; +use logos_blockchain_core::mantle::ops::channel::ChannelId; +use logos_blockchain_zone_sdk::{Slot, ZoneMessage, adapter, indexer::ZoneIndexer}; /// 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. +/// Result of comparing a caller'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). @@ -24,7 +24,7 @@ pub enum ChainConsistency { /// - 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. + /// A genuine divergence is still caught later when applying the channel history. Inconclusive, /// Positive evidence that the channel is a different chain than the store. /// @@ -36,13 +36,13 @@ pub enum ChainConsistency { pub enum ChainMismatch { /// The channel serves a different block at the anchor's id. Block { - ours: (u64, HashType), - channel: (u64, HashType), + ours: (BlockId, HashType), + channel: (BlockId, 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), + channel: (BlockId, HashType), slot: Slot, anchor_slot: Slot, }, @@ -97,18 +97,26 @@ impl std::fmt::Display for ChainMismatch { /// 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 { +#[derive(Debug, Clone, PartialEq, Eq)] +pub 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)>, + /// `None` when anchored on an undeserializable inscription (no header was recorded). + block: Option<(BlockId, HashType)>, } impl Anchor { + /// Builds an anchor at `slot` on the block `(id, hash)`, or a headerless + /// anchor (`None`) when only the slot is known. + #[must_use] + pub const fn new(slot: Slot, block: Option<(BlockId, HashType)>) -> Self { + Self { slot, block } + } + /// 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 { + /// See [`verify_chain_consistency`]. + fn probe_anchor_slot(&self, msg: &ZoneMessage, slot: Slot) -> AnchorProbe { if slot < self.slot { return AnchorProbe::KeepLooking; } @@ -157,6 +165,66 @@ impl Anchor { } } +/// Classifies a stored chain against the channel one message at a time. +/// +/// The shared driver behind both consistency consumers: +/// [`verify_chain_consistency`] runs it over a channel it reads itself, while a +/// caller that already streams the channel history (e.g. a reconstructing +/// sequencer) feeds it the same messages it replays. Run [`Self::check_frontier`] +/// once the channel tip is known, then feed messages in slot order via +/// [`Self::observe`] until a verdict is reached. +pub struct AnchorConsistencyCheck { + anchor: Anchor, + verdict: Option, +} + +impl AnchorConsistencyCheck { + /// New checker for `anchor` with an undetermined verdict. + #[must_use] + pub const fn new(anchor: Anchor) -> Self { + Self { + anchor, + verdict: None, + } + } + + /// Applies the frontier check against a known channel tip. Skip it when the + /// tip could not be read, leaving the verdict to the message scan. + pub fn check_frontier(&mut self, channel_tip_slot: Option) { + if self.verdict.is_none() { + self.verdict = frontier_verdict(self.anchor.slot, channel_tip_slot) + .map(ChainConsistency::Inconsistent); + } + } + + /// Feeds the next channel message in slot order. Returns the verdict once it + /// is known so the caller can stop, or `None` while still undetermined. + pub fn observe(&mut self, msg: &ZoneMessage, slot: Slot) -> Option<&ChainConsistency> { + if self.verdict.is_none() { + self.verdict = match self.anchor.probe_anchor_slot(msg, slot) { + AnchorProbe::SameChain => Some(ChainConsistency::Consistent), + AnchorProbe::Mismatch(mismatch) => Some(ChainConsistency::Inconsistent(mismatch)), + AnchorProbe::Bail => Some(ChainConsistency::Inconclusive), + AnchorProbe::KeepLooking => None, + }; + } + self.verdict.as_ref() + } + + /// The verdict reached so far, or `None` while undetermined. + #[must_use] + pub const fn verdict(&self) -> Option<&ChainConsistency> { + self.verdict.as_ref() + } + + /// Consumes the checker, defaulting an undetermined verdict (the anchor slot + /// was never observed) to [`ChainConsistency::Inconclusive`]. + #[must_use] + pub fn finish(self) -> ChainConsistency { + self.verdict.unwrap_or(ChainConsistency::Inconclusive) + } +} + /// What a single channel message tells the anchored consistency check. enum AnchorProbe { /// The anchor is still in place: same chain. @@ -168,114 +236,65 @@ enum AnchorProbe { 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 { - 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 +/// Detects when a local store belongs to a different chain than the connected +/// L1 (e.g. a wiped/restarted Bedrock) so startup can react instead of silently +/// diverging. +/// +/// 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 +/// [`ChainConsistency::Inconsistent`]; absence of data stays +/// [`ChainConsistency::Inconclusive`]. +/// +/// `node` need only implement the zone-sdk [`adapter::Node`] trait; a throwaway +/// [`ZoneIndexer`] is built internally for the channel read. +pub async fn verify_chain_consistency( + node: &N, + channel_id: ChannelId, + anchor: &Anchor, +) -> Result +where + N: adapter::Node + Clone + Sync, +{ + let mut check = AnchorConsistencyCheck::new(anchor.clone()); + match node.channel_state(channel_id).await { + Ok(state) => check.check_frontier(state.map(|s| s.tip_slot)), + Err(err) => warn!("Failed to read channel state for the consistency check: {err:#}"), + } + if check.verdict().is_some() { + return Ok(check.finish()); } - /// 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> { - 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), - })); - } + // `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); + }; - // 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)), - })) - } + let zone_indexer = ZoneIndexer::new(channel_id, node.clone()); + let scan = async { + let stream = zone_indexer + .next_messages(Some(Slot::from(from_slot))) + .await?; + let mut stream = std::pin::pin!(stream); - /// 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 { - 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:#}"); + while let Some((msg, slot)) = stream.next().await { + if check.observe(&msg, slot).is_some() { + break; } } + Ok::<_, anyhow::Error>(()) + }; - // `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) - } + match tokio::time::timeout(CHANNEL_READ_TIMEOUT, scan).await { + Ok(Ok(())) => Ok(check.finish()), + 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) } } } @@ -297,35 +316,13 @@ fn frontier_verdict(anchor_slot: Slot, channel_tip_slot: Option) -> Option #[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 { + fn test_block(block_id: BlockId, timestamp: u64) -> Block { HashableBlockData { block_id, prev_block_hash: HashType([0; 32]), @@ -344,98 +341,7 @@ mod tests { } 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))); + Anchor::new(slot, Some((block.header.block_id, block.header.hash))) } #[test] @@ -516,10 +422,7 @@ mod tests { 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 anchor = Anchor::new(Slot::from(1_000), None); let garbage = ZoneMessage::Block(ZoneBlock { id: MsgId::from([0_u8; 32]), data: Inscription::try_from(&[1_u8, 2, 3][..]).expect("inscription"), diff --git a/lez/indexer/core/src/ingest_error.rs b/lez/chain_state/src/ingest_error.rs similarity index 93% rename from lez/indexer/core/src/ingest_error.rs rename to lez/chain_state/src/ingest_error.rs index 299013d7..d259b5f7 100644 --- a/lez/indexer/core/src/ingest_error.rs +++ b/lez/chain_state/src/ingest_error.rs @@ -1,10 +1,10 @@ use common::HashType; use serde::{Deserialize, Serialize}; -/// Why the indexer could not apply an L2 block from the channel. +/// Why an L2 block from the channel could not be applied. /// -/// Persisted in `RocksDB`, so every variant must have the following -/// traits: `Clone + Serialize + Deserialize`. +/// Persisted in `RocksDB` (as part of [`crate::StallReason`]), so every variant +/// must be `Clone + Serialize + Deserialize`. #[derive(Debug, Clone, Serialize, Deserialize, thiserror::Error)] pub enum BlockIngestError { #[error("Failed to deserialize L2 block: {0}")] diff --git a/lez/chain_state/src/lib.rs b/lez/chain_state/src/lib.rs new file mode 100644 index 00000000..89357af0 --- /dev/null +++ b/lez/chain_state/src/lib.rs @@ -0,0 +1,17 @@ +//! Storage-free chain-state core shared by the LEZ sequencer and indexer: +//! the [`apply_block`] entry point plus [`BlockIngestError`], [`StallReason`], +//! [`Tip`], and [`AcceptOutcome`]. See [`ChainState`] for the two-tier model. + +pub use apply::{AcceptOutcome, Tip, apply_block, apply_block_to_state, validate_against_tip}; +pub use chain::{ChainState, HeadEntry}; +pub use consistency::{ + Anchor, AnchorConsistencyCheck, ChainConsistency, ChainMismatch, verify_chain_consistency, +}; +pub use ingest_error::BlockIngestError; +pub use stall_reason::StallReason; + +pub mod apply; +pub mod chain; +pub mod consistency; +pub mod ingest_error; +pub mod stall_reason; diff --git a/lez/indexer/core/src/stall_reason.rs b/lez/chain_state/src/stall_reason.rs similarity index 51% rename from lez/indexer/core/src/stall_reason.rs rename to lez/chain_state/src/stall_reason.rs index c5118fcc..e1d9b3ac 100644 --- a/lez/indexer/core/src/stall_reason.rs +++ b/lez/chain_state/src/stall_reason.rs @@ -1,4 +1,4 @@ -use common::HashType; +use common::{HashType, block::BlockHeader}; use logos_blockchain_zone_sdk::Slot; use serde::{Deserialize, Serialize}; @@ -23,3 +23,28 @@ pub struct StallReason { /// just count them. pub orphans_since: u64, } + +impl StallReason { + /// First stall for a break, built from the breaking block's header + /// (`None` for a deserialize break). + #[must_use] + pub fn new(header: Option<&BlockHeader>, l1_slot: Slot, error: BlockIngestError) -> Self { + Self { + block_id: header.map(|header| header.block_id), + block_hash: header.map(|header| header.hash), + prev_block_hash: header.map(|header| header.prev_block_hash), + first_seen: header.map(|header| header.timestamp), + l1_slot, + error, + orphans_since: 0, + } + } + + /// A later stall on the same break: bumps `orphans_since`, preserving the + /// original cause. + #[must_use] + pub const fn escalate(mut self) -> Self { + self.orphans_since = self.orphans_since.saturating_add(1); + self + } +} diff --git a/lez/common/src/block.rs b/lez/common/src/block.rs index b22eec9f..53a2f033 100644 --- a/lez/common/src/block.rs +++ b/lez/common/src/block.rs @@ -13,6 +13,15 @@ pub struct BlockMeta { pub hash: BlockHash, } +impl From<&Block> for BlockMeta { + fn from(block: &Block) -> Self { + Self { + id: block.header.block_id, + hash: block.header.hash, + } + } +} + #[derive(Debug, Clone)] /// Our own hasher. /// Currently it is SHA256 hasher wrapper. May change in a future. diff --git a/lez/indexer/core/Cargo.toml b/lez/indexer/core/Cargo.toml index 14941773..afba6f29 100644 --- a/lez/indexer/core/Cargo.toml +++ b/lez/indexer/core/Cargo.toml @@ -12,6 +12,7 @@ default = [] testnet = [] [dependencies] +chain_state.workspace = true common.workspace = true logos-blockchain-zone-sdk.workspace = true lee.workspace = true @@ -32,7 +33,6 @@ 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 diff --git a/lez/indexer/core/src/block_store.rs b/lez/indexer/core/src/block_store.rs index 8c974399..a5d66a23 100644 --- a/lez/indexer/core/src/block_store.rs +++ b/lez/indexer/core/src/block_store.rs @@ -1,12 +1,14 @@ use std::{path::Path, sync::Arc}; use anyhow::{Context as _, Result}; -use common::{ - HashType, - block::{BedrockStatus, Block, BlockHeader}, - transaction::{LeeTransaction, clock_invocation}, +use chain_state::{ + AcceptOutcome, BlockIngestError, StallReason, Tip, apply_block_to_state, validate_against_tip, }; -use lee::{Account, AccountId, GENESIS_BLOCK_ID, V03State}; +use common::{ + block::{BedrockStatus, Block, BlockHeader}, + transaction::LeeTransaction, +}; +use lee::{Account, AccountId, V03State}; use lee_core::BlockId; use log::warn; use logos_blockchain_core::header::HeaderId; @@ -14,28 +16,6 @@ use logos_blockchain_zone_sdk::Slot; use storage::indexer::RocksDBIO; use tokio::sync::RwLock; -use crate::{ingest_error::BlockIngestError, stall_reason::StallReason}; - -struct Tip { - block_id: u64, - hash: HashType, -} - -/// Outcome of feeding a parsed L2 block to the validated tip. -pub enum AcceptOutcome { - /// Chained and applied; tip and L1 read cursor both advance. - Applied, - /// A duplicate re-delivery of the current tip. Just L2 advances. - AlreadyApplied, - /// Did not chain or failed to apply; tip stays frozen, stall recorded. - Parked(BlockIngestError), - /// Chained but failed to apply, possibly transiently - /// ([`BlockIngestError::is_retryable`]); nothing recorded, tip and state - /// untouched. The caller retries and parks via - /// [`IndexerStore::record_stall`] once it gives up. - RetryableFailure(BlockIngestError), -} - #[derive(Clone)] pub struct IndexerStore { dbio: Arc, @@ -207,10 +187,7 @@ impl IndexerStore { let Some(block) = self.dbio.get_block(block_id)? else { return Ok(None); }; - Ok(Some(Tip { - block_id, - hash: block.header.hash, - })) + Ok(Some(Tip::from(&block))) } /// Record the stall reason. @@ -223,22 +200,10 @@ impl IndexerStore { l1_slot: Slot, error: BlockIngestError, ) -> Result<()> { - let stall = match self.get_stall_reason()? { - Some(mut existing) => { - existing.orphans_since = existing.orphans_since.saturating_add(1); - existing - } - None => StallReason { - // need to map out of `header` because they are not ser/de - block_id: header.map(|h| h.block_id), - block_hash: header.map(|h| h.hash), - prev_block_hash: header.map(|h| h.prev_block_hash), - first_seen: header.map(|h| h.timestamp), - l1_slot, - error, - orphans_since: 0, - }, - }; + let stall = self.get_stall_reason()?.map_or_else( + || StallReason::new(header, l1_slot, error), + StallReason::escalate, + ); self.set_stall_reason(&Some(stall)) } @@ -258,6 +223,8 @@ impl IndexerStore { return Ok(AcceptOutcome::AlreadyApplied); } + // Validate before paying for the scratch clone; validation failures + // are never retryable, so parking immediately is exact. if let Err(err) = validate_against_tip(tip.as_ref(), block) { self.record_stall(Some(&block.header), l1_slot, err.clone())?; return Ok(AcceptOutcome::Parked(err)); @@ -265,7 +232,7 @@ impl IndexerStore { // TODO: we use scratch state to be atomic, but need to revisit how expensive a clone is let mut scratch = self.current_state.read().await.clone(); - if let Err(err) = apply_block_to_scratch(block, &mut scratch) { + if let Err(err) = apply_block_to_state(block, &mut scratch) { if err.is_retryable() { return Ok(AcceptOutcome::RetryableFailure(err)); } @@ -290,112 +257,11 @@ impl IndexerStore { } } -/// Checks that `block` is the valid continuation of `tip`: hash integrity, -/// then block-id continuity, then `prev_block_hash` linkage. A `None` tip -/// (cold store) expects the genesis block. -fn validate_against_tip(tip: Option<&Tip>, block: &Block) -> Result<(), BlockIngestError> { - let computed = block.recompute_hash(); - if computed != block.header.hash { - return Err(BlockIngestError::HashMismatch { - computed, - header: block.header.hash, - }); - } - - match tip { - None => { - if block.header.block_id != GENESIS_BLOCK_ID { - return Err(BlockIngestError::UnexpectedBlockId { - expected: GENESIS_BLOCK_ID, - got: block.header.block_id, - }); - } - } - Some(tip) => { - let expected = tip - .block_id - .checked_add(1) - .expect("block id should not overflow"); - if block.header.block_id != expected { - return Err(BlockIngestError::UnexpectedBlockId { - expected, - got: block.header.block_id, - }); - } - if block.header.prev_block_hash != tip.hash { - return Err(BlockIngestError::BrokenChainLink { - expected_prev: tip.hash, - got_prev: block.header.prev_block_hash, - }); - } - } - } - Ok(()) -} - -/// Applies a block's transactions to `state`, mapping every failure to a -/// [`BlockIngestError`] so the caller can park rather than crash. Operates on a -/// scratch state; the caller commits only on `Ok`. -fn apply_block_to_scratch(block: &Block, state: &mut V03State) -> Result<(), BlockIngestError> { - let (clock_tx, user_txs) = block - .body - .transactions - .split_last() - .ok_or(BlockIngestError::EmptyBlock)?; - - let expected_clock = LeeTransaction::Public(clock_invocation(block.header.timestamp)); - if *clock_tx != expected_clock { - return Err(BlockIngestError::InvalidClockTransaction); - } - - let is_genesis = block.header.block_id == GENESIS_BLOCK_ID; - for (tx_index, transaction) in user_txs.iter().enumerate() { - let state_transition = |err: anyhow::Error| BlockIngestError::StateTransition { - tx_index: tx_index.try_into().expect("tx index fits in u64"), - reason: format!("{err:#}"), - }; - if is_genesis { - let LeeTransaction::Public(public_tx) = transaction else { - return Err(BlockIngestError::NonPublicGenesisTransaction); - }; - state - .transition_from_public_transaction( - public_tx, - block.header.block_id, - block.header.timestamp, - ) - .map_err(|err| state_transition(err.into()))?; - } else { - transaction - .clone() - .execute_on_state(state, block.header.block_id, block.header.timestamp) - .map_err(|err| state_transition(err.into()))?; - } - } - - let LeeTransaction::Public(clock_public_tx) = clock_tx else { - return Err(BlockIngestError::InvalidClockTransaction); - }; - state - .transition_from_public_transaction( - clock_public_tx, - block.header.block_id, - block.header.timestamp, - ) - .map_err(|err| BlockIngestError::StateTransition { - tx_index: user_txs.len().try_into().expect("tx index fits in u64"), - reason: format!("{:#}", anyhow::Error::from(err)), - })?; - - Ok(()) -} - #[cfg(test)] mod stall_reason_tests { use common::HashType; use super::*; - use crate::{ingest_error::BlockIngestError, stall_reason::StallReason}; #[tokio::test] async fn stall_reason_roundtrips_and_clears() { @@ -544,7 +410,6 @@ mod accept_tests { use common::{HashType, block::HashableBlockData, test_utils::produce_dummy_block}; use super::*; - use crate::ingest_error::BlockIngestError; fn signing_key() -> lee::PrivateKey { lee::PrivateKey::try_new([7_u8; 32]).expect("valid key") diff --git a/lez/indexer/core/src/lib.rs b/lez/indexer/core/src/lib.rs index 7bee6387..5a4909f4 100644 --- a/lez/indexer/core/src/lib.rs +++ b/lez/indexer/core/src/lib.rs @@ -2,31 +2,28 @@ use std::{path::Path, sync::Arc}; use anyhow::Result; use arc_swap::ArcSwap; +pub use chain_state::{AcceptOutcome, BlockIngestError, StallReason}; +use chain_state::{Anchor, ChainConsistency}; use common::block::Block; // TODO: Remove after testnet use futures::StreamExt as _; -pub use ingest_error::BlockIngestError; use log::{error, info, warn}; use logos_blockchain_zone_sdk::{ CommonHttpClient, Slot, ZoneMessage, adapter::NodeHttpClient, indexer::ZoneIndexer, }; use retry::ApplyRetryGate; -pub use stall_reason::StallReason; use crate::{ - block_store::{AcceptOutcome, IndexerStore}, - chain_consistency::ChainConsistency, + block_store::IndexerStore, 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. @@ -125,6 +122,52 @@ 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 { + let Some(anchor) = self.get_startup_anchor()? else { + // empty or cold store: nothing to compare + return Ok(ChainConsistency::Inconclusive); + }; + + chain_state::verify_chain_consistency(&self.node, self.config.channel_id, &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> { + if let Some(stall) = self.store.get_stall_reason()? { + return Ok(Some(Anchor::new( + stall.l1_slot, + 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()? + .map_or_else(|| self.store.get_zone_cursor(), |slot| Ok(Some(slot)))? + 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::new(slot, Some((tip_id, tip.header.hash))))) + } + /// Snapshot of the current ingestion status (sync state + indexed tip). /// /// Combines the ingest loop's live status with the L2 tip read fresh from the @@ -207,8 +250,8 @@ impl IndexerCore { let mut cursor = initial_cursor; let mut retry_gate = ApplyRetryGate::new(); - if cursor.is_some() { - info!("Resuming indexer from cursor {cursor:?}"); + if let Some(slot) = &cursor { + info!("Resuming indexer from cursor {slot:?}"); } else { info!("Starting indexer from beginning of channel"); } @@ -370,3 +413,127 @@ impl IndexerCore { } } } + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use common::{HashType, block::HashableBlockData}; + use logos_blockchain_zone_sdk::Slot; + + use super::*; + use crate::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")) + } + + #[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"); + let expected = Anchor::new(Slot::from(1_010), Some((3, block3.header.hash))); + assert_eq!(anchor, expected); + } +} diff --git a/lez/indexer/core/src/status.rs b/lez/indexer/core/src/status.rs index a483fde6..aa182c23 100644 --- a/lez/indexer/core/src/status.rs +++ b/lez/indexer/core/src/status.rs @@ -1,7 +1,6 @@ +use chain_state::StallReason; 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)] @@ -117,10 +116,9 @@ mod tests { #[test] fn stalled_status_serializes_with_stall_reason() { + use chain_state::{BlockIngestError, StallReason}; 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), diff --git a/lez/mempool/src/lib.rs b/lez/mempool/src/lib.rs index 1b36eaf7..0006f2c3 100644 --- a/lez/mempool/src/lib.rs +++ b/lez/mempool/src/lib.rs @@ -65,6 +65,11 @@ impl MemPoolHandle { pub async fn push(&self, item: T) -> Result<(), tokio::sync::mpsc::error::SendError> { self.sender.send(item).await } + + /// Send an item to the mempool, failing _immediately_ if it is full. + pub fn try_push(&self, item: T) -> Result<(), tokio::sync::mpsc::error::TrySendError> { + self.sender.try_send(item) + } } #[cfg(test)] @@ -123,6 +128,19 @@ mod tests { assert_eq!(pool.pop(), Some(2)); } + #[test] + async fn try_push_fails_when_full_without_blocking() { + let (mut pool, handle) = MemPool::new(1); + + handle.try_push(1).unwrap(); + assert!(handle.try_push(2).is_err(), "full mempool must not accept"); + + // Popping frees capacity again. + assert_eq!(pool.pop(), Some(1)); + handle.try_push(2).unwrap(); + assert_eq!(pool.pop(), Some(2)); + } + #[test] async fn push_front() { let (mut pool, handle) = MemPool::new(10); diff --git a/lez/sequencer/core/Cargo.toml b/lez/sequencer/core/Cargo.toml index cd89f223..c8590c2a 100644 --- a/lez/sequencer/core/Cargo.toml +++ b/lez/sequencer/core/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [dependencies] lee.workspace = true lee_core.workspace = true +chain_state.workspace = true common.workspace = true storage.workspace = true mempool.workspace = true @@ -33,6 +34,7 @@ tempfile.workspace = true chrono.workspace = true log.workspace = true tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } +tokio-util.workspace = true rand.workspace = true borsh.workspace = true bytesize.workspace = true diff --git a/lez/sequencer/core/src/block_publisher.rs b/lez/sequencer/core/src/block_publisher.rs index 21551131..8f2f2031 100644 --- a/lez/sequencer/core/src/block_publisher.rs +++ b/lez/sequencer/core/src/block_publisher.rs @@ -1,21 +1,44 @@ use std::{pin::Pin, sync::Arc, time::Duration}; -use anyhow::{Context as _, Result, anyhow}; +use anyhow::{Context as _, Result, anyhow, ensure}; use common::block::Block; +use futures::Stream; use log::{info, warn}; -pub use logos_blockchain_core::mantle::ops::channel::MsgId; -use logos_blockchain_core::mantle::ops::channel::{ChannelId, inscribe::Inscription}; -pub use logos_blockchain_key_management_system_service::keys::{Ed25519Key, ZkKey}; +pub use logos_blockchain_core::mantle::ops::channel::{Ed25519PublicKey, MsgId}; +use logos_blockchain_core::{ + mantle::{ + MantleTx, SignedMantleTx, Transaction as _, + channel::{SlotTimeframe, SlotTimeout}, + ops::{ + Op, OpProof, + channel::{ + ChannelId, + config::{ChannelConfigOp, Keys}, + inscribe::Inscription, + }, + }, + }, + proofs::channel_multi_sig_proof::{ChannelMultiSigProof, IndexedSignature}, +}; +pub use logos_blockchain_key_management_system_service::keys::{ + ED25519_SECRET_KEY_SIZE, Ed25519Key, ZkKey, +}; pub use logos_blockchain_zone_sdk::sequencer::SequencerCheckpoint; use logos_blockchain_zone_sdk::{ - CommonHttpClient, - adapter::NodeHttpClient, + CommonHttpClient, Slot, ZoneMessage, + adapter::{Node as _, NodeHttpClient}, + indexer::ZoneIndexer, sequencer::{ - DepositInfo, Event, FinalizedOp, InscriptionInfo, - SequencerConfig as ZoneSdkSequencerConfig, WithdrawArg, WithdrawInfo, ZoneSequencer, + DepositInfo, Event, FinalizedOp, InscriptionInfo, OrphanedTx, + SequencerConfig as ZoneSdkSequencerConfig, TurnNotification, WithdrawArg, WithdrawInfo, + ZoneSequencer, }, }; -use tokio::{sync::mpsc, task::JoinHandle}; +use tokio::{ + sync::{mpsc, oneshot, watch}, + task::JoinHandle, +}; +use tokio_util::sync::CancellationToken; use crate::config::BedrockConfig; @@ -41,8 +64,38 @@ pub type OnDepositEventSink = pub type OnWithdrawEventSink = Box Pin + Send>> + Send + 'static>; +/// The channel delta the follow path consumes from one `Event::BlocksProcessed`, +/// with inscription payloads decoded into `(MsgId, Block)` pairs. +pub struct FollowUpdate { + /// Inscriptions newly on the followed L1 branch, in channel order: they + /// extend (or, after a reorg, replace part of) the `head` tier. + pub adopted: Vec<(MsgId, Block)>, + /// Inscriptions dropped from the branch by an L1 reorg: their blocks are + /// reverted from the `head` and their user txs resubmitted to the mempool. + pub orphaned: Vec<(MsgId, Block)>, + /// Inscriptions whose containing L1 block reached finality: their blocks + /// move into the irreversible `final` tier. + pub finalized: Vec<(MsgId, Block)>, +} + +/// Sink for the follow path: apply adopted/finalized blocks to chain state and +/// revert orphaned ones. +pub type OnFollowSink = Box; + +/// Commands the drive task executes with `&mut sequencer`. +enum Command { + /// Publish an inscription (+ atomic withdrawals); responds with the assigned `MsgId`. + Publish { + inscription: Inscription, + withdrawals: Vec, + resp: oneshot::Sender>, + }, +} + +type CommandSender = mpsc::Sender; + #[expect(async_fn_in_trait, reason = "We don't care about Send/Sync here")] -pub trait BlockPublisherTrait: Clone { +pub trait BlockPublisherTrait: Sized { #[expect( clippy::too_many_arguments, reason = "Looks better than bundling all those callbacks into a struct" @@ -56,22 +109,49 @@ pub trait BlockPublisherTrait: Clone { on_finalized_block: FinalizedBlockSink, on_deposit_event: OnDepositEventSink, on_withdraw_event: OnWithdrawEventSink, + on_follow: OnFollowSink, ) -> Result; - /// Fire-and-forget publish. Zone-sdk drives the actual submission and - /// retries internally; this just hands the payload off. - async fn publish_block(&self, block: &Block, withdrawals: Vec) -> Result<()>; + /// Publish a block and return the `MsgId` zone-sdk assigned its inscription. + /// Zone-sdk drives the actual submission and retries internally. + async fn publish_block(&self, block: &Block, withdrawals: Vec) -> Result; fn channel_id(&self) -> ChannelId; + + /// Whether this sequencer is currently authorized to write to the channel. + fn is_our_turn(&self) -> bool; + + /// A [`CancellationToken`] cancelled when the publisher's background driver + /// terminates (a panicked sink, an ended event stream). No channel events + /// are processed past that point, so the node must halt. + fn driver_cancellation(&self) -> CancellationToken; + + /// Current channel frontier slot on the connected chain, or `None` if the + /// channel does not exist there. Drives the startup frontier check. + async fn channel_tip_slot(&self) -> Result>; + + /// Finalized channel messages from `after_slot` (exclusive) up to LIB, used + /// for the startup consistency check and reconstruction. Pass `None` to read + /// from the channel's genesis. + async fn read_channel_after( + &self, + after_slot: Option, + ) -> Result + '_>; } /// Real block publisher backed by zone-sdk's `ZoneSequencer`. -#[derive(Clone)] pub struct ZoneSdkPublisher { channel_id: ChannelId, - publish_tx: mpsc::Sender<(Inscription, Vec)>, + /// Direct node handle retained for channel reads (startup consistency check + /// and reconstruction); the sequencer itself lives in the drive task. + node: NodeHttpClient, + command_tx: CommandSender, + turn_rx: watch::Receiver, + // Cancelled when the drive task ends for any reason, including a panic. + driver_cancellation: CancellationToken, // Aborts the drive task when the last clone is dropped. _drive_task: Arc, + indexer: ZoneIndexer, } struct DriveTaskGuard(JoinHandle<()>); @@ -92,6 +172,7 @@ impl BlockPublisherTrait for ZoneSdkPublisher { on_finalized_block: FinalizedBlockSink, on_deposit_event: OnDepositEventSink, on_withdraw_event: OnWithdrawEventSink, + on_follow: OnFollowSink, ) -> Result { let basic_auth = config.auth.clone().map(Into::into); let node = NodeHttpClient::new(CommonHttpClient::new(basic_auth), config.node_url.clone()); @@ -104,7 +185,7 @@ impl BlockPublisherTrait for ZoneSdkPublisher { let mut sequencer = ZoneSequencer::init_with_config( config.channel_id, bedrock_signing_key, - node, + node.clone(), zone_sdk_config, initial_checkpoint, ); @@ -112,11 +193,18 @@ impl BlockPublisherTrait for ZoneSdkPublisher { // Grab readiness receiver before moving the sequencer into the drive // task so we can await cold-start completion below. let mut ready_rx = sequencer.subscribe_ready(); + // Grab the turn watch before the move; the sdk actor keeps it current. + let turn_rx = sequencer.subscribe_turn_to_write(); - let (publish_tx, mut publish_rx) = - mpsc::channel::<(Inscription, Vec)>(PUBLISH_INBOX_CAPACITY); + let (command_tx, mut command_rx): (CommandSender, _) = + mpsc::channel(PUBLISH_INBOX_CAPACITY); + let driver_cancellation = CancellationToken::new(); + let driver_guard = driver_cancellation.clone().drop_guard(); let drive_task = tokio::spawn(async move { + // Dropped when this task ends (including panics in the sinks), + // cancelling every `driver_cancellation`. + let _driver_guard = driver_guard; loop { #[expect( clippy::integer_division_remainder_used, @@ -124,32 +212,38 @@ impl BlockPublisherTrait for ZoneSdkPublisher { )] { tokio::select! { - // Drain external publish requests by calling the - // borrowing handle โ€” `&mut sequencer` is only - // available here. - Some((data_bounded, withdrawals)) = publish_rx.recv() => { - let data_byte_size = data_bounded.len(); - if withdrawals.is_empty() { - if let Err(e) = sequencer.handle() - .publish(data_bounded) - .context("Failed to publish block") { - warn!("zone-sdk publish failed: {e:?}"); - } - - info!("Published block with the size of {data_byte_size} bytes"); - } else { + // Drain external commands by calling the borrowing + // handle โ€” `&mut sequencer` is only available here. + Some(command) = command_rx.recv() => match command { + Command::Publish { inscription: data_bounded, withdrawals, resp: resp_tx } => { + let data_byte_size = data_bounded.len(); let withdraw_count = withdrawals.len(); - if let Err(e) = sequencer.handle() - .publish_atomic_withdraw(data_bounded, withdrawals) - .context("Failed to publish block with withdrawals") { - warn!("zone-sdk publish failed: {e:?}"); - } + let published = if withdrawals.is_empty() { + sequencer.handle() + .publish(data_bounded) + .context("Failed to publish block") + } else { + sequencer.handle() + .publish_atomic_withdraw(data_bounded, withdrawals) + .context("Failed to publish block with withdrawals") + }; - info!( - "Published block with the size of {data_byte_size} bytes and {withdraw_count} bridge withdrawals", - ); + let msg_result = published + .map(|(result, _checkpoint)| result.tx.inscription().this_msg); + match &msg_result { + Ok(_) if withdraw_count == 0 => { + info!("Published block with the size of {data_byte_size} bytes"); + } + Ok(_) => { + info!( + "Published block with the size of {data_byte_size} bytes and {withdraw_count} bridge withdrawals", + ); + } + Err(e) => warn!("zone-sdk publish failed: {e:?}"), + } + let _dontcare = resp_tx.send(msg_result); } - } + }, event = sequencer.next_event() => { let Some(event) = event else { continue; @@ -157,17 +251,32 @@ impl BlockPublisherTrait for ZoneSdkPublisher { match event { Event::BlocksProcessed { checkpoint, - channel_update: _, + channel_update, finalized, } => { on_checkpoint(checkpoint); + + let adopted = channel_update + .adopted + .iter() + .filter_map(block_from_inscription) + .collect(); + let orphaned = channel_update + .orphaned + .iter() + .map(orphan_inscription) + .filter_map(block_from_inscription) + .collect(); + + let mut finalized_blocks = Vec::new(); for op in finalized.into_iter().flat_map(|item| item.ops) { match op { FinalizedOp::Inscription(inscription) => { - if let Some(block_id) = - block_id_from_inscription(&inscription) + if let Some((msg, block)) = + block_from_inscription(&inscription) { - on_finalized_block(block_id); + on_finalized_block(block.header.block_id); + finalized_blocks.push((msg, block)); } } FinalizedOp::Deposit(deposit) => { @@ -178,8 +287,22 @@ impl BlockPublisherTrait for ZoneSdkPublisher { } } } + + on_follow(FollowUpdate { + adopted, + orphaned, + finalized: finalized_blocks, + }); + } + Event::Ready => {} + Event::TurnNotification { notification } => { + info!( + "Turn update: our_turn={}, starting_slot={:?}, ends_at_slot={:?}", + notification.our_turn_to_write, + notification.starting_slot, + notification.ends_at_slot + ); } - Event::Ready | Event::TurnNotification { .. } => {} } } } @@ -196,37 +319,151 @@ impl BlockPublisherTrait for ZoneSdkPublisher { Ok(Self { channel_id: config.channel_id, - publish_tx, + indexer: ZoneIndexer::new(config.channel_id, node.clone()), + node, + command_tx, + turn_rx, + driver_cancellation, _drive_task: Arc::new(DriveTaskGuard(drive_task)), }) } - async fn publish_block(&self, block: &Block, withdrawals: Vec) -> Result<()> { + async fn publish_block(&self, block: &Block, withdrawals: Vec) -> Result { let data = borsh::to_vec(block).context("Failed to serialize block")?; let data_bounded: Inscription = data .try_into() .context("Block data exceeds maximum allowed size")?; - self.publish_tx - .send((data_bounded, withdrawals)) + let (resp_tx, resp_rx) = oneshot::channel(); + self.command_tx + .send(Command::Publish { + inscription: data_bounded, + withdrawals, + resp: resp_tx, + }) .await .map_err(|_closed| anyhow!("Drive task is no longer running"))?; - Ok(()) + resp_rx + .await + .map_err(|_closed| anyhow!("Drive task dropped the publish response"))? } fn channel_id(&self) -> ChannelId { self.channel_id } + + fn is_our_turn(&self) -> bool { + self.turn_rx.borrow().our_turn_to_write + } + + fn driver_cancellation(&self) -> CancellationToken { + self.driver_cancellation.clone() + } + + async fn channel_tip_slot(&self) -> Result> { + Ok(self + .node + .channel_state(self.channel_id) + .await + .context("Failed to read channel state")? + .map(|state| state.tip_slot)) + } + + async fn read_channel_after( + &self, + after_slot: Option, + ) -> Result + '_> { + let stream = self + .indexer + .next_messages(after_slot) + .await + .context("Failed to start channel read stream")?; + Ok(stream) + } } -/// Deserialize inscription payload as a `Block` and return it's`block_id`. -/// Bad payloads are logged and skipped. -fn block_id_from_inscription(inscription: &InscriptionInfo) -> Option { +/// Deserialize an inscription payload into `(this_msg, Block)`. Bad payloads are +/// logged and skipped. +fn block_from_inscription(inscription: &InscriptionInfo) -> Option<(MsgId, Block)> { borsh::from_slice::(&inscription.payload) .inspect_err(|err| { warn!("Failed to deserialize block from inscription: {err:?}"); }) .ok() - .map(|block| block.header.block_id) + .map(|block| (inscription.this_msg, block)) +} + +/// The inscription carried by an orphaned tx (plain or atomic-withdraw bundle). +const fn orphan_inscription(orphan: &OrphanedTx) -> &InscriptionInfo { + match orphan { + OrphanedTx::Inscription(info) => info, + OrphanedTx::AtomicWithdraw(bundle) => &bundle.inscription, + } +} + +/// Signs a `ChannelConfig` op (accredited keys + rotation params) with +/// `signing_key` and posts it straight to the bedrock node. +/// +/// A standalone one-shot โ€” no running sequencer involved, so authorization is +/// holding the admin key: the L1 rejects non-admin signers. `Ok(())` means the +/// node accepted the transaction; channel acceptance is asynchronous and a +/// rejection only shows up in node logs and on-chain behavior. +pub async fn post_channel_config( + config: &BedrockConfig, + signing_key: &Ed25519Key, + keys: Vec, + posting_timeframe: u32, + posting_timeout: u32, + configuration_threshold: u16, + withdraw_threshold: u16, +) -> Result<()> { + ensure!(!keys.is_empty(), "Channel key list must not be empty"); + for (name, threshold) in [ + ("configuration_threshold", configuration_threshold), + ("withdraw_threshold", withdraw_threshold), + ] { + ensure!( + threshold >= 1 && usize::from(threshold) <= keys.len(), + "{name} must be between 1 and the key count ({}), got {threshold}", + keys.len() + ); + } + ensure!( + posting_timeframe > 0 && posting_timeout >= posting_timeframe, + "posting_timeframe must be nonzero and posting_timeout at least as long, \ + got {posting_timeframe} and {posting_timeout}" + ); + + let keys = Keys::try_from(keys).map_err(|err| anyhow!("Invalid channel key list: {err}"))?; + let config_op = ChannelConfigOp { + channel: config.channel_id, + keys, + posting_timeframe: SlotTimeframe::from(posting_timeframe), + posting_timeout: SlotTimeout::from(posting_timeout), + configuration_threshold, + withdraw_threshold, + }; + + let mantle_tx = MantleTx([Op::ChannelConfig(config_op)].into()); + let tx_hash = mantle_tx.hash(); + // The admin key is `keys[0]`, hence signature index 0. + let signature = IndexedSignature::new( + 0, + signing_key.sign_payload(tx_hash.as_signing_bytes().as_ref()), + ); + let proof = ChannelMultiSigProof::new(vec![signature]) + .map_err(|err| anyhow!("Failed to assemble channel multi-sig proof: {err:?}"))?; + let signed_tx = SignedMantleTx { + ops_proofs: vec![OpProof::ChannelMultiSigProof(proof)], + mantle_tx, + }; + + let node = NodeHttpClient::new( + CommonHttpClient::new(config.auth.clone().map(Into::into)), + config.node_url.clone(), + ); + node.post_transaction(signed_tx) + .await + .context("Failed to post channel config transaction") } diff --git a/lez/sequencer/core/src/block_store.rs b/lez/sequencer/core/src/block_store.rs index 77b57661..37394352 100644 --- a/lez/sequencer/core/src/block_store.rs +++ b/lez/sequencer/core/src/block_store.rs @@ -12,7 +12,7 @@ use log::info; use logos_blockchain_zone_sdk::sequencer::SequencerCheckpoint; use storage::sequencer::{ RocksDBIO, - sequencer_cells::{PendingDepositEventRecord, WithdrawalReconciliationKey}, + sequencer_cells::{PendingDepositEventRecord, WithdrawalReconciliationKey, ZoneAnchorRecord}, }; pub use storage::{DbResult, sequencer::DbDump}; @@ -28,29 +28,17 @@ impl SequencerStore { /// Open existing database at the given location. Fails if no database is found. pub fn open_db(location: &Path, signing_key: lee::PrivateKey) -> DbResult { let dbio = Arc::new(RocksDBIO::open(location)?); - let genesis_id = dbio.get_meta_first_block_in_db()?; - let last_id = dbio.latest_block_meta()?.id; + Self::from_dbio_and_signing_key(dbio, signing_key) + } - info!("Preparing block cache"); - let mut tx_hash_to_block_map = HashMap::new(); - for i in genesis_id..=last_id { - let block = dbio - .get_block(i)? - .expect("Block should be present in the database"); - - tx_hash_to_block_map.extend(block_to_transactions_map(&block)); - } - info!( - "Block cache prepared. Total blocks in cache: {}", - tx_hash_to_block_map.len() - ); - - Ok(Self { - dbio, - tx_hash_to_block_map, - genesis_id, - signing_key, - }) + /// Create a fresh rocksdb at `location` from `dump`. + pub fn restore_db_from_dump( + location: &Path, + dump: &DbDump, + signing_key: lee::PrivateKey, + ) -> DbResult { + let dbio = Arc::new(RocksDBIO::restore_from_dump(location, dump)?); + Self::from_dbio_and_signing_key(dbio, signing_key) } /// Starting database at the start of new chain. @@ -75,6 +63,38 @@ impl SequencerStore { }) } + fn from_dbio_and_signing_key( + dbio: Arc, + signing_key: lee::PrivateKey, + ) -> DbResult { + let genesis_id = dbio.get_meta_first_block_in_db()?; + let last_id = dbio.latest_block_meta()?.map(|meta| meta.id); + + let mut tx_hash_to_block_map = HashMap::new(); + + if let Some(last_id) = last_id { + info!("Preparing block cache"); + for i in genesis_id..=last_id { + let block = dbio + .get_block(i)? + .expect("Block should be present in the database"); + + tx_hash_to_block_map.extend(block_to_transactions_map(&block)); + } + info!( + "Block cache prepared. Total blocks in cache: {}", + tx_hash_to_block_map.len() + ); + } + + Ok(Self { + dbio, + tx_hash_to_block_map, + genesis_id, + signing_key, + }) + } + /// Shared handle to the underlying rocksdb. Used to persist the zone-sdk /// checkpoint from the sequencer's drive task without needing &mut to the /// store. @@ -114,7 +134,7 @@ impl SequencerStore { ); } - pub fn latest_block_meta(&self) -> DbResult { + pub fn latest_block_meta(&self) -> DbResult> { self.dbio.latest_block_meta() } @@ -165,13 +185,6 @@ impl SequencerStore { 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> { let Some(bytes) = self.dbio.get_zone_sdk_checkpoint_bytes()? else { return Ok(None); @@ -188,9 +201,33 @@ impl SequencerStore { Ok(()) } + /// The last channel block read back and verified from Bedrock (L1 slot + + /// `id`/`hash`), or `None` before any block has been read from the channel. + pub fn get_zone_anchor(&self) -> DbResult> { + self.dbio.get_zone_anchor() + } + + pub fn set_zone_anchor(&self, anchor: &ZoneAnchorRecord) -> DbResult<()> { + self.dbio.put_zone_anchor(anchor) + } + pub fn get_unfulfilled_deposit_events(&self) -> DbResult> { self.dbio.get_pending_deposit_events() } + + pub fn is_deposit_event_submitted(&self, deposit_op_id: HashType) -> DbResult { + self.dbio.is_deposit_event_submitted(deposit_op_id) + } + + /// Marks the given deposit events submitted in `block_id`, in one write. + pub fn mark_deposit_events_submitted( + &self, + deposit_op_ids: &[HashType], + submitted_block_id: u64, + ) -> DbResult<()> { + self.dbio + .mark_deposit_events_submitted(deposit_op_ids, submitted_block_id) + } } pub(crate) fn block_to_transactions_map(block: &Block) -> HashMap { @@ -275,7 +312,7 @@ mod tests { .unwrap(); // Verify that initially the latest block hash equals genesis hash - let latest_meta = node_store.latest_block_meta().unwrap(); + let latest_meta = node_store.latest_block_meta().unwrap().unwrap(); assert_eq!(latest_meta.hash, genesis_hash); } @@ -313,7 +350,7 @@ mod tests { .unwrap(); // Verify that the latest block meta now equals the new block's hash - let latest_meta = node_store.latest_block_meta().unwrap(); + let latest_meta = node_store.latest_block_meta().unwrap().unwrap(); assert_eq!(latest_meta.hash, block_hash); } diff --git a/lez/sequencer/core/src/lib.rs b/lez/sequencer/core/src/lib.rs index 3a96e6d5..c33dcac4 100644 --- a/lez/sequencer/core/src/lib.rs +++ b/lez/sequencer/core/src/lib.rs @@ -1,19 +1,30 @@ -use std::{path::Path, sync::Arc, time::Instant}; +use std::{ + path::Path, + sync::{Arc, Mutex}, + time::Instant, +}; use anyhow::{Context as _, Result, anyhow}; use borsh::BorshDeserialize; +use chain_state::{ + AcceptOutcome, Anchor, AnchorConsistencyCheck, ChainConsistency, ChainMismatch, ChainState, Tip, +}; use common::{ HashType, - block::{BedrockStatus, Block, HashableBlockData}, + block::{BedrockStatus, Block, BlockMeta, HashableBlockData}, transaction::{LeeTransaction, clock_invocation}, }; use config::{GenesisAction, SequencerConfig}; +use futures::StreamExt as _; use itertools::Itertools as _; use lee::{AccountId, PublicTransaction, public_transaction::Message}; use lee_core::GENESIS_BLOCK_ID; use log::{error, info, warn}; use logos_blockchain_key_management_system_service::keys::{ED25519_SECRET_KEY_SIZE, Ed25519Key}; -use logos_blockchain_zone_sdk::sequencer::{DepositInfo, WithdrawArg}; +use logos_blockchain_zone_sdk::{ + Slot, ZoneMessage, + sequencer::{DepositInfo, WithdrawArg}, +}; use mempool::{MemPool, MemPoolHandle}; #[cfg(feature = "mock")] pub use mock::SequencerCoreWithMockClients; @@ -21,11 +32,11 @@ use num_bigint::BigUint; pub use storage::error::DbError; use storage::sequencer::{ RocksDBIO, - sequencer_cells::{PendingDepositEventRecord, WithdrawalReconciliationKey}, + sequencer_cells::{PendingDepositEventRecord, WithdrawalReconciliationKey, ZoneAnchorRecord}, }; use crate::{ - block_publisher::{BlockPublisherTrait, ZoneSdkPublisher}, + block_publisher::{BlockPublisherTrait, MsgId, ZoneSdkPublisher}, block_store::SequencerStore, }; @@ -51,18 +62,13 @@ struct DepositMetadata { recipient_id: lee::AccountId, } -impl DepositMetadata { - fn decode(bytes: &[u8]) -> Result { - Self::try_from_slice(bytes) - } -} - pub struct SequencerCore { - state: lee::V03State, + /// Two-tier chain state: production builds on its head; the publisher's + /// `on_follow` sink feeds adopted/orphaned/finalized peer blocks into it. + chain: Arc>, store: SequencerStore, mempool: MemPool<(TransactionOrigin, LeeTransaction)>, sequencer_config: SequencerConfig, - chain_height: u64, block_publisher: BP, } @@ -72,7 +78,7 @@ impl SequencerCore { /// assumed to represent the correct latest state consistent with Bedrock-finalized data. /// If no database is found, the sequencer performs a fresh start from genesis, /// initializing its state with the accounts defined in the configuration file. - fn open_or_create_store(config: &SequencerConfig) -> (SequencerStore, lee::V03State, Block) { + fn open_or_create_store(config: &SequencerConfig) -> (SequencerStore, lee::V03State) { let signing_key = lee::PrivateKey::try_new(config.signing_key).unwrap(); let db_path = config.home.join("rocksdb"); @@ -86,11 +92,7 @@ impl SequencerCore { let state = store .get_lee_state() .expect("Failed to read state from store"); - let genesis_block = store - .get_block_at_id(store.genesis_id()) - .expect("Failed to read genesis block from store") - .expect("Genesis block not found in store"); - (store, state, genesis_block) + (store, state) } else { warn!( "Database not found at {}, starting from genesis", @@ -115,22 +117,70 @@ impl SequencerCore { ) .expect("Failed to create database with genesis block"); - (store, genesis_state, genesis_block) + (store, genesis_state) } } + /// Rebuilds the two-tier [`ChainState`]: the final tier from the persisted + /// final snapshot (pre-genesis state when absent), the head tier by replaying + /// every stored block above it, so a post-restart orphan can still revert. + fn restore_chain_state( + config: &SequencerConfig, + store: &SequencerStore, + stored_head_state: &lee::V03State, + ) -> ChainState { + let final_snapshot = store + .dbio() + .get_final_snapshot() + .expect("Failed to read final snapshot from store"); + let (final_state, final_tip) = match final_snapshot { + Some((state, meta)) => (state, Some(Tip::from(meta))), + // Nothing finalized yet: replay the whole stored chain. + None => (build_initial_state(config), None), + }; + let boundary = final_tip.as_ref().map_or(0, |tip| tip.block_id); + + let mut head_blocks = store + .get_all_blocks() + .filter_ok(|block| block.header.block_id > boundary) + .collect::, _>>() + .expect("Failed to read blocks from store while restoring chain state"); + head_blocks.sort_unstable_by_key(|block| block.header.block_id); + + let mut chain = ChainState::from_final(final_state, final_tip); + for block in head_blocks { + let block_id = block.header.block_id; + chain.restore_head_block(block).unwrap_or_else(|err| { + panic!("Stored block {block_id} does not replay while restoring chain state: {err}") + }); + } + + // The replayed head must reproduce the persisted state, else store + // and config disagree (e.g. edited genesis actions). + assert!( + chain.head_state() == stored_head_state, + "Persisted state does not match the replayed chain; reset the store or restore the original config" + ); + + chain + } + pub async fn start_from_config( config: SequencerConfig, ) -> (Self, MemPoolHandle<(TransactionOrigin, LeeTransaction)>) { let bedrock_signing_key = load_or_create_signing_key(&config.home.join("bedrock_signing_key")) .expect("Failed to load or create bedrock signing key"); + info!( + "Bedrock signing public key: {}", + hex::encode(bedrock_signing_key.public_key().to_bytes()) + ); - let (store, state, _genesis_block) = Self::open_or_create_store(&config); + let (store, state) = Self::open_or_create_store(&config); - let latest_block_meta = store - .latest_block_meta() - .expect("Failed to read latest block meta from store"); + let chain = Arc::new(Mutex::new(Self::restore_chain_state( + &config, &store, &state, + ))); let initial_checkpoint = store .get_zone_checkpoint() @@ -149,12 +199,33 @@ impl SequencerCore { Self::on_finalized_block(store.dbio()), Self::on_deposit_event(store.dbio(), mempool_handle.clone()), Self::on_withdraw_event(store.dbio()), + Self::on_follow(store.dbio(), Arc::clone(&chain), mempool_handle.clone()), ) .await .expect("Failed to initialize Block Publisher"); - // Fresh start (no checkpoint): republish all pending blocks - if is_fresh_start { + // 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, + ); + } + // Before producing, verify our local state still belongs to the chain + // the channel serves and replay any channel blocks we are missing + // (e.g. from other sequencers). + let channel_was_empty = + Self::verify_and_reconstruct(&block_publisher, &store, &chain, is_fresh_start) + .await + .expect("Failed to verify/reconstruct sequencer state from Bedrock"); + + // Publish our blocks only when bootstrapping an empty channel. If the + // channel already has blocks (another sequencer bootstrapped it), we + // adopted them during reconstruction instead. + if is_fresh_start && channel_was_empty { let mut pending_blocks = store .get_all_blocks() .filter_ok(|block| matches!(block.bedrock_status, BedrockStatus::Pending)) @@ -182,22 +253,10 @@ impl SequencerCore { } } - // Cross-zone messaging: start a watcher per configured peer. The inbox - // config account is seeded into genesis state in `build_genesis_state`. - if let Some(cross_zone) = &config.cross_zone { - cross_zone_watcher::spawn_watchers( - &config.bedrock_config, - cross_zone, - config.block_create_timeout, - &mempool_handle, - ); - } - let sequencer_core = Self { - state, + chain, store, mempool, - chain_height: latest_block_meta.id, sequencer_config: config, block_publisher, }; @@ -205,6 +264,220 @@ impl SequencerCore { (sequencer_core, mempool_handle) } + /// Verifies the local store still belongs to the chain the connected channel + /// serves and replays any finalized channel blocks missing locally into + /// `state`/`store`, recording each block's L1 inscription slot as the new + /// anchor. Fails (never parks) on any divergence. + /// + /// Returns whatever channel was empty or not. + async fn verify_and_reconstruct( + publisher: &BP, + store: &SequencerStore, + chain: &Mutex, + is_fresh_start: bool, + ) -> Result { + let anchor_record = store + .get_zone_anchor() + .context("Failed to read zone anchor")?; + + let after_slot = anchor_record + .and_then(|record| record.slot.checked_sub(1)) + .map(Slot::from); + let channel_tip_slot = publisher + .channel_tip_slot() + .await + .context("Failed to read channel tip slot")?; + + // If this sequencer has already committed blocks to the channel, that + // channel must still exist. A missing channel then means a wiped/rewound + // Bedrock or a node pointing at a different chain, so refuse to resume + // onto a foreign channel. + // + // "Committed" requires *both* a non-genesis tip and a checkpoint that was + // persisted before this startup: the tip alone is set the moment we produce + // (before the channel confirms it), while a checkpoint alone is written by + // zone-sdk's cold-start backfill even on a brand-new empty channel before we + // publish genesis. We must read the checkpoint presence from before `BP::new` + // ran (`!is_fresh_start`), because its cold-start backfill re-persists a + // checkpoint by the time we reach here โ€” reading the store now would always + // see one. Together they mean we produced blocks and zone-sdk processed + // channel activity in a prior run. + let local_tip = store + .latest_block_meta() + .context("Failed to read latest block meta")? + .map(|meta| meta.id); + let had_checkpoint_before_start = !is_fresh_start; + if let Some(local_tip) = local_tip + && had_checkpoint_before_start + && channel_tip_slot.is_none() + { + return Err(anyhow!( + "Sequencer holds committed blocks (tip {local_tip}) but the Bedrock channel \ + no longer exists on the connected chain โ€” the channel was wiped or the node \ + points at a different chain. Refusing to resume onto a foreign channel." + )); + } + + let divergence_error = |mismatch: &ChainMismatch| { + anyhow!( + "Sequencer store diverges from the Bedrock channel ({mismatch}). \ + Delete the sequencer storage directory or point at the correct channel." + ) + }; + + // With a recorded anchor, probe the channel for positive evidence of a + // different chain: the frontier upfront (a missing/behind channel serves + // no messages to scan), then the anchor block as messages stream in. + let mut consistency_check = anchor_record.map(|record| { + let anchor = Anchor::new( + Slot::from(record.slot), + Some((record.block_id, record.hash)), + ); + let mut check = AnchorConsistencyCheck::new(anchor); + check.check_frontier(channel_tip_slot); + check + }); + if let Some(ChainConsistency::Inconsistent(mismatch)) = consistency_check + .as_ref() + .and_then(AnchorConsistencyCheck::verdict) + { + return Err(divergence_error(mismatch)); + } + + // Verify each message against the anchor and replay the + // blocks (applying the ones we miss, checking the ones we hold). + let messages = publisher + .read_channel_after(after_slot) + .await + .context("Failed to read channel history for reconstruction")?; + let mut messages = std::pin::pin!(messages); + let mut channel_is_empty = true; + while let Some((message, slot)) = messages.next().await { + if let Some(check) = &mut consistency_check + && let Some(ChainConsistency::Inconsistent(mismatch)) = + check.observe(&message, slot) + { + return Err(divergence_error(mismatch)); + } + + let ZoneMessage::Block(zone_block) = message else { + continue; + }; + let block: Block = borsh::from_slice(&zone_block.data).map_err(|err| { + anyhow!( + "Failed to deserialize channel block at slot {}: {err}", + slot.into_inner() + ) + })?; + channel_is_empty = false; + // Locked per message (not across the stream `await`): concurrent + // follow events interleave safely โ€” both paths apply idempotently + // and persist under this same lock. + let mut chain = chain.lock().expect("chain state mutex poisoned"); + Self::apply_reconstructed_block(store, &mut chain, &block, slot)?; + } + + Ok(channel_is_empty) + } + + /// Applies a single channel block during reconstruction: idempotent for + /// blocks we already hold (verifying their hash), a validated continuation + /// for new ones. Advances the persisted anchor to the block's slot. + fn apply_reconstructed_block( + store: &SequencerStore, + chain: &mut ChainState, + block: &Block, + slot: Slot, + ) -> Result<()> { + let tip = store + .latest_block_meta() + .context("Failed to read latest block meta")?; + let block_id = block.header.block_id; + let block_hash = block.header.hash; + + let record = ZoneAnchorRecord { + slot: slot.into_inner(), + block_id, + hash: block_hash, + }; + + // A block at/below the tip must match what we already stored, otherwise + // the channel is a different chain. + if let Some(tip) = &tip + && block_id <= tip.id + { + match store + .get_block_at_id(block_id) + .context("Failed to read stored block")? + { + Some(stored) if stored.header.hash == block_hash => { + store + .set_zone_anchor(&record) + .context("Failed to persist zone anchor")?; + return Ok(()); + } + Some(stored) => { + return Err(anyhow!( + "Channel block {block_id} hash {block_hash} does not match stored hash {}", + stored.header.hash + )); + } + None => { + return Err(anyhow!( + "Channel block {block_id} is at/below local tip {} but is missing locally", + tip.id + )); + } + } + } + + // New continuation: channel history is finalized, so it goes through + // the final tier โ€” validation happens inside `apply_finalized`. + match chain.apply_finalized(MsgId::from(block.header.hash.0), block, slot) { + AcceptOutcome::Applied | AcceptOutcome::AlreadyApplied => {} + AcceptOutcome::Parked(err) | AcceptOutcome::RetryableFailure(err) => { + return Err(anyhow!( + "Channel block {block_id} does not extend local tip {:?}: {err}", + tip.map(|tip| tip.id) + )); + } + } + + // Persist like the follow path: the tip meta stays pinned to the head + // tip even when the reconstructed block lands below it. + let head_tip = chain.head_tip().map(|head| BlockMeta::from(&head)); + let final_meta = chain.final_tip().map(|meta| BlockMeta::from(&meta)); + store + .dbio() + .store_followed_blocks( + &[(block, true)], + head_tip.as_ref(), + chain.head_state(), + final_meta.as_ref().map(|meta| (chain.final_state(), meta)), + ) + .context("Failed to persist reconstructed block")?; + + // Mark the deposits' pending records submitted so the production-time + // guard drops the mints cold-start backfill re-queued for them. Withdraw + // intents are deliberately not counted: backfill already re-delivered and + // dropped their finalized L1 events, so an increment here would never be + // consumed and would leave a phantom count. + let deposit_event_ids: Vec<_> = block + .body + .transactions + .iter() + .filter_map(extract_bridge_deposit_id) + .collect(); + store + .mark_deposit_events_submitted(&deposit_event_ids, block_id) + .context("Failed to mark reconstructed deposits submitted")?; + + store + .set_zone_anchor(&record) + .context("Failed to persist zone anchor")?; + Ok(()) + } + fn on_checkpoint(dbio: Arc) -> block_publisher::CheckpointSink { Box::new(move |cp| { let bytes = match serde_json::to_vec(&cp) { @@ -335,6 +608,17 @@ impl SequencerCore { }) } + /// Publisher sink adapter over [`apply_follow_update`]. + fn on_follow( + dbio: Arc, + chain: Arc>, + mempool_handle: MemPoolHandle<(TransactionOrigin, LeeTransaction)>, + ) -> block_publisher::OnFollowSink { + Box::new(move |update: block_publisher::FollowUpdate| { + apply_follow_update(&dbio, &chain, &mempool_handle, update); + }) + } + /// Produces a new block from mempool transactions and publishes it via zone-sdk. pub async fn produce_new_block(&mut self) -> Result { let BlockWithMeta { @@ -351,26 +635,77 @@ impl SequencerCore { .collect::>() .context("Failed to build reconciliation keys for block withdrawals")?; - self.block_publisher + let this_msg = self + .block_publisher .publish_block(&block, withdrawals) .await .context("Failed to publish block to Bedrock")?; - self.store.update( + self.record_produced_block( + this_msg, &block, &deposit_event_ids, withdrawal_reconciliation_keys, - &self.state, )?; - Ok(self.chain_height) + Ok(block.header.block_id) + } + + /// Applies our own freshly-published block to the head with the [`MsgId`] the + /// publish assigned it, so the head advances and the later adopted + /// redelivery dedups, then persists it. + /// + /// Persistence is gated on the block actually becoming the head: if a peer + /// block won this height while we were publishing (`AlreadyApplied`, or + /// `Parked` when the head reorged to a different parent), the canonical + /// block is persisted by the follow path instead, and our invalidated + /// inscription comes back via `orphaned`. + fn record_produced_block( + &mut self, + this_msg: MsgId, + block: &Block, + deposit_event_ids: &[HashType], + withdrawal_reconciliation_keys: Vec, + ) -> Result<()> { + let mut chain = self.chain.lock().expect("chain state mutex poisoned"); + match chain.apply_produced(this_msg, block) { + AcceptOutcome::Applied => { + // Persisted under the lock so disk writes land in apply order + // with the follow path. + self.store.update( + block, + deposit_event_ids, + withdrawal_reconciliation_keys, + chain.head_state(), + )?; + } + AcceptOutcome::AlreadyApplied => { + warn!( + "Produced block {} lost a competing-write race, skipping persistence", + block.header.block_id + ); + } + AcceptOutcome::Parked(err) | AcceptOutcome::RetryableFailure(err) => { + warn!( + "Produced block {} no longer chains on the head, skipping persistence: {err}", + block.header.block_id + ); + } + } + + Ok(()) } /// Validates and applies a single mempool transaction to the current state. /// Returns `Ok(true)` if the transaction was valid and applied, `Ok(false)` if /// it was skipped due to validation failure. + #[expect( + clippy::too_many_arguments, + reason = "splitting the produce-path accumulators into a struct buys nothing" + )] fn apply_mempool_transaction( - &mut self, + store: &SequencerStore, + state: &mut lee::V03State, origin: TransactionOrigin, tx: &LeeTransaction, block_height: u64, @@ -381,11 +716,7 @@ impl SequencerCore { let tx_hash = tx.hash(); match origin { TransactionOrigin::User => { - let validated_diff = match tx.validate_on_state( - &self.state, - block_height, - timestamp, - ) { + let validated_diff = match tx.validate_on_state(state, block_height, timestamp) { Ok(diff) => diff, Err(err) => { error!( @@ -399,7 +730,7 @@ impl SequencerCore { withdrawals.push(withdraw_data); } - self.state.apply_state_diff(validated_diff); + state.apply_state_diff(validated_diff); } TransactionOrigin::Sequencer => { let LeeTransaction::Public(public_tx) = tx else { @@ -407,10 +738,17 @@ impl SequencerCore { }; if let Some(deposit_op_id) = extract_bridge_deposit_id(tx) { + if store + .is_deposit_event_submitted(deposit_op_id) + .context("Failed to check whether deposit was already submitted")? + { + info!("Skipping already-submitted bridge deposit {deposit_op_id}"); + return Ok(false); + } deposit_event_ids.push(deposit_op_id); } - self.state + state .transition_from_public_transaction(public_tx, block_height, timestamp) .context("Failed to execute sequencer-generated transaction")?; } @@ -423,7 +761,18 @@ impl SequencerCore { fn build_block_from_mempool(&mut self) -> Result { let now = Instant::now(); - let new_block_height = self.next_block_id(); + // Build on the head: its tip is the parent, its state the validation base. + let (prev_block_hash, new_block_height, mut working_state) = { + let chain = self.chain.lock().expect("chain state mutex poisoned"); + let tip = chain.head_tip(); + let height = tip.as_ref().map_or(GENESIS_BLOCK_ID, |head| { + head.block_id + .checked_add(1) + .expect("block id should not overflow") + }); + let prev = tip.map_or(HashType([0; 32]), |head| head.hash); + (prev, height, chain.head_state().clone()) + }; let mut valid_transactions = Vec::new(); let mut deposit_event_ids = Vec::new(); @@ -432,11 +781,6 @@ impl SequencerCore { let max_block_size = usize::try_from(self.sequencer_config.max_block_size.as_u64()) .expect("`max_block_size` should fit into usize"); - let latest_block_meta = self - .store - .latest_block_meta() - .context("Failed to get latest block meta from store")?; - let new_block_timestamp = u64::try_from(chrono::Utc::now().timestamp_millis()) .expect("Timestamp must be positive"); @@ -455,7 +799,7 @@ impl SequencerCore { let temp_hashable_data = HashableBlockData { block_id: new_block_height, transactions: temp_valid_transactions, - prev_block_hash: latest_block_meta.hash, + prev_block_hash, timestamp: new_block_timestamp, }; @@ -472,7 +816,9 @@ impl SequencerCore { break; } - if self.apply_mempool_transaction( + if Self::apply_mempool_transaction( + &self.store, + &mut working_state, origin, &tx, new_block_height, @@ -488,7 +834,7 @@ impl SequencerCore { } } - self.state + working_state .transition_from_public_transaction(&clock_tx, new_block_height, new_block_timestamp) .context("Clock transaction failed. Aborting block production.")?; valid_transactions.push(clock_lee_tx); @@ -496,7 +842,7 @@ impl SequencerCore { let hashable_data = HashableBlockData { block_id: new_block_height, transactions: valid_transactions, - prev_block_hash: latest_block_meta.hash, + prev_block_hash, timestamp: new_block_timestamp, }; @@ -504,8 +850,6 @@ impl SequencerCore { .clone() .into_pending_block(self.store.signing_key()); - self.chain_height = new_block_height; - log::info!( "Created block with {} transactions in {} seconds", hashable_data.transactions.len(), @@ -519,16 +863,27 @@ impl SequencerCore { }) } - pub const fn state(&self) -> &lee::V03State { - &self.state + /// Reads the current head state under the lock without cloning it, so callers + /// reuse `V03State`'s own API (accounts, nonces, proofs) with no whole-state copy. + pub fn with_state(&self, f: impl FnOnce(&lee::V03State) -> R) -> R { + f(self + .chain + .lock() + .expect("chain state mutex poisoned") + .head_state()) } pub const fn block_store(&self) -> &SequencerStore { &self.store } - pub const fn chain_height(&self) -> u64 { - self.chain_height + #[must_use] + pub fn chain_height(&self) -> u64 { + self.chain + .lock() + .expect("chain state mutex poisoned") + .head_tip() + .map_or(0, |tip| tip.block_id) } pub const fn sequencer_config(&self) -> &SequencerConfig { @@ -561,14 +916,21 @@ impl SequencerCore { .collect()) } - pub fn block_publisher(&self) -> BP { - self.block_publisher.clone() + pub const fn block_publisher(&self) -> &BP { + &self.block_publisher } - fn next_block_id(&self) -> u64 { - self.chain_height - .checked_add(1) - .unwrap_or_else(|| panic!("Max block height reached: {}", self.chain_height)) + /// Whether this sequencer is currently authorized to write to the channel. + #[must_use] + pub fn is_our_turn(&self) -> bool { + self.block_publisher.is_our_turn() + } + + /// Shared handle to the two-tier follow state, for tests to drive the + /// follow path directly. + #[cfg(all(test, feature = "mock"))] + fn chain(&self) -> Arc> { + Arc::clone(&self.chain) } } @@ -578,6 +940,105 @@ struct BlockWithMeta { withdrawals: Vec, } +/// Feed one channel delta into the follow state and mirror it to the store: +/// revert orphaned, then apply and persist adopted and finalized blocks. +/// Production builds on this same head. Wired to the publisher via +/// [`SequencerCore::on_follow`]; a free function so tests can drive it directly. +/// +/// TODO: unlike the indexer's ingest loop, this path does not retry +/// `is_retryable` (transient) apply failures โ€” a failed block just parks and +/// relies on a valid successor or a restart. `ChainState` never emits +/// `AcceptOutcome::RetryableFailure` yet; adding retry parity here is a +/// follow-up. +fn apply_follow_update( + dbio: &RocksDBIO, + chain: &Mutex, + mempool_handle: &MemPoolHandle<(TransactionOrigin, LeeTransaction)>, + update: block_publisher::FollowUpdate, +) { + let block_publisher::FollowUpdate { + adopted, + orphaned, + finalized, + } = update; + + // The lock is held across the persist below so disk writes land in apply + // order โ€” the produce path persists under this same lock. + let resubmit_txs = { + let mut chain = chain.lock().expect("chain state mutex poisoned"); + + // User txs of orphaned blocks, returned to the mempool below. + let resubmit_txs: Vec = orphaned + .iter() + .flat_map(|(_, block)| resubmittable_txs(block)) + .collect(); + + // Outcomes align with `adopted`. + let outcomes = chain.apply_channel_update(&orphaned, &adopted); + let mut to_persist: Vec<(&Block, bool)> = adopted + .iter() + .zip(&outcomes) + .filter(|(_, outcome)| matches!(outcome, AcceptOutcome::Applied)) + .map(|((_, block), _)| (block, false)) + .collect(); + + let mut final_advanced = false; + for (this_msg, block) in &finalized { + // FIXME: thread the finalized inscription's L1 slot once the + // sdk surfaces it; only used for the invalid-finalized stall. + if matches!( + chain.apply_finalized(*this_msg, block, Slot::from(0)), + AcceptOutcome::Applied + ) { + to_persist.push((block, true)); + final_advanced = true; + } + } + // Snapshot the advanced final tier so a restart re-anchors on it. + let final_meta = final_advanced.then(|| { + let tip = chain.final_tip().expect("advanced final tier has a tip"); + BlockMeta::from(&tip) + }); + let head_tip = chain.head_tip().map(|tip| BlockMeta::from(&tip)); + + // One atomic write for the whole update: blocks, tip meta and the + // state after the last block land together, so a crash can never + // leave the stored state ahead of the stored blocks. A persist + // failure is fatal: the in-memory chain has already advanced, and + // continuing would leave a permanent gap in the store. + // + // The `panic!` ends the drive task, whose cancellation halts the node. + // + // TODO: the zone-sdk checkpoint is persisted by `on_checkpoint` + // before this write; a crash in between resumes past these blocks + // without them ever landing in the store. Full `BlocksProcessed` + // atomicity (checkpoint + blocks + state in one batch, per the sdk's + // event contract) is a follow-up. + dbio.store_followed_blocks( + &to_persist, + head_tip.as_ref(), + chain.head_state(), + final_meta.as_ref().map(|meta| (chain.final_state(), meta)), + ) + .unwrap_or_else(|err| panic!("Failed to persist followed blocks: {err:#}")); + + resubmit_txs + }; + + // Rebuild orphaned work: return its user txs to the mempool so the + // next on-turn production re-includes them on the new head. + // + // We use [`try_push`] here because this is called from the + // publisher's drive task, and only the block production drains the mempool. + // A blocking push on a full mempool would deadlock here. + for tx in resubmit_txs { + let tx_hash = tx.hash(); + if let Err(err) = mempool_handle.try_push((TransactionOrigin::User, tx)) { + warn!("Dropping orphaned transaction {tx_hash} on resubmit: {err}"); + } + } +} + /// Checks the database for any pending deposit events that have not yet been marked as submitted in /// a block, and re-queues them in the mempool in a separate async task for inclusion in the next /// block. @@ -627,41 +1088,15 @@ fn replay_unfulfilled_deposit_events( }); } -/// Builds the initial genesis state from `testnet_initial_state` plus configured genesis -/// transactions. Returns the final state and the list of [`LeeTransaction`]s that should be -/// committed to the genesis block so external observers can replay them. -fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec) { +/// The pre-genesis state: `testnet_initial_state` plus accounts seeded outside +/// any transaction (bridge-lock holdings, the cross-zone inbox config). +fn build_initial_state(config: &SequencerConfig) -> lee::V03State { #[cfg(not(feature = "testnet"))] let mut state = testnet_initial_state::initial_state(); #[cfg(feature = "testnet")] let mut state = testnet_initial_state::initial_state_testnet(); - let genesis_txs = config - .genesis - .iter() - .filter_map(|genesis_tx| match genesis_tx { - GenesisAction::SupplyAccount { - account_id, - balance, - } => Some(build_supply_account_genesis_transaction( - account_id, *balance, - )), - GenesisAction::SupplyBridgeAccount { 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| { - state - .transition_from_public_transaction(tx, GENESIS_BLOCK_ID, 0) - .expect("Failed to execute genesis transaction"); - }) - .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 { @@ -679,6 +1114,41 @@ fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec (lee::V03State, Vec) { + let mut state = build_initial_state(config); + + let genesis_txs = config + .genesis + .iter() + .filter_map(|genesis_tx| match genesis_tx { + GenesisAction::SupplyAccount { + account_id, + balance, + } => Some(build_supply_account_genesis_transaction( + account_id, *balance, + )), + GenesisAction::SupplyBridgeAccount { balance } => { + Some(build_supply_bridge_account_genesis_transaction(*balance)) + } + // Force-inserted in `build_initial_state`: bridge_lock has no mint transaction. + GenesisAction::SupplyBridgeLockHolding { .. } => None, + }) + .chain(std::iter::once(clock_invocation(0))) + .inspect(|tx| { + state + .transition_from_public_transaction(tx, GENESIS_BLOCK_ID, 0) + .expect("Failed to execute genesis transaction"); + }) + .map(LeeTransaction::Public) + .collect(); + (state, genesis_txs) } @@ -743,7 +1213,7 @@ fn pending_deposit_event_record(deposit: &DepositInfo) -> PendingDepositEventRec } fn build_bridge_deposit_tx_from_event(event: &PendingDepositEventRecord) -> Result { - let metadata = DepositMetadata::decode(&event.metadata) + let metadata = DepositMetadata::try_from_slice(&event.metadata) .context("Failed to decode finalized Bedrock deposit metadata")?; let bridge_program_id = programs::bridge().id(); @@ -771,6 +1241,26 @@ fn build_bridge_deposit_tx_from_event(event: &PendingDepositEventRecord) -> Resu ))) } +/// User transactions of an orphaned block to return to the mempool: everything +/// except the trailing clock tx, sequencer-generated bridge deposits (replayed +/// from their own bedrock events) and sequencer-only cross-zone txs (replayed +/// by the watcher; the ingress guard rejects them as `User`). +fn resubmittable_txs(block: &Block) -> Vec { + let Some((_clock, rest)) = block.body.transactions.split_last() else { + return Vec::new(); + }; + rest.iter() + .filter(|tx| extract_bridge_deposit_id(tx).is_none() && !is_sequencer_only_tx(tx)) + .cloned() + .collect() +} + +#[must_use] +fn is_sequencer_only_tx(tx: &LeeTransaction) -> bool { + matches!(tx, LeeTransaction::Public(tx) + if is_sequencer_only_program(tx.message().program_id)) +} + #[must_use] fn extract_bridge_deposit_id(tx: &LeeTransaction) -> Option { let LeeTransaction::Public(tx) = tx else { @@ -860,7 +1350,7 @@ fn withdraw_event_reconciliation_key( } /// Load signing key from file or generate a new one if it doesn't exist. -fn load_or_create_signing_key(path: &Path) -> Result { +pub fn load_or_create_signing_key(path: &Path) -> Result { if path.exists() { let key_bytes = std::fs::read(path)?; diff --git a/lez/sequencer/core/src/mock.rs b/lez/sequencer/core/src/mock.rs index 39f635f9..4f2552b7 100644 --- a/lez/sequencer/core/src/mock.rs +++ b/lez/sequencer/core/src/mock.rs @@ -2,13 +2,15 @@ use std::time::Duration; use anyhow::Result; use common::block::Block; -use logos_blockchain_core::mantle::ops::channel::ChannelId; +use futures::Stream; +use logos_blockchain_core::mantle::ops::channel::{ChannelId, MsgId}; use logos_blockchain_key_management_system_service::keys::Ed25519Key; -use logos_blockchain_zone_sdk::sequencer::WithdrawArg; +use logos_blockchain_zone_sdk::{Slot, ZoneMessage, sequencer::WithdrawArg}; +use tokio_util::sync::CancellationToken; use crate::{ block_publisher::{ - BlockPublisherTrait, CheckpointSink, FinalizedBlockSink, OnDepositEventSink, + BlockPublisherTrait, CheckpointSink, FinalizedBlockSink, OnDepositEventSink, OnFollowSink, OnWithdrawEventSink, SequencerCheckpoint, }, config::BedrockConfig, @@ -19,6 +21,31 @@ pub type SequencerCoreWithMockClients = crate::SequencerCore #[derive(Clone)] pub struct MockBlockPublisher { channel_id: ChannelId, + // Never cancelled: the mock driver never dies. + driver_cancellation: CancellationToken, + /// Canned channel frontier returned by [`Self::channel_tip_slot`]. + tip_slot: Option, + /// Canned finalized channel history returned by [`Self::read_channel_after`]. + messages: Vec<(ZoneMessage, Slot)>, +} + +impl MockBlockPublisher { + /// Builds a mock publisher backed by a canned channel, for reconstruction + /// and consistency tests. The default (via [`BlockPublisherTrait::new`]) + /// serves an empty channel. + #[must_use] + pub fn with_canned_channel( + channel_id: ChannelId, + tip_slot: Option, + messages: Vec<(ZoneMessage, Slot)>, + ) -> Self { + Self { + channel_id, + driver_cancellation: CancellationToken::new(), + tip_slot, + messages, + } + } } impl BlockPublisherTrait for MockBlockPublisher { @@ -31,21 +58,53 @@ impl BlockPublisherTrait for MockBlockPublisher { _on_finalized_block: FinalizedBlockSink, _on_deposit_event: OnDepositEventSink, _on_withdraw_event: OnWithdrawEventSink, + _on_follow: OnFollowSink, ) -> Result { Ok(Self { channel_id: config.channel_id, + driver_cancellation: CancellationToken::new(), + tip_slot: None, + messages: Vec::new(), }) } async fn publish_block( &self, - _block: &Block, + block: &Block, _bridge_withdrawals: Vec, - ) -> Result<()> { - Ok(()) + ) -> Result { + // Deterministic per-block id so head dedup behaves in tests. + // + // TODO: should we allow more "mockability" here? + Ok(MsgId::from(block.header.hash.0)) } fn channel_id(&self) -> ChannelId { self.channel_id } + + fn is_our_turn(&self) -> bool { + true + } + + fn driver_cancellation(&self) -> CancellationToken { + self.driver_cancellation.clone() + } + + async fn channel_tip_slot(&self) -> Result> { + Ok(self.tip_slot) + } + + async fn read_channel_after( + &self, + after_slot: Option, + ) -> Result + '_> { + // Mirror `next_messages`: `after_slot` is exclusive. + let messages = self + .messages + .iter() + .filter(move |(_, slot)| after_slot.is_none_or(|after| *slot > after)) + .cloned(); + Ok(futures::stream::iter(messages)) + } } diff --git a/lez/sequencer/core/src/tests.rs b/lez/sequencer/core/src/tests.rs index 05e2ec4c..4b6e4cae 100644 --- a/lez/sequencer/core/src/tests.rs +++ b/lez/sequencer/core/src/tests.rs @@ -4,7 +4,7 @@ use std::{pin::pin, time::Duration}; use common::{ HashType, - block::HashableBlockData, + block::{BedrockStatus, HashableBlockData}, test_utils::sequencer_sign_key_for_testing, transaction::{LeeTransaction, clock_invocation}, }; @@ -22,21 +22,25 @@ use lee_core::{ account::{AccountWithMetadata, Nonce}, program::PdaSeed, }; -use logos_blockchain_core::mantle::ops::channel::ChannelId; +use logos_blockchain_core::mantle::ops::channel::{ChannelId, MsgId}; use mempool::MemPoolHandle; use storage::sequencer::sequencer_cells::PendingDepositEventRecord; use tempfile::tempdir; use testnet_initial_state::{initial_pub_accounts_private_keys, initial_public_user_accounts}; use crate::{ - TransactionOrigin, + TransactionOrigin, apply_follow_update, + block_publisher::FollowUpdate, block_store::SequencerStore, - build_genesis_state, + build_bridge_deposit_tx_from_event, build_genesis_state, config::{BedrockConfig, SequencerConfig}, is_sequencer_only_program, mock::SequencerCoreWithMockClients, + resubmittable_txs, }; +mod reconstruction; + #[derive(borsh::BorshSerialize)] struct DepositMetadataForEncoding { recipient_id: lee::AccountId, @@ -146,14 +150,14 @@ async fn start_from_config() { let (sequencer, _mempool_handle) = SequencerCoreWithMockClients::start_from_config(config.clone()).await; - assert_eq!(sequencer.chain_height, 1); + assert_eq!(sequencer.chain_height(), 1); assert_eq!(sequencer.sequencer_config.max_num_tx_in_block, 10); let acc1_account_id = initial_public_user_accounts()[0].account_id; let acc2_account_id = initial_public_user_accounts()[1].account_id; - let balance_acc_1 = sequencer.state.get_account_by_id(acc1_account_id).balance; - let balance_acc_2 = sequencer.state.get_account_by_id(acc2_account_id).balance; + let balance_acc_1 = sequencer.with_state(|s| s.get_account_by_id(acc1_account_id).balance); + let balance_acc_2 = sequencer.with_state(|s| s.get_account_by_id(acc2_account_id).balance); assert_eq!(10000, balance_acc_1); assert_eq!(20000, balance_acc_2); @@ -186,7 +190,7 @@ async fn start_from_config_opens_existing_db_if_it_exists() { let (sequencer, _mempool_handle) = SequencerCoreWithMockClients::start_from_config(config).await; - assert_eq!(sequencer.chain_height, 1); + assert_eq!(sequencer.chain_height(), 1); assert!(sequencer.store.latest_block_meta().is_ok()); } @@ -295,7 +299,7 @@ async fn transaction_pre_check_native_transfer_valid() { #[tokio::test] async fn transaction_pre_check_native_transfer_other_signature() { - let (mut sequencer, _mempool_handle) = common_setup().await; + let (sequencer, _mempool_handle) = common_setup().await; let acc1 = initial_public_user_accounts()[0].account_id; let acc2 = initial_public_user_accounts()[1].account_id; @@ -309,7 +313,15 @@ async fn transaction_pre_check_native_transfer_other_signature() { let tx = tx.transaction_stateless_check().unwrap(); // Signature is not from sender. Execution fails - let result = tx.execute_check_on_state(&mut sequencer.state, 0, 0); + let result = tx.execute_check_on_state( + sequencer + .chain() + .lock() + .expect("chain mutex poisoned") + .head_state_mut(), + 0, + 0, + ); assert!(matches!( result, @@ -319,7 +331,7 @@ async fn transaction_pre_check_native_transfer_other_signature() { #[tokio::test] async fn transaction_pre_check_native_transfer_sent_too_much() { - let (mut sequencer, _mempool_handle) = common_setup().await; + let (sequencer, _mempool_handle) = common_setup().await; let acc1 = initial_public_user_accounts()[0].account_id; let acc2 = initial_public_user_accounts()[1].account_id; @@ -335,9 +347,15 @@ async fn transaction_pre_check_native_transfer_sent_too_much() { // Passed pre-check assert!(result.is_ok()); - let result = result - .unwrap() - .execute_check_on_state(&mut sequencer.state, 0, 0); + let result = result.unwrap().execute_check_on_state( + sequencer + .chain() + .lock() + .expect("chain mutex poisoned") + .head_state_mut(), + 0, + 0, + ); let is_failed_at_balance_mismatch = matches!( result.err().unwrap(), lee::error::LeeError::ProgramExecutionFailed(_) @@ -348,7 +366,7 @@ async fn transaction_pre_check_native_transfer_sent_too_much() { #[tokio::test] async fn transaction_execute_native_transfer() { - let (mut sequencer, _mempool_handle) = common_setup().await; + let (sequencer, _mempool_handle) = common_setup().await; let acc1 = initial_public_user_accounts()[0].account_id; let acc2 = initial_public_user_accounts()[1].account_id; @@ -359,11 +377,19 @@ async fn transaction_execute_native_transfer() { acc1, 0, acc2, 100, &sign_key1, ); - tx.execute_check_on_state(&mut sequencer.state, 0, 0) - .unwrap(); + tx.execute_check_on_state( + sequencer + .chain() + .lock() + .expect("chain mutex poisoned") + .head_state_mut(), + 0, + 0, + ) + .unwrap(); - let bal_from = sequencer.state.get_account_by_id(acc1).balance; - let bal_to = sequencer.state.get_account_by_id(acc2).balance; + let bal_from = sequencer.with_state(|s| s.get_account_by_id(acc1).balance); + let bal_to = sequencer.with_state(|s| s.get_account_by_id(acc2).balance); assert_eq!(bal_from, 9900); assert_eq!(bal_to, 20100); @@ -400,7 +426,7 @@ async fn push_tx_into_mempool_blocks_until_mempool_is_full() { #[tokio::test] async fn build_block_from_mempool() { let (mut sequencer, mempool_handle) = common_setup().await; - let genesis_height = sequencer.chain_height; + let genesis_height = sequencer.chain_height(); let tx = common::test_utils::produce_dummy_empty_transaction(); mempool_handle @@ -410,7 +436,8 @@ async fn build_block_from_mempool() { let result = sequencer.build_block_from_mempool(); assert!(result.is_ok()); - assert_eq!(sequencer.chain_height, genesis_height + 1); + // Building itself does not advance the head; only apply-after-publish does. + assert_eq!(sequencer.chain_height(), genesis_height); } #[tokio::test] @@ -442,7 +469,7 @@ async fn replay_transactions_are_rejected_in_the_same_block() { sequencer.produce_new_block().await.unwrap(); let block = sequencer .store - .get_block_at_id(sequencer.chain_height) + .get_block_at_id(sequencer.chain_height()) .unwrap() .unwrap(); @@ -477,7 +504,7 @@ async fn replay_transactions_are_rejected_in_different_blocks() { sequencer.produce_new_block().await.unwrap(); let block = sequencer .store - .get_block_at_id(sequencer.chain_height) + .get_block_at_id(sequencer.chain_height()) .unwrap() .unwrap(); assert_eq!( @@ -496,7 +523,7 @@ async fn replay_transactions_are_rejected_in_different_blocks() { sequencer.produce_new_block().await.unwrap(); let block = sequencer .store - .get_block_at_id(sequencer.chain_height) + .get_block_at_id(sequencer.chain_height()) .unwrap() .unwrap(); // The replay is rejected, so only the clock tx is in the block. @@ -538,7 +565,7 @@ async fn restart_from_storage() { sequencer.produce_new_block().await.unwrap(); let block = sequencer .store - .get_block_at_id(sequencer.chain_height) + .get_block_at_id(sequencer.chain_height()) .unwrap() .unwrap(); assert_eq!( @@ -554,8 +581,8 @@ async fn restart_from_storage() { // with the above transaction and update the state to reflect that. let (sequencer, _mempool_handle) = SequencerCoreWithMockClients::start_from_config(config.clone()).await; - let balance_acc_1 = sequencer.state.get_account_by_id(acc1_account_id).balance; - let balance_acc_2 = sequencer.state.get_account_by_id(acc2_account_id).balance; + let balance_acc_1 = sequencer.with_state(|s| s.get_account_by_id(acc1_account_id).balance); + let balance_acc_2 = sequencer.with_state(|s| s.get_account_by_id(acc2_account_id).balance); // Balances should be consistent with the stored block assert_eq!( @@ -625,7 +652,7 @@ async fn produce_block_with_correct_prev_meta_after_restart() { sequencer.produce_new_block().await.unwrap(); // Get the metadata of the last block produced - sequencer.store.latest_block_meta().unwrap() + sequencer.store.latest_block_meta().unwrap().unwrap() }; // Step 2: Restart sequencer from the same storage @@ -653,7 +680,7 @@ async fn produce_block_with_correct_prev_meta_after_restart() { // Step 5: Verify the new block has correct previous block metadata let new_block = sequencer .store - .get_block_at_id(sequencer.chain_height) + .get_block_at_id(sequencer.chain_height()) .unwrap() .unwrap(); @@ -705,7 +732,7 @@ async fn transactions_touching_clock_account_are_dropped_from_block() { let block = sequencer .store - .get_block_at_id(sequencer.chain_height) + .get_block_at_id(sequencer.chain_height()) .unwrap() .unwrap(); @@ -760,7 +787,7 @@ async fn user_tx_that_chain_calls_clock_is_dropped() { let block = sequencer .store - .get_block_at_id(sequencer.chain_height) + .get_block_at_id(sequencer.chain_height()) .unwrap() .unwrap(); @@ -779,10 +806,13 @@ async fn block_production_aborts_when_clock_account_data_is_corrupted() { // Corrupt the clock 01 account data so the clock program panics on deserialization. let clock_account_id = system_accounts::clock_account_ids()[0]; - let mut corrupted = sequencer.state.get_account_by_id(clock_account_id); + let mut corrupted = sequencer.with_state(|s| s.get_account_by_id(clock_account_id)); corrupted.data = vec![0xff; 3].try_into().unwrap(); sequencer - .state + .chain() + .lock() + .expect("chain mutex poisoned") + .head_state_mut() .force_insert_account(clock_account_id, corrupted); // Push a dummy transaction so the mempool is non-empty. @@ -862,6 +892,7 @@ fn private_bridge_withdraw_invocation_is_dropped() { InputAccountIdentity::PrivateAuthorizedUpdate { vpk: sender_keys.viewing_public_key.clone(), random_seed: [0; 32], + view_tag: 0, nsk: sender_keys.private_key_holder.nullifier_secret_key, membership_proof: state .get_proof_for_commitment(&sender_commitment) @@ -1245,3 +1276,509 @@ fn pda_mechanism_with_pinata_token_program() { expected_winner_token_holding_post ); } + +#[test] +fn resubmittable_txs_drops_clock_and_bridge_deposits() { + let user_tx = common::test_utils::produce_dummy_empty_transaction(); + let deposit_tx = build_bridge_deposit_tx_from_event(&PendingDepositEventRecord { + deposit_op_id: HashType([13; 32]), + source_tx_hash: HashType([7; 32]), + amount: 1, + metadata: borsh::to_vec(&DepositMetadataForEncoding { + recipient_id: initial_public_user_accounts()[0].account_id, + }) + .unwrap(), + submitted_in_block_id: None, + }) + .unwrap(); + let withdraw_tx = { + let message = lee::public_transaction::Message::try_new( + programs::bridge().id(), + vec![system_accounts::bridge_account_id()], + vec![], + bridge_core::Instruction::Withdraw { + amount: 1, + bedrock_account_pk: [0; 32], + }, + ) + .unwrap(); + LeeTransaction::Public(PublicTransaction::new( + message, + lee::public_transaction::WitnessSet::from_raw_parts(vec![]), + )) + }; + + let block = common::test_utils::produce_dummy_block( + 2, + Some(HashType([1; 32])), + vec![user_tx.clone(), deposit_tx, withdraw_tx.clone()], + ); + + // The trailing clock tx and the sequencer-generated deposit are dropped; + // user txs (withdrawals included) are returned. + assert_eq!(resubmittable_txs(&block), vec![user_tx, withdraw_tx]); +} + +#[test] +fn resubmittable_txs_of_blocks_without_user_txs_is_empty() { + // No transactions at all (not even the mandatory clock tx). + let empty = HashableBlockData { + block_id: 1, + prev_block_hash: HashType([0; 32]), + timestamp: 0, + transactions: vec![], + } + .into_pending_block(&sequencer_sign_key_for_testing()); + assert!(resubmittable_txs(&empty).is_empty()); + + let clock_only = common::test_utils::produce_dummy_block(1, None, vec![]); + assert!(resubmittable_txs(&clock_only).is_empty()); +} + +#[tokio::test] +async fn follow_adopted_peer_block_applies_and_persists() { + let config = setup_sequencer_config(); + let (sequencer, mempool_handle) = SequencerCoreWithMockClients::start_from_config(config).await; + let genesis_meta = sequencer + .store + .latest_block_meta() + .unwrap() + .expect("genesis meta is set"); + + let acc1 = initial_public_user_accounts()[0].account_id; + let acc2 = initial_public_user_accounts()[1].account_id; + let tx = common::test_utils::create_transaction_native_token_transfer( + acc1, + 0, + acc2, + 10, + &create_signing_key_for_account1(), + ); + let peer_block = common::test_utils::produce_dummy_block(2, Some(genesis_meta.hash), vec![tx]); + + apply_follow_update( + &sequencer.store.dbio(), + &sequencer.chain(), + &mempool_handle, + FollowUpdate { + adopted: vec![(MsgId::from([1; 32]), peer_block.clone())], + orphaned: vec![], + finalized: vec![], + }, + ); + + assert_eq!(sequencer.chain_height(), 2); + let stored = sequencer + .store + .get_block_at_id(2) + .unwrap() + .expect("adopted peer block should be persisted"); + assert_eq!(stored.header.hash, peer_block.header.hash); + assert_eq!( + sequencer.with_state(|s| s.get_account_by_id(acc2).balance), + 20010 + ); +} + +#[tokio::test] +async fn follow_redelivery_of_own_block_is_deduped() { + let config = setup_sequencer_config(); + let (mut sequencer, mempool_handle) = + SequencerCoreWithMockClients::start_from_config(config).await; + + let acc1 = initial_public_user_accounts()[0].account_id; + let acc2 = initial_public_user_accounts()[1].account_id; + let tx = common::test_utils::create_transaction_native_token_transfer( + acc1, + 0, + acc2, + 10, + &create_signing_key_for_account1(), + ); + mempool_handle + .push((TransactionOrigin::User, tx)) + .await + .unwrap(); + sequencer.produce_new_block().await.unwrap(); + let block2 = sequencer.store.get_block_at_id(2).unwrap().unwrap(); + + // The channel redelivers our own block under the MsgId the mock publisher + // assigned at publish time. + apply_follow_update( + &sequencer.store.dbio(), + &sequencer.chain(), + &mempool_handle, + FollowUpdate { + adopted: vec![(MsgId::from(block2.header.hash.0), block2)], + orphaned: vec![], + finalized: vec![], + }, + ); + + assert_eq!(sequencer.chain_height(), 2); + assert_eq!( + sequencer.with_state(|s| s.get_account_by_id(acc2).balance), + 20010, + "the transfer must not be double-applied" + ); +} + +#[tokio::test] +async fn follow_orphan_reverts_head_and_requeues_user_txs() { + let config = setup_sequencer_config(); + let (mut sequencer, mempool_handle) = + SequencerCoreWithMockClients::start_from_config(config).await; + + let acc1 = initial_public_user_accounts()[0].account_id; + let acc2 = initial_public_user_accounts()[1].account_id; + let tx = common::test_utils::create_transaction_native_token_transfer( + acc1, + 0, + acc2, + 10, + &create_signing_key_for_account1(), + ); + mempool_handle + .push((TransactionOrigin::User, tx.clone())) + .await + .unwrap(); + sequencer.produce_new_block().await.unwrap(); + let block2 = sequencer.store.get_block_at_id(2).unwrap().unwrap(); + + apply_follow_update( + &sequencer.store.dbio(), + &sequencer.chain(), + &mempool_handle, + FollowUpdate { + adopted: vec![], + orphaned: vec![(MsgId::from(block2.header.hash.0), block2)], + finalized: vec![], + }, + ); + + assert_eq!(sequencer.chain_height(), 1); + assert_eq!( + sequencer.with_state(|s| s.get_account_by_id(acc1).balance), + 10000, + "the orphaned transfer must be reverted from the head" + ); + let (origin, requeued) = sequencer + .mempool + .pop() + .expect("orphaned user tx should be requeued"); + assert!(matches!(origin, TransactionOrigin::User)); + assert_eq!(requeued, tx); + assert!( + sequencer.mempool.pop().is_none(), + "the clock tx must not be requeued" + ); +} + +#[tokio::test] +async fn follow_finalized_own_block_moves_final_tier_and_marks_store() { + let config = setup_sequencer_config(); + let (mut sequencer, mempool_handle) = + SequencerCoreWithMockClients::start_from_config(config).await; + + let tx = common::test_utils::produce_dummy_empty_transaction(); + mempool_handle + .push((TransactionOrigin::User, tx)) + .await + .unwrap(); + sequencer.produce_new_block().await.unwrap(); + let block2 = sequencer.store.get_block_at_id(2).unwrap().unwrap(); + + apply_follow_update( + &sequencer.store.dbio(), + &sequencer.chain(), + &mempool_handle, + FollowUpdate { + adopted: vec![], + orphaned: vec![], + finalized: vec![(MsgId::from(block2.header.hash.0), block2)], + }, + ); + + let final_tip = sequencer + .chain() + .lock() + .expect("chain mutex poisoned") + .final_tip() + .expect("final tip set"); + assert_eq!(final_tip.block_id, 2); + assert_eq!(sequencer.chain_height(), 2, "head is unchanged"); + let stored = sequencer.store.get_block_at_id(2).unwrap().unwrap(); + assert!(matches!(stored.bedrock_status, BedrockStatus::Finalized)); +} + +#[tokio::test] +async fn follow_finalized_backfill_block_is_applied_and_marked_finalized() { + let config = setup_sequencer_config(); + let (sequencer, mempool_handle) = SequencerCoreWithMockClients::start_from_config(config).await; + let genesis_meta = sequencer + .store + .latest_block_meta() + .unwrap() + .expect("genesis meta is set"); + + // A peer block we never saw as adopted arrives straight from the + // finalized (backfill) stream. + let peer_block = common::test_utils::produce_dummy_block(2, Some(genesis_meta.hash), vec![]); + + apply_follow_update( + &sequencer.store.dbio(), + &sequencer.chain(), + &mempool_handle, + FollowUpdate { + adopted: vec![], + orphaned: vec![], + finalized: vec![(MsgId::from([2; 32]), peer_block.clone())], + }, + ); + + assert_eq!( + sequencer.chain_height(), + 2, + "head mirrors final on backfill" + ); + let stored = sequencer + .store + .get_block_at_id(2) + .unwrap() + .expect("backfilled block should be persisted"); + assert_eq!(stored.header.hash, peer_block.header.hash); + assert!(matches!(stored.bedrock_status, BedrockStatus::Finalized)); +} + +#[tokio::test] +async fn restart_restores_head_tier_and_recovers_from_orphan() { + let config = setup_sequencer_config(); + let acc1 = initial_public_user_accounts()[0].account_id; + let acc2 = initial_public_user_accounts()[1].account_id; + + // Produce block 2 (a user transfer), then "crash" before it finalizes. + let (tx, block2) = { + let (mut sequencer, mempool_handle) = + SequencerCoreWithMockClients::start_from_config(config.clone()).await; + let tx = common::test_utils::create_transaction_native_token_transfer( + acc1, + 0, + acc2, + 10, + &create_signing_key_for_account1(), + ); + mempool_handle + .push((TransactionOrigin::User, tx.clone())) + .await + .unwrap(); + sequencer.produce_new_block().await.unwrap(); + (tx, sequencer.store.get_block_at_id(2).unwrap().unwrap()) + }; + + // Restart: nothing is finalized, so block 2 must come back as *head*, not + // final โ€” the L1 can still orphan it. + let (mut sequencer, mempool_handle) = + SequencerCoreWithMockClients::start_from_config(config.clone()).await; + assert_eq!(sequencer.chain_height(), 2); + + // The L1 orphans block 2 under its real MsgId (which we never persisted) + // and adopts a competing empty block 2'. + let genesis = sequencer.store.get_block_at_id(1).unwrap().unwrap(); + let block2_prime = + common::test_utils::produce_dummy_block(2, Some(genesis.header.hash), vec![]); + apply_follow_update( + &sequencer.store.dbio(), + &sequencer.chain(), + &mempool_handle, + FollowUpdate { + adopted: vec![(MsgId::from([21; 32]), block2_prime.clone())], + orphaned: vec![(MsgId::from([20; 32]), block2)], + finalized: vec![], + }, + ); + + // The head reorged onto 2': transfer reverted, store overwritten, and the + // orphaned user tx returned to the mempool. + assert_eq!(sequencer.chain_height(), 2); + let head_tip = sequencer + .chain() + .lock() + .expect("chain mutex poisoned") + .head_tip() + .expect("head tip set"); + assert_eq!(head_tip.hash, block2_prime.header.hash); + assert_eq!( + sequencer.with_state(|s| s.get_account_by_id(acc1).balance), + 10000, + "the orphaned transfer must be reverted" + ); + let stored = sequencer.store.get_block_at_id(2).unwrap().unwrap(); + assert_eq!(stored.header.hash, block2_prime.header.hash); + let (origin, requeued) = sequencer + .mempool + .pop() + .expect("orphaned user tx should be requeued"); + assert!(matches!(origin, TransactionOrigin::User)); + assert_eq!(requeued, tx); +} + +#[tokio::test] +async fn restart_reanchors_on_the_persisted_final_snapshot() { + let config = setup_sequencer_config(); + + // Produce block 2 and follow its finalization, which persists the final + // snapshot; then "crash". + { + let (mut sequencer, mempool_handle) = + SequencerCoreWithMockClients::start_from_config(config.clone()).await; + let tx = common::test_utils::produce_dummy_empty_transaction(); + mempool_handle + .push((TransactionOrigin::User, tx)) + .await + .unwrap(); + sequencer.produce_new_block().await.unwrap(); + let block2 = sequencer.store.get_block_at_id(2).unwrap().unwrap(); + apply_follow_update( + &sequencer.store.dbio(), + &sequencer.chain(), + &mempool_handle, + FollowUpdate { + adopted: vec![], + orphaned: vec![], + finalized: vec![(MsgId::from(block2.header.hash.0), block2)], + }, + ); + } + + // Restart: the final tier re-anchors on the snapshot instead of treating + // the whole stored chain as final. + let (sequencer, _mempool_handle) = + SequencerCoreWithMockClients::start_from_config(config.clone()).await; + let chain = sequencer.chain(); + let chain = chain.lock().expect("chain mutex poisoned"); + assert_eq!(chain.final_tip().expect("final tip set").block_id, 2); + assert_eq!(chain.head_tip().expect("head tip set").block_id, 2); +} + +#[tokio::test] +async fn record_produced_block_skips_persistence_on_lost_race() { + let config = setup_sequencer_config(); + let (mut sequencer, _mempool_handle) = + SequencerCoreWithMockClients::start_from_config(config).await; + let genesis_meta = sequencer + .store + .latest_block_meta() + .unwrap() + .expect("genesis meta is set"); + + // A peer block wins height 2 while "our" block is in flight. + let peer_block = common::test_utils::produce_dummy_block(2, Some(genesis_meta.hash), vec![]); + sequencer + .chain() + .lock() + .expect("chain mutex poisoned") + .apply_adopted(MsgId::from([9; 32]), &peer_block); + + // Our competing block at the same height: same parent, different content. + let acc1 = initial_public_user_accounts()[0].account_id; + let acc2 = initial_public_user_accounts()[1].account_id; + let tx = common::test_utils::create_transaction_native_token_transfer( + acc1, + 0, + acc2, + 10, + &create_signing_key_for_account1(), + ); + let our_block = common::test_utils::produce_dummy_block(2, Some(genesis_meta.hash), vec![tx]); + sequencer + .record_produced_block( + MsgId::from(our_block.header.hash.0), + &our_block, + &[], + vec![], + ) + .unwrap(); + + // The lost-race block must not reach the store; the head keeps the peer block. + assert!(sequencer.store.get_block_at_id(2).unwrap().is_none()); + let head_tip = sequencer + .chain() + .lock() + .expect("chain mutex poisoned") + .head_tip() + .expect("head tip"); + assert_eq!(head_tip.hash, peer_block.header.hash); +} + +#[tokio::test] +async fn record_produced_block_skips_persistence_when_block_no_longer_chains() { + let config = setup_sequencer_config(); + let (mut sequencer, _mempool_handle) = + SequencerCoreWithMockClients::start_from_config(config).await; + + // The head reorged under us: our block's parent is no longer the tip. + let stale = common::test_utils::produce_dummy_block(2, Some(HashType([9; 32])), vec![]); + sequencer + .record_produced_block(MsgId::from(stale.header.hash.0), &stale, &[], vec![]) + .unwrap(); + + assert!(sequencer.store.get_block_at_id(2).unwrap().is_none()); + assert_eq!(sequencer.chain_height(), 1, "head is unchanged"); +} + +#[tokio::test] +async fn follow_update_persists_blocks_meta_and_state_atomically() { + let config = setup_sequencer_config(); + let (sequencer, mempool_handle) = SequencerCoreWithMockClients::start_from_config(config).await; + let genesis_meta = sequencer + .store + .latest_block_meta() + .unwrap() + .expect("genesis meta is set"); + + let acc1 = initial_public_user_accounts()[0].account_id; + let acc2 = initial_public_user_accounts()[1].account_id; + let tx = common::test_utils::create_transaction_native_token_transfer( + acc1, + 0, + acc2, + 10, + &create_signing_key_for_account1(), + ); + let block2 = common::test_utils::produce_dummy_block(2, Some(genesis_meta.hash), vec![tx]); + let block3 = common::test_utils::produce_dummy_block(3, Some(block2.header.hash), vec![]); + + // One update carrying several blocks: both adopted, block 2 also finalized. + apply_follow_update( + &sequencer.store.dbio(), + &sequencer.chain(), + &mempool_handle, + FollowUpdate { + adopted: vec![ + (MsgId::from([2; 32]), block2.clone()), + (MsgId::from([3; 32]), block3.clone()), + ], + orphaned: vec![], + finalized: vec![(MsgId::from([2; 32]), block2)], + }, + ); + + // Blocks, tip meta and state all reflect the end of the batch: a late + // finalized entry for an earlier block must not drag the tip meta back. + let meta = sequencer + .store + .latest_block_meta() + .unwrap() + .expect("meta is set"); + assert_eq!(meta.id, 3); + assert_eq!(meta.hash, block3.header.hash); + let stored2 = sequencer.store.get_block_at_id(2).unwrap().unwrap(); + assert!(matches!(stored2.bedrock_status, BedrockStatus::Finalized)); + let stored_balance = sequencer + .store + .get_lee_state() + .unwrap() + .get_account_by_id(acc2) + .balance; + assert_eq!(stored_balance, 20010); +} diff --git a/lez/sequencer/core/src/tests/reconstruction.rs b/lez/sequencer/core/src/tests/reconstruction.rs new file mode 100644 index 00000000..e52c3364 --- /dev/null +++ b/lez/sequencer/core/src/tests/reconstruction.rs @@ -0,0 +1,610 @@ +#![expect( + clippy::arithmetic_side_effects, + clippy::as_conversions, + reason = "We don't care about it in tests" +)] + +use std::sync::Mutex; + +use chain_state::ChainState; +use common::block::Block; +use logos_blockchain_core::mantle::ops::channel::{MsgId, inscribe::Inscription}; +use logos_blockchain_zone_sdk::{Slot, ZoneBlock, ZoneMessage}; +use storage::sequencer::sequencer_cells::ZoneAnchorRecord; + +use super::*; +use crate::{ + SequencerCore, block_store::SequencerStore, config::GenesisAction, mock::MockBlockPublisher, +}; + +/// Fresh `(store, chain)` pair for a reconstruction target, as +/// `start_from_config` would build them before the publisher starts. +fn fresh_store_and_chain(config: &SequencerConfig) -> (SequencerStore, Mutex) { + let (store, state) = SequencerCore::::open_or_create_store(config); + let chain = Mutex::new(SequencerCore::::restore_chain_state( + config, &store, &state, + )); + (store, chain) +} + +fn block_to_channel_message(block: &Block, slot: u64) -> (ZoneMessage, Slot) { + let bytes = borsh::to_vec(block).expect("serialize block"); + let message = ZoneMessage::Block(ZoneBlock { + id: MsgId::from([0_u8; 32]), + data: Inscription::try_from(bytes.as_slice()).expect("inscription"), + }); + (message, Slot::from(slot)) +} + +/// Collects a sequencer's whole chain (genesis..=tip) into a canned channel, +/// one block per slot at `slot_step` spacing. +fn channel_from_store(store: &SequencerStore, slot_step: u64) -> Vec<(ZoneMessage, Slot)> { + let genesis_id = store.genesis_id(); + let tip_id = store.latest_block_meta().expect("tip").expect("present").id; + (genesis_id..=tip_id) + .enumerate() + .map(|(index, id)| { + let block = store.get_block_at_id(id).expect("read").expect("present"); + block_to_channel_message(&block, (index as u64 + 1) * slot_step) + }) + .collect() +} + +#[tokio::test] +async fn reconstructs_missing_channel_blocks_into_fresh_store() { + // Sequencer A produces a few blocks; treat its chain as the channel. + let config_a = setup_sequencer_config(); + let (mut seq_a, _handle_a) = + SequencerCoreWithMockClients::start_from_config(config_a.clone()).await; + seq_a.produce_new_block().await.unwrap(); + seq_a.produce_new_block().await.unwrap(); + let tip_a = seq_a.block_store().latest_block_meta().unwrap().unwrap(); + + let messages = channel_from_store(seq_a.block_store(), 10); + let tip_slot = messages.last().unwrap().1; + let channel_id = config_a.bedrock_config.channel_id; + + // Sequencer B starts from a fresh store and reconstructs A's chain. + let config_b = setup_sequencer_config(); + let (store_b, chain_b) = fresh_store_and_chain(&config_b); + let mock_b = MockBlockPublisher::with_canned_channel(channel_id, Some(tip_slot), messages); + + let channel_was_empty = SequencerCore::::verify_and_reconstruct( + &mock_b, &store_b, &chain_b, true, + ) + .await + .expect("reconstruct"); + assert!(!channel_was_empty); + + let tip_b = store_b.latest_block_meta().unwrap().unwrap(); + assert_eq!(tip_b.id, tip_a.id); + assert_eq!(tip_b.hash, tip_a.hash); + + // State matches: initial account balances agree with sequencer A. + let state_b = chain_b.lock().unwrap().head_state().clone(); + let state_a = seq_a.chain().lock().unwrap().head_state().clone(); + for account in initial_public_user_accounts() { + assert_eq!( + state_b.get_account_by_id(account.account_id).balance, + state_a.get_account_by_id(account.account_id).balance, + ); + } + + let anchor = store_b.get_zone_anchor().unwrap().expect("anchor recorded"); + assert_eq!(anchor.block_id, tip_a.id); + assert_eq!(anchor.slot, tip_slot.into_inner()); + + // Re-running is idempotent: everything is already applied, no error. + let channel_was_empty = SequencerCore::::verify_and_reconstruct( + &mock_b, &store_b, &chain_b, true, + ) + .await + .expect("reconstruct idempotent"); + assert!(!channel_was_empty); + assert_eq!(store_b.latest_block_meta().unwrap().unwrap().id, tip_a.id); +} + +#[tokio::test] +async fn fails_when_channel_serves_a_divergent_block() { + let config = setup_sequencer_config(); + let (store, chain) = fresh_store_and_chain(&config); + + // Anchor on the local genesis at some slot. + let genesis_id = store.genesis_id(); + let genesis = store.get_block_at_id(genesis_id).unwrap().unwrap(); + let anchor_slot = 100_u64; + store + .set_zone_anchor(&ZoneAnchorRecord { + slot: anchor_slot, + block_id: genesis_id, + hash: genesis.header.hash, + }) + .unwrap(); + + // The channel serves a different block at the anchor id/slot. + let mut tampered = genesis.clone(); + tampered.header.hash = HashType([9_u8; 32]); + let messages = vec![block_to_channel_message(&tampered, anchor_slot)]; + let mock = MockBlockPublisher::with_canned_channel( + config.bedrock_config.channel_id, + Some(Slot::from(anchor_slot)), + messages, + ); + + let result = + SequencerCore::::verify_and_reconstruct(&mock, &store, &chain, true) + .await; + assert!(result.is_err(), "divergent channel must abort startup"); +} + +#[tokio::test] +async fn fails_when_channel_is_missing() { + let config = setup_sequencer_config(); + let (store, chain) = fresh_store_and_chain(&config); + let genesis_id = store.genesis_id(); + let genesis = store.get_block_at_id(genesis_id).unwrap().unwrap(); + store + .set_zone_anchor(&ZoneAnchorRecord { + slot: 100, + block_id: genesis_id, + hash: genesis.header.hash, + }) + .unwrap(); + + // Anchor present, but the channel does not exist on the connected chain. + let mock = + MockBlockPublisher::with_canned_channel(config.bedrock_config.channel_id, None, vec![]); + let result = + SequencerCore::::verify_and_reconstruct(&mock, &store, &chain, true) + .await; + assert!(result.is_err(), "missing channel must abort startup"); +} + +// The following cases exercise the divergence branches of +// `apply_reconstructed_block` reached with no recorded anchor, so the block's own +// validation fires rather than the up-front `AnchorConsistencyCheck`. + +#[tokio::test] +async fn fails_when_channel_reinscribes_genesis_with_a_different_hash() { + let config = setup_sequencer_config(); + let (store, chain) = fresh_store_and_chain(&config); + + // Fresh store, no anchor. The channel serves a genesis at the same id but a + // different hash โ€” a foreign chain reinscribing genesis. + let mut reinscribed = store.get_block_at_id(store.genesis_id()).unwrap().unwrap(); + reinscribed.header.hash = HashType([0xAB_u8; 32]); + + let messages = vec![block_to_channel_message(&reinscribed, 10)]; + let mock = MockBlockPublisher::with_canned_channel( + config.bedrock_config.channel_id, + Some(Slot::from(10)), + messages, + ); + let result = + SequencerCore::::verify_and_reconstruct(&mock, &store, &chain, true) + .await; + assert!( + result.is_err(), + "a reinscribed genesis with a different hash must abort startup" + ); +} + +#[tokio::test] +async fn fails_when_a_stored_block_hash_diverges_from_the_channel() { + // A sequencer that committed blocks past genesis but never recorded an anchor. + let config = setup_sequencer_config(); + let (mut seq, _handle) = SequencerCoreWithMockClients::start_from_config(config.clone()).await; + seq.produce_new_block().await.unwrap(); + seq.produce_new_block().await.unwrap(); + + // A below-tip block re-served with a corrupted hash: we already hold this id + // with a different hash, so the channel is a different chain. + let below_tip_id = seq.block_store().genesis_id() + 1; + let mut block = seq + .block_store() + .get_block_at_id(below_tip_id) + .unwrap() + .unwrap(); + block.header.hash = HashType([0xCD_u8; 32]); + + let messages = vec![block_to_channel_message(&block, 10)]; + let mock = MockBlockPublisher::with_canned_channel( + config.bedrock_config.channel_id, + Some(Slot::from(10)), + messages, + ); + let result = SequencerCore::::verify_and_reconstruct( + &mock, &seq.store, &seq.chain, true, + ) + .await; + assert!( + result.is_err(), + "a diverging below-tip block hash must abort startup" + ); +} + +#[tokio::test] +async fn fails_when_a_channel_block_is_missing_locally() { + let config = setup_sequencer_config(); + let (store, chain) = fresh_store_and_chain(&config); + + // A block numbered below our genesis is at/below the local tip yet absent from + // the store โ€” a foreign chain with a lower numbering. + let mut foreign = store.get_block_at_id(store.genesis_id()).unwrap().unwrap(); + foreign.header.block_id = store.genesis_id() - 1; + + let messages = vec![block_to_channel_message(&foreign, 10)]; + let mock = MockBlockPublisher::with_canned_channel( + config.bedrock_config.channel_id, + Some(Slot::from(10)), + messages, + ); + let result = + SequencerCore::::verify_and_reconstruct(&mock, &store, &chain, true) + .await; + assert!( + result.is_err(), + "a channel block below the local range must abort startup" + ); +} + +#[tokio::test] +async fn fails_when_a_channel_block_does_not_extend_the_tip() { + let config = setup_sequencer_config(); + let (store, chain) = fresh_store_and_chain(&config); + + // A block claiming an id far past genesis does not chain onto the local tip. + let mut orphan = store.get_block_at_id(store.genesis_id()).unwrap().unwrap(); + orphan.header.block_id = store.genesis_id() + 5; + + let messages = vec![block_to_channel_message(&orphan, 10)]; + let mock = MockBlockPublisher::with_canned_channel( + config.bedrock_config.channel_id, + Some(Slot::from(10)), + messages, + ); + let result = + SequencerCore::::verify_and_reconstruct(&mock, &store, &chain, true) + .await; + assert!( + result.is_err(), + "a non-contiguous channel block must abort startup" + ); +} + +/// A sequencer config whose genesis funds the bridge account, so replayed bridge +/// deposit transactions have a source balance to mint from. +fn bridge_funded_config() -> SequencerConfig { + let mut config = setup_sequencer_config(); + config.genesis = vec![GenesisAction::SupplyBridgeAccount { balance: 1_000_000 }]; + config +} + +/// Builds an unfulfilled pending deposit event for `recipient`, matching the +/// encoding `build_bridge_deposit_tx_from_event` expects. +fn deposit_event_record( + op_id: [u8; 32], + amount: u64, + recipient: lee::AccountId, +) -> PendingDepositEventRecord { + PendingDepositEventRecord { + deposit_op_id: HashType(op_id), + source_tx_hash: HashType([0_u8; 32]), + amount, + metadata: borsh::to_vec(&DepositMetadataForEncoding { + recipient_id: recipient, + }) + .unwrap(), + submitted_in_block_id: None, + } +} + +/// Builds a signed public bridge `Withdraw` transaction (the normal user path). +fn build_public_withdraw_tx( + sender: lee::AccountId, + nonce: u128, + amount: u64, + bedrock_account_pk: [u8; 32], + signing_key: &lee::PrivateKey, +) -> LeeTransaction { + let message = lee::public_transaction::Message::try_new( + programs::bridge().id(), + vec![sender, system_accounts::bridge_account_id()], + vec![nonce.into()], + bridge_core::Instruction::Withdraw { + amount, + bedrock_account_pk, + }, + ) + .unwrap(); + let witness_set = lee::public_transaction::WitnessSet::for_message(&message, &[signing_key]); + LeeTransaction::Public(lee::PublicTransaction::new(message, witness_set)) +} + +/// The cold-start backfill re-delivers an already-finalized deposit into the +/// mempool before reconstruction applies the same deposit block, and the queued +/// mint cannot be pulled back out. Since the bridge program does not dedup on +/// `l1_deposit_op_id`, block production must skip the already-submitted deposit +/// so the vault is minted exactly once. +#[tokio::test] +async fn reconstructed_deposit_is_not_reminted_after_backfill_redelivery() { + let recipient = initial_public_user_accounts()[0].account_id; + let deposit_amount = 500_u64; + let withdraw_amount = 100_u64; + let bedrock_account_pk = [0x22_u8; 32]; + let deposit_op_id = [0x0d_u8; 32]; + + // Sequencer A produces a deposit block then a withdraw block. + let config_a = bridge_funded_config(); + let (mut seq_a, mempool_a) = + SequencerCoreWithMockClients::start_from_config(config_a.clone()).await; + + let deposit_record = deposit_event_record(deposit_op_id, deposit_amount, recipient); + let deposit_tx = + crate::build_bridge_deposit_tx_from_event(&deposit_record).expect("build deposit tx"); + mempool_a + .push((TransactionOrigin::Sequencer, deposit_tx.clone())) + .await + .unwrap(); + seq_a.produce_new_block().await.unwrap(); + + let withdraw_tx = build_public_withdraw_tx( + recipient, + 0, + withdraw_amount, + bedrock_account_pk, + &create_signing_key_for_account1(), + ); + mempool_a + .push((TransactionOrigin::User, withdraw_tx.clone())) + .await + .unwrap(); + seq_a.produce_new_block().await.unwrap(); + + let tip_a = seq_a.block_store().latest_block_meta().unwrap().unwrap(); + let messages = channel_from_store(seq_a.block_store(), 10); + let tip_slot = messages.last().unwrap().1; + let channel_id = config_a.bedrock_config.channel_id; + + let config_b = bridge_funded_config(); + let (mut seq_b, mempool_b) = SequencerCoreWithMockClients::start_from_config(config_b).await; + + // Backfill re-delivery: persist the pending record and queue the mint, as + // `on_deposit_event` does, before reconstruction runs. + assert!( + seq_b + .block_store() + .dbio() + .add_pending_deposit_event(deposit_record.clone()) + .unwrap() + ); + mempool_b + .push((TransactionOrigin::Sequencer, deposit_tx)) + .await + .unwrap(); + + let mock_b = MockBlockPublisher::with_canned_channel(channel_id, Some(tip_slot), messages); + SequencerCore::::verify_and_reconstruct( + &mock_b, + &seq_b.store, + &seq_b.chain, + true, + ) + .await + .expect("reconstruct"); + + let tip_b = seq_b.block_store().latest_block_meta().unwrap().unwrap(); + assert_eq!(tip_b.id, tip_a.id); + assert_eq!(tip_b.hash, tip_a.hash); + + seq_b.produce_new_block().await.unwrap(); + + let vault_id = vault_core::compute_vault_account_id(programs::vault().id(), recipient); + let bridge_id = system_accounts::bridge_account_id(); + let state_b = seq_b.chain().lock().unwrap().head_state().clone(); + let state_a = seq_a.chain().lock().unwrap().head_state().clone(); + for account in [vault_id, bridge_id, recipient] { + assert_eq!( + state_b.get_account_by_id(account).balance, + state_a.get_account_by_id(account).balance, + "reconstructed balance mismatch for {account:?}", + ); + } + assert_eq!( + state_b.get_account_by_id(vault_id).balance, + u128::from(deposit_amount), + "deposit must mint into the recipient vault exactly once, not twice" + ); + + let produced = seq_b + .block_store() + .get_block_at_id(tip_b.id + 1) + .unwrap() + .expect("produced block present"); + assert!( + !produced + .body + .transactions + .iter() + .any(|tx| crate::extract_bridge_deposit_id(tx) == Some(HashType(deposit_op_id))), + "the re-delivered mint must be skipped, not re-included in a block" + ); + + // A reconstructed withdraw's finalized L1 event was already re-delivered (and + // dropped) by cold-start backfill, so it will never be consumed again. + // Reconstruction must not count it, or the count stays phantom-inflated forever. + let withdraw_arg = crate::extract_bridge_withdraw_data(&withdraw_tx).expect("withdraw data"); + let key = crate::withdraw_event_reconciliation_key(&withdraw_arg.outputs).expect("recon key"); + assert!( + !seq_b + .block_store() + .dbio() + .consume_unseen_withdraw_count(key) + .unwrap(), + "reconstruction must not leave a phantom unseen-withdraw count" + ); +} + +/// A reconstructed withdraw block must not touch the unseen-withdraw counter. +/// Its finalized L1 Withdraw event was already re-delivered (and dropped as a +/// no-op) by cold-start backfill, so counting it during reconstruction would +/// leave a permanent phantom that nothing ever consumes. +#[tokio::test] +async fn reconstructed_withdraw_leaves_no_phantom_unseen_count() { + let recipient = initial_public_user_accounts()[0].account_id; + let withdraw_amount = 100_u64; + let bedrock_account_pk = [0x33_u8; 32]; + + // Sequencer A produces a single withdraw block; treat its chain as the channel. + let config_a = bridge_funded_config(); + let (mut seq_a, mempool_a) = + SequencerCoreWithMockClients::start_from_config(config_a.clone()).await; + let withdraw_tx = build_public_withdraw_tx( + recipient, + 0, + withdraw_amount, + bedrock_account_pk, + &create_signing_key_for_account1(), + ); + mempool_a + .push((TransactionOrigin::User, withdraw_tx.clone())) + .await + .unwrap(); + seq_a.produce_new_block().await.unwrap(); + + let withdraw_arg = crate::extract_bridge_withdraw_data(&withdraw_tx).expect("withdraw data"); + let key = crate::withdraw_event_reconciliation_key(&withdraw_arg.outputs).expect("recon key"); + // Producing the withdraw counts it as unseen, awaiting its L1 event. + assert!( + seq_a + .block_store() + .dbio() + .consume_unseen_withdraw_count(key) + .unwrap(), + "producing a withdraw must count it as unseen" + ); + + let messages = channel_from_store(seq_a.block_store(), 10); + let tip_slot = messages.last().unwrap().1; + let channel_id = config_a.bedrock_config.channel_id; + + // Sequencer B reconstructs A's chain from a fresh store. + let config_b = bridge_funded_config(); + let (store_b, chain_b) = fresh_store_and_chain(&config_b); + let mock_b = MockBlockPublisher::with_canned_channel(channel_id, Some(tip_slot), messages); + SequencerCore::::verify_and_reconstruct(&mock_b, &store_b, &chain_b, true) + .await + .expect("reconstruct"); + + assert!( + !store_b.dbio().consume_unseen_withdraw_count(key).unwrap(), + "reconstruction must not leave a phantom unseen-withdraw count" + ); +} + +/// A deposit whose L1 event was observed (an unfulfilled pending record +/// exists) and whose L2 mint is already contained in a finalized channel block. +/// Reconstruction must reconcile the pending record against that block โ€” marking +/// it submitted so the startup replay does not re-inject it โ€” and apply the mint +/// exactly once. +#[tokio::test] +async fn reconstruction_reconciles_already_finished_deposit() { + let recipient = initial_public_user_accounts()[0].account_id; + let deposit_amount = 400_u64; + let deposit_op_id = [0x1a_u8; 32]; + + // Sequencer A: a single block that fully processes the bridge deposit. + let config_a = bridge_funded_config(); + let (mut seq_a, mempool_a) = + SequencerCoreWithMockClients::start_from_config(config_a.clone()).await; + let deposit_record = deposit_event_record(deposit_op_id, deposit_amount, recipient); + let deposit_tx = + crate::build_bridge_deposit_tx_from_event(&deposit_record).expect("build deposit tx"); + mempool_a + .push((TransactionOrigin::Sequencer, deposit_tx)) + .await + .unwrap(); + seq_a.produce_new_block().await.unwrap(); + let deposit_block_id = seq_a.block_store().latest_block_meta().unwrap().unwrap().id; + + let messages = channel_from_store(seq_a.block_store(), 10); + let tip_slot = messages.last().unwrap().1; + let channel_id = config_a.bedrock_config.channel_id; + + // Sequencer B: fresh store, but with the *unfulfilled* pending deposit event + // pre-seeded, as the cold-start backfill would when it re-observes this + // already-finalized deposit. + let config_b = bridge_funded_config(); + let (store_b, chain_b) = fresh_store_and_chain(&config_b); + assert!( + store_b + .dbio() + .add_pending_deposit_event(deposit_record.clone()) + .unwrap() + ); + + let mock_b = MockBlockPublisher::with_canned_channel(channel_id, Some(tip_slot), messages); + SequencerCore::::verify_and_reconstruct(&mock_b, &store_b, &chain_b, true) + .await + .expect("reconstruct"); + + // The mint was applied exactly once. + let vault_id = vault_core::compute_vault_account_id(programs::vault().id(), recipient); + assert_eq!( + chain_b + .lock() + .unwrap() + .head_state() + .get_account_by_id(vault_id) + .balance, + u128::from(deposit_amount), + "already-finished deposit must be applied exactly once" + ); + + // The pending event is now marked submitted in the reconstructed block, so the + // startup replay would not re-queue it โ€” no double mint on restart. + let record = store_b + .get_unfulfilled_deposit_events() + .unwrap() + .into_iter() + .find(|event| event.deposit_op_id == HashType(deposit_op_id)) + .expect("pending deposit event should still be recorded"); + assert_eq!( + record.submitted_in_block_id, + Some(deposit_block_id), + "reconstruction must reconcile the already-finished deposit against its channel block" + ); +} + +#[tokio::test] +async fn committed_local_against_missing_channel_fails_without_anchor() { + // A sequencer that has committed blocks โ€” a non-genesis tip plus a persisted + // checkpoint โ€” but only ever produced (so it never recorded a per-block + // anchor). Restarting it against a wiped/missing channel must still fail, + // driven by the committed-blocks invariant rather than an anchor probe. + let config = setup_sequencer_config(); + { + let (mut seq, _handle) = + SequencerCoreWithMockClients::start_from_config(config.clone()).await; + seq.produce_new_block().await.unwrap(); + seq.produce_new_block().await.unwrap(); + assert!(seq.block_store().latest_block_meta().unwrap().unwrap().id > 1); + } // drop releases the store so we can reopen it + + // Reopen: blocks beyond genesis, no anchor. `is_fresh_start = false` stands in + // for a checkpoint persisted by a prior sync (the mock never emits one). + let (store, chain) = fresh_store_and_chain(&config); + assert!(store.get_zone_anchor().unwrap().is_none()); + assert!(store.latest_block_meta().unwrap().unwrap().id > 1); + + // The channel is gone: no tip, no messages. + let mock = + MockBlockPublisher::with_canned_channel(config.bedrock_config.channel_id, None, vec![]); + let result = + SequencerCore::::verify_and_reconstruct(&mock, &store, &chain, false) + .await; + assert!( + result.is_err(), + "committed blocks against a missing channel must abort startup" + ); +} diff --git a/lez/sequencer/service/Cargo.toml b/lez/sequencer/service/Cargo.toml index 3427dc22..338aac0d 100644 --- a/lez/sequencer/service/Cargo.toml +++ b/lez/sequencer/service/Cargo.toml @@ -1,6 +1,7 @@ [package] name = "sequencer_service" version = "0.1.0" +default-run = "sequencer_service" edition = "2024" license = { workspace = true } @@ -19,6 +20,7 @@ programs.workspace = true clap = { workspace = true, features = ["derive", "env"] } anyhow.workspace = true env_logger.workspace = true +hex.workspace = true log.workspace = true tokio.workspace = true tokio-util.workspace = true diff --git a/lez/sequencer/service/rpc/src/lib.rs b/lez/sequencer/service/rpc/src/lib.rs index f32f6bd2..a1d2acfb 100644 --- a/lez/sequencer/service/rpc/src/lib.rs +++ b/lez/sequencer/service/rpc/src/lib.rs @@ -90,6 +90,4 @@ pub trait Rpc { #[method(name = "getChannelId")] async fn get_channel_id(&self) -> Result; - - // ============================================================================================= } diff --git a/lez/sequencer/service/src/bin/bedrock_pubkey.rs b/lez/sequencer/service/src/bin/bedrock_pubkey.rs new file mode 100644 index 00000000..ad602be6 --- /dev/null +++ b/lez/sequencer/service/src/bin/bedrock_pubkey.rs @@ -0,0 +1,35 @@ +//! Prints the sequencer's bedrock signing public key (hex) without booting it. +//! +//! Loads `/bedrock_signing_key` from the given sequencer config, creating +//! the key if it doesn't exist yet, so that a node can be accredited into the +//! channel committee before its first boot. + +use std::path::PathBuf; + +use anyhow::Result; +use clap::Parser; + +#[derive(Debug, Parser)] +#[clap(version)] +struct Args { + #[clap(name = "config")] + config_path: PathBuf, + /// Override the config's home directory, matching the sequencer's --home. + #[clap(long)] + home: Option, +} + +#[expect( + clippy::print_stdout, + reason = "the hex pubkey on stdout is this binary's output" +)] +fn main() -> Result<()> { + let Args { config_path, home } = Args::parse(); + + let config = sequencer_service::SequencerConfig::from_path(&config_path)?; + let home = home.unwrap_or(config.home); + let key = sequencer_core::load_or_create_signing_key(&home.join("bedrock_signing_key"))?; + println!("{}", hex::encode(key.public_key().to_bytes())); + + Ok(()) +} diff --git a/lez/sequencer/service/src/bin/configure_channel.rs b/lez/sequencer/service/src/bin/configure_channel.rs new file mode 100644 index 00000000..023e32b0 --- /dev/null +++ b/lez/sequencer/service/src/bin/configure_channel.rs @@ -0,0 +1,71 @@ +//! Posts a `ChannelConfig` op (accredited keys + rotation params) to bedrock, +//! signed with `/bedrock_signing_key`, without booting the sequencer. +//! +//! Authorization is holding the admin key file โ€” the L1 rejects non-admin +//! signers. Acceptance is asynchronous: a rejection only shows up in node +//! logs and on-chain behavior. + +use std::path::PathBuf; + +use anyhow::{Context as _, Result, anyhow}; +use clap::Parser; +use sequencer_core::block_publisher::{Ed25519PublicKey, post_channel_config}; + +#[derive(Debug, Parser)] +#[clap(version)] +struct Args { + #[clap(name = "config")] + config_path: PathBuf, + /// Override the config's home directory, matching the sequencer's --home. + #[clap(long)] + home: Option, + /// Accredited ed25519 public keys (hex), admin (this node's key) first. + #[clap(long, required = true, value_delimiter = ',')] + keys: Vec, + /// Slots a sequencer's posting turn lasts. + #[clap(long)] + posting_timeframe: u32, + /// Slots after which a stalled turn can be taken over. + #[clap(long)] + posting_timeout: u32, + /// Signatures required for future config changes. + #[clap(long, default_value_t = 1)] + configuration_threshold: u16, + /// Signatures required for channel withdrawals. + #[clap(long, default_value_t = 1)] + withdraw_threshold: u16, +} + +#[tokio::main] +async fn main() -> Result<()> { + env_logger::init(); + let args = Args::parse(); + + let config = sequencer_service::SequencerConfig::from_path(&args.config_path)?; + let home = args.home.unwrap_or(config.home); + let signing_key = + sequencer_core::load_or_create_signing_key(&home.join("bedrock_signing_key"))?; + let keys = args + .keys + .iter() + .map(|key| parse_key(key)) + .collect::>>()?; + + post_channel_config( + &config.bedrock_config, + &signing_key, + keys, + args.posting_timeframe, + args.posting_timeout, + args.configuration_threshold, + args.withdraw_threshold, + ) + .await +} + +fn parse_key(hex_key: &str) -> Result { + let mut bytes = [0_u8; 32]; + hex::decode_to_slice(hex_key, &mut bytes) + .with_context(|| format!("Invalid hex-encoded key {hex_key}"))?; + Ed25519PublicKey::from_bytes(&bytes).map_err(|err| anyhow!("Invalid Ed25519 public key: {err}")) +} diff --git a/lez/sequencer/service/src/lib.rs b/lez/sequencer/service/src/lib.rs index 687ee424..a8aa856e 100644 --- a/lez/sequencer/service/src/lib.rs +++ b/lez/sequencer/service/src/lib.rs @@ -11,10 +11,11 @@ use mempool::MemPoolHandle; use sequencer_core::SequencerCore; #[cfg(feature = "standalone")] use sequencer_core::SequencerCoreWithMockClients as SequencerCore; -use sequencer_core::TransactionOrigin; pub use sequencer_core::config::*; +use sequencer_core::{TransactionOrigin, block_publisher::BlockPublisherTrait as _}; use sequencer_service_rpc::RpcServer as _; use tokio::{sync::Mutex, task::JoinHandle}; +use tokio_util::sync::CancellationToken; pub mod service; @@ -28,6 +29,9 @@ pub struct SequencerHandle { /// Option because of `Drop` which forbids to simply move out of `self` in `stopped()`. server_handle: Option, main_loop_handle: JoinHandle>, + /// Cancelled when the publisher's drive task terminates (e.g. a panicked + /// persist sink); no channel events are processed past that point. + driver_cancellation: CancellationToken, } impl SequencerHandle { @@ -35,11 +39,13 @@ impl SequencerHandle { addr: SocketAddr, server_handle: ServerHandle, main_loop_handle: JoinHandle>, + driver_cancellation: CancellationToken, ) -> Self { Self { addr, server_handle: Some(server_handle), main_loop_handle, + driver_cancellation, } } @@ -53,6 +59,7 @@ impl SequencerHandle { addr: _, server_handle, main_loop_handle, + driver_cancellation, } = &mut self; let server_handle = server_handle.take().expect("Server handle is set"); @@ -65,6 +72,9 @@ impl SequencerHandle { .context("Main loop task panicked")? .context("Main loop exited unexpectedly") } + () = driver_cancellation.cancelled() => { + Err(anyhow!("Publisher drive task terminated")) + } } } @@ -78,10 +88,12 @@ impl SequencerHandle { addr: _, server_handle, main_loop_handle, + driver_cancellation, } = self; let stopped = server_handle.as_ref().is_none_or(ServerHandle::is_stopped) - || main_loop_handle.is_finished(); + || main_loop_handle.is_finished() + || driver_cancellation.is_cancelled(); !stopped } @@ -97,6 +109,7 @@ impl Drop for SequencerHandle { addr: _, server_handle, main_loop_handle, + driver_cancellation: _, } = self; main_loop_handle.abort(); @@ -111,21 +124,23 @@ impl Drop for SequencerHandle { } } -pub async fn run(config: SequencerConfig, port: u16) -> Result { +pub async fn run(config: SequencerConfig, listen_addr: SocketAddr) -> Result { let block_timeout = config.block_create_timeout; let max_block_size = config.max_block_size; - let (sequencer_core, mempool_handle) = SequencerCore::start_from_config(config).await; + let (sequencer_core, mempool_handle): (SequencerCore, _) = + SequencerCore::start_from_config(config).await; info!("Sequencer core set up"); + let driver_cancellation = sequencer_core.block_publisher().driver_cancellation(); let seq_core_wrapped = Arc::new(Mutex::new(sequencer_core)); let mempool_handle_for_server = mempool_handle.clone(); let (server_handle, addr) = run_server( Arc::clone(&seq_core_wrapped), mempool_handle_for_server, - port, + listen_addr, max_block_size.as_u64(), ) .await?; @@ -136,13 +151,18 @@ pub async fn run(config: SequencerConfig, port: u16) -> Result let _ = mempool_handle; - Ok(SequencerHandle::new(addr, server_handle, main_loop_handle)) + Ok(SequencerHandle::new( + addr, + server_handle, + main_loop_handle, + driver_cancellation, + )) } async fn run_server( sequencer: Arc>, mempool_handle: MemPoolHandle<(TransactionOrigin, LeeTransaction)>, - port: u16, + listen_addr: SocketAddr, max_block_size: u64, ) -> Result<(ServerHandle, SocketAddr)> { let server = jsonrpsee::server::ServerBuilder::with_config( @@ -153,7 +173,7 @@ async fn run_server( ) .build(), ) - .build(SocketAddr::from(([0, 0, 0, 0], port))) + .build(listen_addr) .await .context("Failed to build RPC server")?; @@ -172,16 +192,15 @@ async fn main_loop(seq_core: Arc>, block_timeout: Duration) loop { tokio::time::sleep(block_timeout).await; - info!("Collecting transactions from mempool, block creation"); + let mut state = seq_core.lock().await; - let id = { - let mut state = seq_core.lock().await; - - state.produce_new_block().await? - }; + // Only produce on our turn. + if !state.is_our_turn() { + continue; + } + info!("Our turn: collecting transactions from mempool, creating block"); + let id = state.produce_new_block().await?; info!("Block with id {id} created"); - - info!("Waiting for new transactions"); } } diff --git a/lez/sequencer/service/src/main.rs b/lez/sequencer/service/src/main.rs index 8b577bb8..7c50ece6 100644 --- a/lez/sequencer/service/src/main.rs +++ b/lez/sequencer/service/src/main.rs @@ -1,4 +1,7 @@ -use std::path::PathBuf; +use std::{ + net::{IpAddr, SocketAddr}, + path::PathBuf, +}; use anyhow::Result; use clap::Parser; @@ -12,6 +15,14 @@ struct Args { config_path: PathBuf, #[clap(short, long, default_value = "3040")] port: u16, + /// Interface the RPC server binds to. The RPC has no caller auth โ€” + /// bind loopback unless the port is firewalled. + #[clap(long, default_value = "0.0.0.0")] + listen_address: IpAddr, + /// Override the config's home directory (`RocksDB` + bedrock signing key), + /// so multiple instances can share one config file. + #[clap(long)] + home: Option, } #[tokio::main] @@ -22,14 +33,23 @@ struct Args { async fn main() -> Result<()> { env_logger::init(); - let Args { config_path, port } = Args::parse(); + let Args { + config_path, + port, + listen_address, + home, + } = 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)?; - let sequencer_handle = sequencer_service::run(config, port).await?; + let mut config = sequencer_service::SequencerConfig::from_path(&config_path)?; + if let Some(home) = home { + config.home = home; + } + let sequencer_handle = + sequencer_service::run(config, SocketAddr::new(listen_address, port)).await?; tokio::select! { () = cancellation_token.cancelled() => { diff --git a/lez/sequencer/service/src/service.rs b/lez/sequencer/service/src/service.rs index d0850837..7ab9ed3c 100644 --- a/lez/sequencer/service/src/service.rs +++ b/lez/sequencer/service/src/service.rs @@ -40,7 +40,7 @@ impl SequencerService { } #[async_trait] -impl sequencer_service_rpc::RpcServer +impl sequencer_service_rpc::RpcServer for SequencerService { async fn send_transaction(&self, tx: LeeTransaction) -> Result { @@ -138,8 +138,8 @@ impl sequencer_service_rpc::RpcServer async fn get_account_balance(&self, account_id: AccountId) -> Result { let sequencer = self.sequencer.lock().await; - let account = sequencer.state().get_account_by_id(account_id); - Ok(account.balance) + let balance = sequencer.with_state(|state| state.get_account_by_id(account_id).balance); + Ok(balance) } async fn get_transaction( @@ -155,10 +155,12 @@ impl sequencer_service_rpc::RpcServer account_ids: Vec, ) -> Result, ErrorObjectOwned> { let sequencer = self.sequencer.lock().await; - let nonces = account_ids - .into_iter() - .map(|account_id| sequencer.state().get_account_by_id(account_id).nonce) - .collect(); + let nonces = sequencer.with_state(|state| { + account_ids + .into_iter() + .map(|account_id| state.get_account_by_id(account_id).nonce) + .collect() + }); Ok(nonces) } @@ -167,17 +169,18 @@ impl sequencer_service_rpc::RpcServer commitments: Vec, ) -> Result<(Vec>, CommitmentSetDigest), ErrorObjectOwned> { let sequencer = self.sequencer.lock().await; - let state = sequencer.state(); - let proofs = commitments - .iter() - .map(|commitment| state.get_proof_for_commitment(commitment)) - .collect(); - Ok((proofs, state.commitment_root())) + Ok(sequencer.with_state(|state| { + let proofs = commitments + .iter() + .map(|commitment| state.get_proof_for_commitment(commitment)) + .collect(); + (proofs, state.commitment_root()) + })) } async fn get_account(&self, account_id: AccountId) -> Result { let sequencer = self.sequencer.lock().await; - Ok(sequencer.state().get_account_by_id(account_id)) + Ok(sequencer.with_state(|state| state.get_account_by_id(account_id))) } async fn get_program_ids(&self) -> Result, ErrorObjectOwned> { diff --git a/lez/storage/src/sequencer/mod.rs b/lez/storage/src/sequencer/mod.rs index 1a3a1886..0150a99f 100644 --- a/lez/storage/src/sequencer/mod.rs +++ b/lez/storage/src/sequencer/mod.rs @@ -19,10 +19,12 @@ use crate::{ }, error::DbError, sequencer::sequencer_cells::{ - LEEStateCellOwned, LEEStateCellRef, LastFinalizedBlockIdCell, LatestBlockMetaCellOwned, - LatestBlockMetaCellRef, PendingDepositEventRecord, PendingDepositEventsCellOwned, - PendingDepositEventsCellRef, UnseenWithdrawCountCell, WithdrawalReconciliationKey, - ZoneSdkCheckpointCellOwned, ZoneSdkCheckpointCellRef, + FinalBlockMetaCellOwned, FinalBlockMetaCellRef, FinalLeeStateCellOwned, + FinalLeeStateCellRef, LEEStateCellOwned, LEEStateCellRef, LastFinalizedBlockIdCell, + LatestBlockMetaCellOwned, LatestBlockMetaCellRef, PendingDepositEventRecord, + PendingDepositEventsCellOwned, PendingDepositEventsCellRef, UnseenWithdrawCountCell, + WithdrawalReconciliationKey, ZoneAnchorCell, ZoneAnchorRecord, ZoneSdkCheckpointCellOwned, + ZoneSdkCheckpointCellRef, }, }; @@ -34,6 +36,10 @@ pub const DB_META_LAST_FINALIZED_BLOCK_ID: &str = "last_finalized_block_id"; pub const DB_META_LATEST_BLOCK_META_KEY: &str = "latest_block_meta"; /// Key base for storing the zone-sdk sequencer checkpoint (opaque bytes). pub const DB_META_ZONE_SDK_CHECKPOINT_KEY: &str = "zone_sdk_checkpoint"; +/// Key base for storing the last channel block read back and verified from +/// Bedrock (its L1 slot + `id`/`hash`) โ€” the anchor for the startup +/// consistency check and the resume point for reconstruction. +pub const DB_META_ZONE_CURSOR_KEY: &str = "zone_cursor"; /// Key base for storing queued deposit events that were not yet /// fulfilled on L2. pub const DB_META_PENDING_DEPOSIT_EVENTS_KEY: &str = "pending_deposit_events"; @@ -42,6 +48,10 @@ pub const DB_META_UNSEEN_WITHDRAW_COUNT_KEY: &str = "unseen_withdraw_count"; /// Key base for storing the LEE state. pub const DB_LEE_STATE_KEY: &str = "lee_state"; +/// Key base for storing the LEE state at the last L1-finalized block. +pub const DB_FINAL_LEE_STATE_KEY: &str = "final_lee_state"; +/// Key base for storing `(id, hash)` of the last L1-finalized block. +pub const DB_FINAL_BLOCK_META_KEY: &str = "final_block_meta"; /// Name of state column family. pub const CF_LEE_STATE_NAME: &str = "cf_lee_state"; @@ -340,8 +350,9 @@ impl RocksDBIO { self.put_batch(&LatestBlockMetaCellRef(block_meta), (), batch) } - pub fn latest_block_meta(&self) -> DbResult { - self.get::(()).map(|val| val.0) + pub fn latest_block_meta(&self) -> DbResult> { + self.get_opt::(()) + .map(|val| val.map(|cell| cell.0)) } pub fn get_zone_sdk_checkpoint_bytes(&self) -> DbResult>> { @@ -359,6 +370,14 @@ impl RocksDBIO { self.del::(()) } + pub fn get_zone_anchor(&self) -> DbResult> { + Ok(self.get_opt::(())?.map(|cell| cell.0)) + } + + pub fn put_zone_anchor(&self, anchor: &ZoneAnchorRecord) -> DbResult<()> { + self.put(&ZoneAnchorCell(*anchor), ()) + } + pub fn get_pending_deposit_events(&self) -> DbResult> { Ok(self .get_opt::(())? @@ -390,6 +409,25 @@ impl RocksDBIO { Ok(true) } + /// Marks the given deposit events submitted in `block_id`, in one write. + pub fn mark_deposit_events_submitted( + &self, + deposit_op_ids: &[HashType], + submitted_block_id: u64, + ) -> DbResult<()> { + if deposit_op_ids.is_empty() { + return Ok(()); + } + let mut batch = WriteBatch::default(); + self.mark_pending_deposit_events_submitted(deposit_op_ids, submitted_block_id, &mut batch)?; + self.db.write(batch).map_err(|rerr| { + DbError::rocksdb_cast_message( + rerr, + Some("Failed to mark deposit events submitted".to_owned()), + ) + }) + } + fn mark_pending_deposit_events_submitted( &self, deposit_op_ids: &[HashType], @@ -434,6 +472,14 @@ impl RocksDBIO { Ok(removed) } + /// Whether a bridge deposit for `deposit_op_id` is already recorded as + /// included in a block (its pending record is marked submitted). + pub fn is_deposit_event_submitted(&self, deposit_op_id: HashType) -> DbResult { + Ok(self.get_pending_deposit_events()?.iter().any(|record| { + record.deposit_op_id == deposit_op_id && record.submitted_in_block_id.is_some() + })) + } + fn increment_unseen_withdraw_count( &self, withdrawal: WithdrawalReconciliationKey, @@ -482,23 +528,36 @@ impl RocksDBIO { } pub fn put_block(&self, block: &Block, first: bool, batch: &mut WriteBatch) -> DbResult<()> { - let cf_block = self.block_column(); - if !first { + // A produced block is the new head tip by construction: pin the + // tip meta and drop any stale higher blocks a preceding reorg left + // behind (mirrors `store_followed_blocks`). let last_curr_block = self.get_meta_last_block_in_db()?; - - if block.header.block_id > last_curr_block { - self.put_meta_last_block_in_db_batch(block.header.block_id, batch)?; - self.put_meta_latest_block_meta_batch( - &BlockMeta { - id: block.header.block_id, - hash: block.header.hash, - }, - batch, - )?; + for stale_id in block.header.block_id.saturating_add(1)..=last_curr_block { + self.delete_block_payload(stale_id, batch)?; } + self.put_meta_last_block_in_db_batch(block.header.block_id, batch)?; + self.put_meta_latest_block_meta_batch(&BlockMeta::from(block), batch)?; } + self.put_block_payload(block, batch) + } + + /// Stages deletion of a block payload into `batch`. + fn delete_block_payload(&self, block_id: u64, batch: &mut WriteBatch) -> DbResult<()> { + let cf_block = self.block_column(); + batch.delete_cf( + &cf_block, + borsh::to_vec(&block_id).map_err(|err| { + DbError::borsh_cast_message(err, Some("Failed to serialize block id".to_owned())) + })?, + ); + Ok(()) + } + + /// Stages just the block payload into `batch`, without touching the tip meta. + fn put_block_payload(&self, block: &Block, batch: &mut WriteBatch) -> DbResult<()> { + let cf_block = self.block_column(); batch.put_cf( &cf_block, borsh::to_vec(&block.header.block_id).map_err(|err| { @@ -516,6 +575,26 @@ impl RocksDBIO { .map(|opt| opt.map(|val| val.0)) } + /// `(state, meta)` at the last L1-finalized block; `None` until the first + /// finalization is observed. + pub fn get_final_snapshot(&self) -> DbResult> { + let Some(meta) = self.get_opt::(())? else { + return Ok(None); + }; + let state = self.get::(())?; + Ok(Some((state.0, meta.0))) + } + + fn put_final_snapshot_batch( + &self, + state: &V03State, + meta: &BlockMeta, + batch: &mut WriteBatch, + ) -> DbResult<()> { + self.put_batch(&FinalLeeStateCellRef(state), (), batch)?; + self.put_batch(&FinalBlockMetaCellRef(meta), (), batch) + } + pub fn get_lee_state(&self) -> DbResult { self.get::(()).map(|val| val.0) } @@ -612,6 +691,97 @@ impl RocksDBIO { Ok(()) } + /// One-block form of [`Self::store_followed_blocks`], with the block as the + /// head tip and no final snapshot. Production always uses the batch form. + #[cfg(test)] + fn store_followed_block( + &self, + block: &Block, + state: &V03State, + finalized: bool, + ) -> DbResult<()> { + self.store_followed_blocks( + &[(block, finalized)], + Some(&BlockMeta::from(block)), + state, + None, + ) + } + + /// Persists a batch of followed blocks, the caller's head-tip `state`, and + /// the optional final-tier snapshot in one atomic write. + /// + /// The tip meta is pinned to `head_tip`, and blocks stored above it (left + /// behind by a net-shortening reorg) are deleted in the same write, so + /// restart replay never walks past the tip. + /// + /// Per block: skips the payload write when the store already holds it (by + /// id and hash), unless `finalized` is set, which rewrites it with the + /// finalized status. A no-op update (nothing to write, tip unchanged) + /// writes nothing. + /// + /// TODO: the zone-sdk checkpoint is persisted by `on_checkpoint` *before* + /// this write. Full `BlocksProcessed` atomicity (checkpoint, blocks, state + /// and orphan reverts in one batch) is a follow-up. + pub fn store_followed_blocks( + &self, + blocks: &[(&Block, bool)], + head_tip: Option<&BlockMeta>, + state: &V03State, + final_snapshot: Option<(&V03State, &BlockMeta)>, + ) -> DbResult<()> { + let last_block_in_db = self.get_meta_last_block_in_db()?; + let mut batch = WriteBatch::default(); + + for (block, finalized) in blocks { + let already_stored = self + .get_block(block.header.block_id)? + .filter(|stored| stored.header.hash == block.header.hash); + + let mut to_write = match already_stored { + Some(_) if !finalized => continue, + Some(stored) => stored, + None => (*block).clone(), + }; + if *finalized { + to_write.bedrock_status = BedrockStatus::Finalized; + } + self.put_block_payload(&to_write, &mut batch)?; + } + + // `head_tip` is `None` only for a chain holding no blocks at all, which + // implies nothing was applied โ€” and the store, created with genesis, + // cannot represent it. No tip to pin, nothing to persist. + let Some(tip) = head_tip else { + debug_assert!(batch.is_empty() && final_snapshot.is_none()); + return Ok(()); + }; + + // A shrink-only update (orphans without adopted replacements) has no + // payloads to write but must still rewind the tip meta, or the stored + // state tears against the stale disk head on the next produce. + if batch.is_empty() && final_snapshot.is_none() && tip.id == last_block_in_db { + return Ok(()); + } + + for stale_id in tip.id.saturating_add(1)..=last_block_in_db { + self.delete_block_payload(stale_id, &mut batch)?; + } + self.put_meta_last_block_in_db_batch(tip.id, &mut batch)?; + self.put_meta_latest_block_meta_batch(tip, &mut batch)?; + self.put_lee_state_in_db_batch(state, &mut batch)?; + if let Some((final_state, final_meta)) = final_snapshot { + self.put_final_snapshot_batch(final_state, final_meta, &mut batch)?; + } + + self.db.write(batch).map_err(|rerr| { + DbError::rocksdb_cast_message( + rerr, + Some("Failed to write followed blocks batch".to_owned()), + ) + }) + } + pub fn get_all_blocks(&self) -> impl Iterator> { let cf_block = self.block_column(); self.db @@ -661,3 +831,6 @@ impl RocksDBIO { }) } } + +#[cfg(test)] +mod tests; diff --git a/lez/storage/src/sequencer/sequencer_cells.rs b/lez/storage/src/sequencer/sequencer_cells.rs index 7672e271..c10b3d30 100644 --- a/lez/storage/src/sequencer/sequencer_cells.rs +++ b/lez/storage/src/sequencer/sequencer_cells.rs @@ -7,9 +7,10 @@ use crate::{ cells::{SimpleReadableCell, SimpleStorableCell, SimpleWritableCell}, error::DbError, sequencer::{ - CF_LEE_STATE_NAME, DB_LEE_STATE_KEY, DB_META_LAST_FINALIZED_BLOCK_ID, - DB_META_LATEST_BLOCK_META_KEY, DB_META_PENDING_DEPOSIT_EVENTS_KEY, - DB_META_UNSEEN_WITHDRAW_COUNT_KEY, DB_META_ZONE_SDK_CHECKPOINT_KEY, + CF_LEE_STATE_NAME, DB_FINAL_BLOCK_META_KEY, DB_FINAL_LEE_STATE_KEY, DB_LEE_STATE_KEY, + DB_META_LAST_FINALIZED_BLOCK_ID, DB_META_LATEST_BLOCK_META_KEY, + DB_META_PENDING_DEPOSIT_EVENTS_KEY, DB_META_UNSEEN_WITHDRAW_COUNT_KEY, + DB_META_ZONE_CURSOR_KEY, DB_META_ZONE_SDK_CHECKPOINT_KEY, }, }; @@ -43,6 +44,72 @@ impl SimpleWritableCell for LEEStateCellRef<'_> { } } +/// State at the last L1-finalized block, written atomically with +/// [`FinalBlockMetaCellRef`]. +#[derive(BorshDeserialize)] +pub struct FinalLeeStateCellOwned(pub V03State); + +impl SimpleStorableCell for FinalLeeStateCellOwned { + type KeyParams = (); + + const CELL_NAME: &'static str = DB_FINAL_LEE_STATE_KEY; + const CF_NAME: &'static str = CF_LEE_STATE_NAME; +} + +impl SimpleReadableCell for FinalLeeStateCellOwned {} + +#[derive(BorshSerialize)] +pub struct FinalLeeStateCellRef<'state>(pub &'state V03State); + +impl SimpleStorableCell for FinalLeeStateCellRef<'_> { + type KeyParams = (); + + const CELL_NAME: &'static str = DB_FINAL_LEE_STATE_KEY; + const CF_NAME: &'static str = CF_LEE_STATE_NAME; +} + +impl SimpleWritableCell for FinalLeeStateCellRef<'_> { + fn value_constructor(&self) -> DbResult> { + borsh::to_vec(&self).map_err(|err| { + DbError::borsh_cast_message(err, Some("Failed to serialize final state".to_owned())) + }) + } +} + +/// `(id, hash)` of the last L1-finalized block, paired with [`FinalLeeStateCellRef`]. +#[derive(BorshDeserialize)] +pub struct FinalBlockMetaCellOwned(pub BlockMeta); + +impl SimpleStorableCell for FinalBlockMetaCellOwned { + type KeyParams = (); + + const CELL_NAME: &'static str = DB_FINAL_BLOCK_META_KEY; + const CF_NAME: &'static str = CF_META_NAME; +} + +impl SimpleReadableCell for FinalBlockMetaCellOwned {} + +#[derive(BorshSerialize)] +pub struct FinalBlockMetaCellRef<'blockmeta>(pub &'blockmeta BlockMeta); + +impl SimpleStorableCell for FinalBlockMetaCellRef<'_> { + type KeyParams = (); + + const CELL_NAME: &'static str = DB_FINAL_BLOCK_META_KEY; + const CF_NAME: &'static str = CF_META_NAME; +} + +impl SimpleWritableCell for FinalBlockMetaCellRef<'_> { + fn value_constructor(&self) -> DbResult> { + borsh::to_vec(&self).map_err(|err| { + DbError::borsh_cast_message( + err, + Some("Failed to serialize final block meta".to_owned()), + ) + }) + } +} + #[derive(Debug, BorshSerialize, BorshDeserialize)] pub struct LastFinalizedBlockIdCell(pub Option); @@ -132,6 +199,39 @@ impl SimpleWritableCell for ZoneSdkCheckpointCellRef<'_> { } } +/// The last channel block read back and verified from Bedrock. +/// +/// Holds its L1 inscription `slot` plus the block's `id`/`hash`, and serves as +/// both the anchor for the startup consistency check and the resume point for +/// reconstruction. `slot` is stored as a raw `u64` because the zone-sdk `Slot` +/// does not derive borsh; the caller converts to/from `Slot`. +#[derive(Debug, Clone, Copy, BorshSerialize, BorshDeserialize)] +pub struct ZoneAnchorRecord { + pub slot: u64, + pub block_id: u64, + pub hash: HashType, +} + +#[derive(Debug, BorshSerialize, BorshDeserialize)] +pub struct ZoneAnchorCell(pub ZoneAnchorRecord); + +impl SimpleStorableCell for ZoneAnchorCell { + type KeyParams = (); + + const CELL_NAME: &'static str = DB_META_ZONE_CURSOR_KEY; + const CF_NAME: &'static str = CF_META_NAME; +} + +impl SimpleReadableCell for ZoneAnchorCell {} + +impl SimpleWritableCell for ZoneAnchorCell { + fn value_constructor(&self) -> DbResult> { + borsh::to_vec(&self).map_err(|err| { + DbError::borsh_cast_message(err, Some("Failed to serialize zone cursor".to_owned())) + }) + } +} + #[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] pub struct PendingDepositEventRecord { pub deposit_op_id: HashType, diff --git a/lez/storage/src/sequencer/tests.rs b/lez/storage/src/sequencer/tests.rs new file mode 100644 index 00000000..0b6ce862 --- /dev/null +++ b/lez/storage/src/sequencer/tests.rs @@ -0,0 +1,283 @@ +use common::test_utils::produce_dummy_block; +use lee::{Account, AccountId}; +use tempfile::tempdir; + +use super::*; + +fn marker_id() -> AccountId { + AccountId::new([1; 32]) +} + +/// A state distinguishable by the marker account's balance, so tests can tell +/// which snapshot a write persisted. +/// +/// TODO: is this a bit too much of a hot-fix for test snapshot? +fn state_with_balance(balance: u128) -> V03State { + V03State::new().with_public_accounts([( + marker_id(), + Account { + balance, + ..Account::default() + }, + )]) +} + +fn dbio_with_genesis(path: &Path) -> (RocksDBIO, Block) { + let genesis = produce_dummy_block(1, None, vec![]); + let dbio = RocksDBIO::create(path, &genesis, &state_with_balance(100)).unwrap(); + (dbio, genesis) +} + +fn stored_balance(dbio: &RocksDBIO) -> u128 { + dbio.get_lee_state() + .unwrap() + .get_account_by_id(marker_id()) + .balance +} + +#[test] +fn store_followed_block_persists_new_block_and_state() { + let temp_dir = tempdir().unwrap(); + let (dbio, genesis) = dbio_with_genesis(temp_dir.path()); + + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + dbio.store_followed_block(&block2, &state_with_balance(200), false) + .unwrap(); + + let stored = dbio.get_block(2).unwrap().expect("block 2 is stored"); + assert_eq!(stored.header.hash, block2.header.hash); + assert!(matches!(stored.bedrock_status, BedrockStatus::Pending)); + assert_eq!( + dbio.latest_block_meta().unwrap().expect("meta is set").id, + 2 + ); + assert_eq!(stored_balance(&dbio), 200); +} + +#[test] +fn store_followed_block_finalized_marks_block() { + let temp_dir = tempdir().unwrap(); + let (dbio, genesis) = dbio_with_genesis(temp_dir.path()); + + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + dbio.store_followed_block(&block2, &state_with_balance(200), true) + .unwrap(); + + let stored = dbio.get_block(2).unwrap().expect("block 2 is stored"); + assert!(matches!(stored.bedrock_status, BedrockStatus::Finalized)); +} + +#[test] +fn store_followed_block_redelivery_is_a_noop_and_keeps_finalized() { + let temp_dir = tempdir().unwrap(); + let (dbio, genesis) = dbio_with_genesis(temp_dir.path()); + + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + dbio.store_followed_block(&block2, &state_with_balance(200), true) + .unwrap(); + dbio.store_followed_block(&block2, &state_with_balance(300), false) + .unwrap(); + + let stored = dbio.get_block(2).unwrap().expect("block 2 is stored"); + assert!( + matches!(stored.bedrock_status, BedrockStatus::Finalized), + "re-delivery must not demote a finalized block" + ); + assert_eq!( + stored_balance(&dbio), + 200, + "re-delivery must not overwrite the persisted state" + ); +} + +#[test] +fn store_followed_blocks_batch_lands_meta_and_state_on_last_block() { + let temp_dir = tempdir().unwrap(); + let (dbio, genesis) = dbio_with_genesis(temp_dir.path()); + + // Block 2 is already stored (own production); one update then finalizes it + // and adopts block 3. + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + dbio.store_followed_block(&block2, &state_with_balance(200), false) + .unwrap(); + + let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![]); + let head_tip = BlockMeta { + id: 3, + hash: block3.header.hash, + }; + dbio.store_followed_blocks( + &[(&block2, true), (&block3, false)], + Some(&head_tip), + &state_with_balance(300), + None, + ) + .unwrap(); + + let stored2 = dbio.get_block(2).unwrap().expect("block 2 is stored"); + assert!(matches!(stored2.bedrock_status, BedrockStatus::Finalized)); + let stored3 = dbio.get_block(3).unwrap().expect("block 3 is stored"); + assert!(matches!(stored3.bedrock_status, BedrockStatus::Pending)); + + // Meta and state land together on the last block of the batch. + let meta = dbio.latest_block_meta().unwrap().expect("meta is set"); + assert_eq!(meta.id, 3); + assert_eq!(meta.hash, block3.header.hash); + assert_eq!(stored_balance(&dbio), 300); +} + +#[test] +fn final_snapshot_round_trips_and_is_absent_on_fresh_store() { + let temp_dir = tempdir().unwrap(); + let (dbio, genesis) = dbio_with_genesis(temp_dir.path()); + + // Fresh store: no finalization observed yet. + assert!(dbio.get_final_snapshot().unwrap().is_none()); + + // A follow update that finalizes block 2 lands the snapshot in the same batch. + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + let final_meta = BlockMeta { + id: 2, + hash: block2.header.hash, + }; + dbio.store_followed_blocks( + &[(&block2, true)], + Some(&final_meta), + &state_with_balance(300), + Some((&state_with_balance(200), &final_meta)), + ) + .unwrap(); + + let (final_state, meta) = dbio + .get_final_snapshot() + .unwrap() + .expect("final snapshot is stored"); + assert_eq!(meta.id, 2); + assert_eq!(meta.hash, block2.header.hash); + assert_eq!(final_state.get_account_by_id(marker_id()).balance, 200); + // The head state is stored independently of the final snapshot. + assert_eq!(stored_balance(&dbio), 300); +} + +#[test] +fn store_followed_block_overwrites_competing_block_at_same_id() { + let temp_dir = tempdir().unwrap(); + let (dbio, genesis) = dbio_with_genesis(temp_dir.path()); + + let block2a = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + dbio.store_followed_block(&block2a, &state_with_balance(200), false) + .unwrap(); + + // A reorg replaces block 2: the competing block wins the slot. + let block2b = produce_dummy_block(2, Some(HashType([9; 32])), vec![]); + dbio.store_followed_block(&block2b, &state_with_balance(300), false) + .unwrap(); + + let stored = dbio.get_block(2).unwrap().expect("block 2 is stored"); + assert_eq!(stored.header.hash, block2b.header.hash); + assert!(matches!(stored.bedrock_status, BedrockStatus::Pending)); + assert_eq!(stored_balance(&dbio), 300); + + // The tip meta must follow the reorg winner, or a restart seeds the chain + // from the orphaned block's hash. + let meta = dbio.latest_block_meta().unwrap().expect("meta is set"); + assert_eq!(meta.id, 2); + assert_eq!(meta.hash, block2b.header.hash); +} + +#[test] +fn net_shortening_reorg_drops_stale_blocks() { + let temp_dir = tempdir().unwrap(); + let (dbio, genesis) = dbio_with_genesis(temp_dir.path()); + + let block2a = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + dbio.store_followed_block(&block2a, &state_with_balance(200), false) + .unwrap(); + let block3 = produce_dummy_block(3, Some(block2a.header.hash), vec![]); + dbio.store_followed_block(&block3, &state_with_balance(300), false) + .unwrap(); + + // A shorter competing chain wins: block 2 is replaced, block 3 gets no + // replacement. + let block2b = produce_dummy_block(2, Some(HashType([9; 32])), vec![]); + let head_tip = BlockMeta { + id: 2, + hash: block2b.header.hash, + }; + dbio.store_followed_blocks( + &[(&block2b, false)], + Some(&head_tip), + &state_with_balance(400), + None, + ) + .unwrap(); + + let stored2 = dbio.get_block(2).unwrap().expect("block 2 is stored"); + assert_eq!(stored2.header.hash, block2b.header.hash); + assert!( + dbio.get_block(3).unwrap().is_none(), + "stale block above the new head must be deleted, or restart replay panics on its broken link" + ); + let meta = dbio.latest_block_meta().unwrap().expect("meta is set"); + assert_eq!(meta.id, 2); + assert_eq!(meta.hash, block2b.header.hash); + assert_eq!(stored_balance(&dbio), 400); +} + +#[test] +fn shrink_only_reorg_rewinds_tip_meta() { + let temp_dir = tempdir().unwrap(); + let (dbio, genesis) = dbio_with_genesis(temp_dir.path()); + + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + dbio.store_followed_block(&block2, &state_with_balance(200), false) + .unwrap(); + let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![]); + dbio.store_followed_block(&block3, &state_with_balance(300), false) + .unwrap(); + + // Orphan-only update: block 3 falls off the branch with no replacement. + let head_tip = BlockMeta { + id: 2, + hash: block2.header.hash, + }; + dbio.store_followed_blocks(&[], Some(&head_tip), &state_with_balance(200), None) + .unwrap(); + + assert!( + dbio.get_block(3).unwrap().is_none(), + "the orphaned block must not survive the tip rewind" + ); + let meta = dbio.latest_block_meta().unwrap().expect("meta is set"); + assert_eq!(meta.id, 2); + assert_eq!(meta.hash, block2.header.hash); + assert_eq!(stored_balance(&dbio), 200); +} + +#[test] +fn produced_block_below_disk_head_pins_meta_and_prunes() { + let temp_dir = tempdir().unwrap(); + let (dbio, genesis) = dbio_with_genesis(temp_dir.path()); + + let block2a = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + dbio.store_followed_block(&block2a, &state_with_balance(200), false) + .unwrap(); + let block3 = produce_dummy_block(3, Some(block2a.header.hash), vec![]); + dbio.store_followed_block(&block3, &state_with_balance(300), false) + .unwrap(); + + // Producing at height 2 while the disk head is still 3: the produce path + // pins the tip meta to the produced block and drops the stale suffix in + // the same write, mirroring the follow path. + let block2b = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + dbio.atomic_update(&block2b, &[], vec![], &state_with_balance(400)) + .unwrap(); + + let stored2 = dbio.get_block(2).unwrap().expect("block 2 is stored"); + assert_eq!(stored2.header.hash, block2b.header.hash); + assert!(dbio.get_block(3).unwrap().is_none()); + let meta = dbio.latest_block_meta().unwrap().expect("meta is set"); + assert_eq!(meta.id, 2); + assert_eq!(meta.hash, block2b.header.hash); + assert_eq!(stored_balance(&dbio), 400); +} diff --git a/lez/wallet/src/account_manager.rs b/lez/wallet/src/account_manager.rs index eb423650..e3fdf213 100644 --- a/lez/wallet/src/account_manager.rs +++ b/lez/wallet/src/account_manager.rs @@ -8,7 +8,7 @@ use lee_core::{ NullifierPublicKey, NullifierSecretKey, SharedSecretKey, account::{AccountWithMetadata, Nonce}, compute_digest_for_path, - encryption::ViewingPublicKey, + encryption::{ViewTag, ViewingPublicKey}, }; use rand::{RngCore as _, rngs::OsRng}; @@ -411,6 +411,7 @@ impl AccountManager { (Some(nsk), Some(membership_proof)) => InputAccountIdentity::PrivatePdaUpdate { vpk: pre.vpk.clone(), random_seed: pre.random_seed, + view_tag: random_view_tag(), nsk, membership_proof, identifier: pre.identifier, @@ -430,6 +431,7 @@ impl AccountManager { InputAccountIdentity::PrivateAuthorizedUpdate { vpk: pre.vpk.clone(), random_seed: pre.random_seed, + view_tag: random_view_tag(), nsk, membership_proof, identifier: pre.identifier, @@ -650,6 +652,13 @@ fn validate_proofs_against_root( Ok(()) } +/// Generate random byte using OS randomness. +fn random_view_tag() -> ViewTag { + let mut byte: [u8; 1] = [0; 1]; + OsRng.fill_bytes(&mut byte); + byte[0] +} + #[cfg(test)] mod tests { use super::*; diff --git a/lez/wallet/src/cli/account.rs b/lez/wallet/src/cli/account.rs index c700d2e9..c165deee 100644 --- a/lez/wallet/src/cli/account.rs +++ b/lez/wallet/src/cli/account.rs @@ -111,8 +111,9 @@ pub enum NewSubcommand { #[arg(long, requires = "pda")] /// Program ID as hex string. program_id: Option, - #[arg(long, requires = "pda")] - /// Identifier that diversifies this PDA within the (`program_id`, seed, npk) family. + #[arg(long)] + /// Identifier selecting the shared account. + /// Co-owners must supply the same value to derive the same account. /// Defaults to a random value if not specified. identifier: Option, }, @@ -197,7 +198,7 @@ impl NewSubcommand { Ok(SubcommandReturnValue::RegisterAccount { account_id }) } - fn handle_private_gms( + async fn handle_private_gms( group: &Label, label: Option