diff --git a/src/rpc/blockchain.rs b/src/rpc/blockchain.rs index b854122..74b6889 100644 --- a/src/rpc/blockchain.rs +++ b/src/rpc/blockchain.rs @@ -784,6 +784,16 @@ impl BlockchainRpc { /// Verify blockchain database /// /// Params: [checklevel (optional, default: 3), numblocks (optional, default: 288)] + /// + /// Bitcoin Core checklevel semantics: + /// - Level 0: Check block exists + /// - Level 1: Check block can be deserialized + /// - Level 2: Check merkle root + /// - Level 3: Check block header linkage (prev_block_hash) + /// - Level 4: Full UTXO validation (requires rewinding UTXO set with undo logs) + /// + /// Note: Level 4 requires undo logs to be stored during initial sync. Without undo logs, + /// level 4 validation will report an error and fall back to level 3 checks. pub async fn verify_chain( &self, checklevel: Option, @@ -795,11 +805,6 @@ impl BlockchainRpc { ); if let Some(ref storage) = self.storage { - let engine_arc = self - .protocol - .as_ref() - .ok_or_else(|| anyhow::anyhow!("Protocol engine not initialised"))?; - let check_level = checklevel.unwrap_or(3); let num_blocks = numblocks.unwrap_or(288); @@ -812,66 +817,77 @@ impl BlockchainRpc { let start_height = tip_height.saturating_sub(num_blocks); let mut errors = Vec::new(); - let mut utxo_set = storage - .utxos() - .get_all_utxos() - .map_err(|e| anyhow::anyhow!("Failed to get UTXO set: {}", e))?; - let blockstore = storage.blocks(); - let protocol_version = engine_arc.get_protocol_version(); - - // Verify blocks from start_height to tip - for height in start_height..=tip_height { - if let Ok(Some(block_hash)) = storage.blocks().get_hash_by_height(height) { - if let Ok(Some(block)) = storage.blocks().get_block(&block_hash) { - let witnesses_result = prepare_block_validation_context( - blockstore.as_ref(), - &block, - height, - protocol_version, - ); - - let witnesses = match witnesses_result { - Ok((w, _)) => w, - Err(e) => { - errors.push(format!( - "Block at height {height} witness load error: {e}" - )); - if check_level >= 4 { - break; + // For level 4 (full UTXO validation), we need to rewind the UTXO set + // to the state before start_height, then reconnect blocks. + // This requires undo logs to be available. + #[cfg(feature = "production")] + let mut utxo_set_for_level4: Option = None; + #[cfg(feature = "production")] + let engine_arc_opt = self.protocol.as_ref(); + + #[cfg(feature = "production")] + if check_level >= 4 { + if let Some(_engine_arc) = engine_arc_opt { + // Load the current tip UTXO set + let mut utxo_set = storage + .utxos() + .get_all_utxos() + .map_err(|e| anyhow::anyhow!("Failed to get UTXO set: {}", e))?; + + // Rewind UTXO set from tip down to start_height using undo logs. + // Apply disconnect semantics matching blvm_consensus::reorganization::disconnect_block: + // One pass per entry: remove new_utxo then restore previous_utxo. + // This handles same-block create→spend correctly (restore wins over remove). + let mut rewind_failed = false; + for height in (start_height..=tip_height).rev() { + if let Ok(Some(block_hash)) = storage.blocks().get_hash_by_height(height) { + match blockstore.get_undo_log(&block_hash) { + Ok(Some(undo_log)) => { + for entry in undo_log.entries.iter() { + // Remove created output (if this entry created one) + if entry.new_utxo.is_some() { + utxo_set.remove(&entry.outpoint); + } + // Restore spent input (if this entry spent one) + if let Some(ref prev_utxo) = entry.previous_utxo { + utxo_set.insert(entry.outpoint, prev_utxo.clone()); + } + } } - continue; - } - }; - - match validate_block_with_context( - blockstore.as_ref(), - engine_arc, - &block, - &witnesses, - &mut utxo_set, - height, - ) { - Ok(blvm_protocol::ValidationResult::Valid) => {} - Ok(blvm_protocol::ValidationResult::Invalid(reason)) => { - errors.push(format!("Block at height {height} invalid: {reason}")); - if check_level >= 4 { - // Level 4: Stop on first error + Ok(None) => { + // No undo log available - cannot do level 4 validation + errors.push(format!( + "Level 4 validation requires undo logs; missing for height {height}" + )); + rewind_failed = true; break; } - } - Err(e) => { - errors.push(format!( - "Block at height {height} validation error: {e}" - )); - if check_level >= 4 { + Err(e) => { + errors.push(format!( + "Failed to load undo log for height {height}: {e}" + )); + rewind_failed = true; break; } } } + } - // Check level 3: Verify block header linkage + if !rewind_failed { + utxo_set_for_level4 = Some(utxo_set); + } + } else { + errors.push("Level 4 validation requires protocol engine".to_string()); + } + } + + // Verify blocks from start_height to tip + for height in start_height..=tip_height { + if let Ok(Some(block_hash)) = storage.blocks().get_hash_by_height(height) { + if let Ok(Some(block)) = storage.blocks().get_block(&block_hash) { + // Check level 3+: Verify block header linkage if check_level >= 3 && height > 0 { if let Ok(Some(prev_hash)) = storage.blocks().get_hash_by_height(height - 1) @@ -890,7 +906,7 @@ impl BlockchainRpc { } } - // Check level 2: Verify merkle root + // Check level 2+: Verify merkle root if check_level >= 2 { use blvm_protocol::mining::{ calculate_merkle_root, calculate_merkle_root_from_tx_ids, @@ -913,6 +929,100 @@ impl BlockchainRpc { } } } + + // Check level 4: Full UTXO validation (reconnect block) + // Uses per-block height MTP (not tip-relative) for proper replay + #[cfg(feature = "production")] + if check_level >= 4 { + if let Some(ref mut utxo_set) = utxo_set_for_level4 { + if let Some(engine_arc) = engine_arc_opt { + let protocol_version = engine_arc.get_protocol_version(); + + // Load witnesses for this block + let witnesses = + match crate::node::block_processor::load_witnesses_for_block( + blockstore.as_ref(), + &block, + height, + protocol_version, + ) { + Ok(w) => w, + Err(e) => { + errors.push(format!( + "Block at height {height} witness load error: {e}" + )); + break; + } + }; + + // Get MTP headers at this block's height (not tip-relative) + let mtp_headers = blockstore + .headers_before_height_for_mtp(height) + .ok() + .filter(|h| !h.is_empty()); + + let median_time_past = mtp_headers + .as_ref() + .map(|h| blvm_protocol::bip113::get_median_time_past(h)) + .unwrap_or(0); + + let network_time = crate::utils::current_timestamp(); + + // Create validation context with correct per-height MTP + let mut context = match blvm_protocol::validation::ProtocolValidationContext::new( + protocol_version, + height, + ) { + Ok(c) => c, + Err(e) => { + errors.push(format!( + "Block at height {height} context error: {e}" + )); + break; + } + }; + context.context_data.insert( + "median_time_past".to_string(), + median_time_past.to_string(), + ); + context.context_data.insert( + "network_time".to_string(), + network_time.to_string(), + ); + + // Validate and connect block + let owned_utxo = std::mem::take(utxo_set); + match engine_arc.validate_and_connect_block_with_difficulty( + &block, + &witnesses, + &owned_utxo, + height, + mtp_headers.as_deref(), + &context, + Some(blockstore.difficulty_ancestor_lookup()), + ) { + Ok((blvm_protocol::ValidationResult::Valid, new_utxo)) => { + *utxo_set = new_utxo; + } + Ok(( + blvm_protocol::ValidationResult::Invalid(reason), + _, + )) => { + errors.push(format!( + "Block at height {height} invalid: {reason}" + )); + break; + } + Err(e) => { + errors.push(format!( + "Block at height {height} validation error: {e}" + )); + break; + } + } + } + } + } } else { errors.push(format!("Block at height {height} not found in storage")); if check_level >= 4 { diff --git a/tests/blockchain_rpc_tests.rs b/tests/blockchain_rpc_tests.rs index 7b9c8b2..ba48a7c 100644 --- a/tests/blockchain_rpc_tests.rs +++ b/tests/blockchain_rpc_tests.rs @@ -50,3 +50,408 @@ async fn test_get_chain_name_without_protocol() { let info = rpc_with_storage.get_blockchain_state().await.unwrap(); assert_eq!(chain_from_state(&info), "regtest"); } + +/// Test that verifychain returns true on a chain with non-coinbase spends. +/// This is the key regression test for GitHub issue #10. +/// +/// Prior to the fix, verifychain would attempt full UTXO validation at all check levels, +/// which would fail on blocks with spends because those blocks were already applied to +/// the tip UTXO set. +#[tokio::test] +async fn test_verify_chain_with_spends_returns_true() { + let temp_dir = TempDir::new().unwrap(); + let storage = Arc::new(Storage::new(temp_dir.path()).unwrap()); + + // Create genesis block with a coinbase transaction that has a spendable output + let coinbase_script: blvm_node::ByteString = vec![0x51].into(); // OP_1 + let coinbase_output_value = 50_000_000_00i64; // 50 BTC in satoshis + + let genesis_coinbase = blvm_protocol::Transaction { + version: 1, + inputs: blvm_protocol::tx_inputs![blvm_protocol::TransactionInput { + prevout: blvm_node::OutPoint { + hash: [0u8; 32], + index: 0xffffffff, + }, + script_sig: vec![0x00, 0xff], // BIP34 height 0: OP_0 + padding + sequence: 0xffffffff, + }], + outputs: blvm_protocol::tx_outputs![blvm_protocol::TransactionOutput { + value: coinbase_output_value, + script_pubkey: coinbase_script.clone().into(), + }], + lock_time: 0, + }; + + let genesis_header = blvm_protocol::BlockHeader { + version: 1, + prev_block_hash: [0u8; 32], + merkle_root: blvm_protocol::mining::calculate_merkle_root(&[genesis_coinbase.clone()]) + .unwrap(), + timestamp: 1_231_006_505, + bits: 0x0f00ffff, + nonce: 1, + }; + + let genesis_block = blvm_node::Block { + header: genesis_header.clone(), + transactions: vec![genesis_coinbase.clone()].into_boxed_slice(), + }; + + let genesis_hash = storage.blocks().get_block_hash(&genesis_block); + storage.blocks().store_block(&genesis_block).unwrap(); + storage.blocks().store_height(0, &genesis_hash).unwrap(); + storage.chain().initialize(&genesis_header).unwrap(); + + // Add the coinbase UTXO to the UTXO set + let coinbase_txid = blvm_protocol::block::calculate_tx_id(&genesis_coinbase); + let coinbase_outpoint = blvm_node::OutPoint { + hash: coinbase_txid, + index: 0, + }; + storage + .utxos() + .add_utxo( + &coinbase_outpoint, + &blvm_node::UTXO { + value: coinbase_output_value, + script_pubkey: coinbase_script.clone().into(), + height: 0, + is_coinbase: true, + }, + ) + .unwrap(); + + // Create block 1 with a coinbase and a transaction spending the genesis coinbase + let block1_coinbase = blvm_protocol::Transaction { + version: 1, + inputs: blvm_protocol::tx_inputs![blvm_protocol::TransactionInput { + prevout: blvm_node::OutPoint { + hash: [0u8; 32], + index: 0xffffffff, + }, + script_sig: vec![0x01, 0x01], + sequence: 0xffffffff, + }], + outputs: blvm_protocol::tx_outputs![blvm_protocol::TransactionOutput { + value: coinbase_output_value, + script_pubkey: coinbase_script.clone().into(), + }], + lock_time: 0, + }; + + // Transaction spending the genesis coinbase output + let spend_tx = blvm_protocol::Transaction { + version: 1, + inputs: blvm_protocol::tx_inputs![blvm_protocol::TransactionInput { + prevout: coinbase_outpoint, + script_sig: vec![0x51], + sequence: 0xffffffff, + }], + outputs: blvm_protocol::tx_outputs![blvm_protocol::TransactionOutput { + value: coinbase_output_value - 1000, + script_pubkey: coinbase_script.clone().into(), + }], + lock_time: 0, + }; + + let block1_txs = vec![block1_coinbase.clone(), spend_tx.clone()]; + let block1_merkle = blvm_protocol::mining::calculate_merkle_root(&block1_txs).unwrap(); + + let block1_header = blvm_protocol::BlockHeader { + version: 1, + prev_block_hash: genesis_hash, + merkle_root: block1_merkle, + timestamp: 1_231_006_605, + bits: 0x0f00ffff, + nonce: 2, + }; + + let block1 = blvm_node::Block { + header: block1_header.clone(), + transactions: block1_txs.into_boxed_slice(), + }; + + let block1_hash = storage.blocks().get_block_hash(&block1); + storage.blocks().store_block(&block1).unwrap(); + storage.blocks().store_height(1, &block1_hash).unwrap(); + storage + .chain() + .update_tip(&block1_hash, &block1_header, 1) + .unwrap(); + + // Update UTXO set: remove spent coinbase, add new outputs + storage.utxos().remove_utxo(&coinbase_outpoint).unwrap(); + + // Add block1 coinbase output + let block1_coinbase_txid = blvm_protocol::block::calculate_tx_id(&block1_coinbase); + storage + .utxos() + .add_utxo( + &blvm_node::OutPoint { + hash: block1_coinbase_txid, + index: 0, + }, + &blvm_node::UTXO { + value: coinbase_output_value, + script_pubkey: coinbase_script.clone().into(), + height: 1, + is_coinbase: true, + }, + ) + .unwrap(); + + // Add spend_tx output + let spend_txid = blvm_protocol::block::calculate_tx_id(&spend_tx); + storage + .utxos() + .add_utxo( + &blvm_node::OutPoint { + hash: spend_txid, + index: 0, + }, + &blvm_node::UTXO { + value: coinbase_output_value - 1000, + script_pubkey: coinbase_script.into(), + height: 1, + is_coinbase: false, + }, + ) + .unwrap(); + + // Test verifychain at different levels + let protocol = Arc::new(BitcoinProtocolEngine::new(ProtocolVersion::Regtest).unwrap()); + let rpc = BlockchainRpc::with_dependencies_and_protocol(storage, protocol); + + // Level 1: basic checks only + let result_level1 = rpc.verify_chain(Some(1), Some(10)).await.unwrap(); + assert!( + result_level1.as_bool() == Some(true), + "verifychain level 1 should return true for chain with spends, got: {result_level1}" + ); + + // Level 2: merkle root check + let result_level2 = rpc.verify_chain(Some(2), Some(10)).await.unwrap(); + assert!( + result_level2.as_bool() == Some(true), + "verifychain level 2 should return true for chain with spends, got: {result_level2}" + ); + + // Level 3: header linkage check (default level) + let result_level3 = rpc.verify_chain(Some(3), Some(10)).await.unwrap(); + assert!( + result_level3.as_bool() == Some(true), + "verifychain level 3 should return true for chain with spends, got: {result_level3}" + ); + + // Default (no level specified, should use level 3) + let result_default = rpc.verify_chain(None, None).await.unwrap(); + assert!( + result_default.as_bool() == Some(true), + "verifychain default should return true for chain with spends, got: {result_default}" + ); +} + +/// Test that verifychain level 4 works correctly with undo logs. +/// Level 4 requires undo logs to rewind the UTXO set before replaying blocks. +/// This test uses coinbase-only blocks to verify undo log rewind mechanics +/// without hitting coinbase maturity constraints (which require 100 blocks). +#[tokio::test] +#[cfg(feature = "production")] +async fn test_verify_chain_level4_with_undo_logs() { + use blvm_consensus::reorganization::{BlockUndoLog, UndoEntry}; + use std::sync::Arc as StdArc; + + let temp_dir = TempDir::new().unwrap(); + let storage = Arc::new(Storage::new(temp_dir.path()).unwrap()); + + // Create genesis block with a coinbase transaction + let coinbase_script: blvm_node::ByteString = vec![0x51].into(); // OP_1 + let coinbase_output_value = 50_000_000_00i64; + + let genesis_coinbase = blvm_protocol::Transaction { + version: 1, + inputs: blvm_protocol::tx_inputs![blvm_protocol::TransactionInput { + prevout: blvm_node::OutPoint { + hash: [0u8; 32], + index: 0xffffffff, + }, + script_sig: vec![0x00, 0xff], // BIP34 height 0: OP_0 + padding + sequence: 0xffffffff, + }], + outputs: blvm_protocol::tx_outputs![blvm_protocol::TransactionOutput { + value: coinbase_output_value, + script_pubkey: coinbase_script.clone().into(), + }], + lock_time: 0, + }; + + let genesis_header = blvm_protocol::BlockHeader { + version: 0x20000000, // BIP9 version bits (required for regtest consensus) + prev_block_hash: [0u8; 32], + merkle_root: blvm_protocol::mining::calculate_merkle_root(&[genesis_coinbase.clone()]) + .unwrap(), + timestamp: 1_231_006_505, + bits: 0x0f00ffff, + nonce: 1, + }; + + let genesis_block = blvm_node::Block { + header: genesis_header.clone(), + transactions: vec![genesis_coinbase.clone()].into_boxed_slice(), + }; + + let genesis_hash = storage.blocks().get_block_hash(&genesis_block); + storage.blocks().store_block(&genesis_block).unwrap(); + storage.blocks().store_height(0, &genesis_hash).unwrap(); + storage.chain().initialize(&genesis_header).unwrap(); + + // Store genesis header for MTP + storage + .blocks() + .store_recent_header(0, &genesis_header) + .unwrap(); + + // Store empty witnesses for genesis block (required for regtest where segwit is active) + let genesis_witnesses: Vec> = genesis_block + .transactions + .iter() + .map(|tx| tx.inputs.iter().map(|_| Vec::new()).collect()) + .collect(); + storage + .blocks() + .store_witness(&genesis_hash, &genesis_witnesses) + .unwrap(); + + // Add the coinbase UTXO to the UTXO set + let coinbase_txid = blvm_protocol::block::calculate_tx_id(&genesis_coinbase); + let coinbase_outpoint = blvm_node::OutPoint { + hash: coinbase_txid, + index: 0, + }; + let genesis_coinbase_utxo = blvm_node::UTXO { + value: coinbase_output_value, + script_pubkey: coinbase_script.clone().into(), + height: 0, + is_coinbase: true, + }; + storage + .utxos() + .add_utxo(&coinbase_outpoint, &genesis_coinbase_utxo) + .unwrap(); + + // Create undo log for genesis block (coinbase output created) + let genesis_undo = BlockUndoLog { + entries: vec![UndoEntry { + outpoint: coinbase_outpoint, + previous_utxo: None, // No previous UTXO (this is a creation) + new_utxo: Some(StdArc::new(genesis_coinbase_utxo.clone())), + }], + }; + storage + .blocks() + .store_undo_log(&genesis_hash, &genesis_undo) + .unwrap(); + + // Create block 1 with just a coinbase (no spends due to coinbase maturity) + let block1_coinbase = blvm_protocol::Transaction { + version: 1, + inputs: blvm_protocol::tx_inputs![blvm_protocol::TransactionInput { + prevout: blvm_node::OutPoint { + hash: [0u8; 32], + index: 0xffffffff, + }, + script_sig: vec![0x01, 0x01], // BIP34 height 1 + sequence: 0xffffffff, + }], + outputs: blvm_protocol::tx_outputs![blvm_protocol::TransactionOutput { + value: coinbase_output_value, + script_pubkey: coinbase_script.clone().into(), + }], + lock_time: 0, + }; + + let block1_txs = vec![block1_coinbase.clone()]; + let block1_merkle = blvm_protocol::mining::calculate_merkle_root(&block1_txs).unwrap(); + + let block1_header = blvm_protocol::BlockHeader { + version: 0x20000000, // BIP9 version bits (required for regtest consensus) + prev_block_hash: genesis_hash, + merkle_root: block1_merkle, + timestamp: 1_231_006_605, + bits: 0x0f00ffff, + nonce: 2, + }; + + let block1 = blvm_node::Block { + header: block1_header.clone(), + transactions: block1_txs.into_boxed_slice(), + }; + + let block1_hash = storage.blocks().get_block_hash(&block1); + storage.blocks().store_block(&block1).unwrap(); + storage.blocks().store_height(1, &block1_hash).unwrap(); + storage + .blocks() + .store_recent_header(1, &block1_header) + .unwrap(); + storage + .chain() + .update_tip(&block1_hash, &block1_header, 1) + .unwrap(); + + // Store empty witnesses for block 1 (required for regtest where segwit is active) + let block1_witnesses: Vec> = block1 + .transactions + .iter() + .map(|tx| tx.inputs.iter().map(|_| Vec::new()).collect()) + .collect(); + storage + .blocks() + .store_witness(&block1_hash, &block1_witnesses) + .unwrap(); + + // Add block1 coinbase output to UTXO set + let block1_coinbase_txid = blvm_protocol::block::calculate_tx_id(&block1_coinbase); + let block1_coinbase_outpoint = blvm_node::OutPoint { + hash: block1_coinbase_txid, + index: 0, + }; + let block1_coinbase_utxo = blvm_node::UTXO { + value: coinbase_output_value, + script_pubkey: coinbase_script.into(), + height: 1, + is_coinbase: true, + }; + storage + .utxos() + .add_utxo(&block1_coinbase_outpoint, &block1_coinbase_utxo) + .unwrap(); + + // Create undo log for block 1 (only coinbase output created) + let block1_undo = BlockUndoLog { + entries: vec![UndoEntry { + outpoint: block1_coinbase_outpoint, + previous_utxo: None, + new_utxo: Some(StdArc::new(block1_coinbase_utxo)), + }], + }; + storage + .blocks() + .store_undo_log(&block1_hash, &block1_undo) + .unwrap(); + + // Test verifychain at level 4 + let protocol = Arc::new(BitcoinProtocolEngine::new(ProtocolVersion::Regtest).unwrap()); + let rpc = BlockchainRpc::with_dependencies_and_protocol(storage, protocol); + + // Level 4: Full UTXO validation with undo log rewind + // This tests that: + // 1. Undo logs are correctly loaded and applied (rewind) + // 2. Blocks are correctly replayed (validation + UTXO creation) + let result_level4 = rpc.verify_chain(Some(4), Some(10)).await.unwrap(); + assert!( + result_level4.as_bool() == Some(true), + "verifychain level 4 should return true for valid chain with undo logs, got: {result_level4}" + ); +}