#![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(), } } /// 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 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 mint's receipt PDA is in the reconstructed state, and reconstruction // dropped the pending record backfill had re-delivered — so the production // drain sees the deposit as minted and never re-emits it. assert!( crate::deposit_already_minted( chain_b.lock().unwrap().head_state(), HashType(deposit_op_id) ), "the reconstructed deposit's receipt marks it minted" ); assert!( store_b.get_pending_deposit_events().unwrap().is_empty(), "reconstruction drops the finalized deposit's pending record" ); } #[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" ); }