From 68d77989227d145ce205d9bae9652440363e902b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 6 Oct 2026 21:41:28 +0000 Subject: [PATCH 1/5] fix(rpc): verifychain false failures on chains with spends The verifychain RPC was incorrectly returning false/invalid on valid chains containing non-coinbase spends. The issue was that verify_chain loaded the current tip UTXO set and then tried to validate blocks that were already applied to that set. Changes: - Levels 0-3: Only perform header/merkle/linkage checks, no UTXO validation - Level 4: Properly rewind UTXO set using undo logs before reconnecting blocks - Add detailed docstring explaining Bitcoin Core checklevel semantics - Feature-gate level 4 UTXO validation under 'production' feature Test: - Add test_verify_chain_with_spends_returns_true that creates a chain with non-coinbase spends and verifies that verifychain returns true at all levels Closes #10 Co-authored-by: Secure Sovereign --- src/rpc/blockchain.rs | 188 +++++++++++++++++++++---------- tests/blockchain_rpc_tests.rs | 201 ++++++++++++++++++++++++++++++++++ 2 files changed, 333 insertions(+), 56 deletions(-) diff --git a/src/rpc/blockchain.rs b/src/rpc/blockchain.rs index b8541222..fd7966c6 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,88 @@ 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. + // Each undo log contains: spent UTXOs to restore + created outputs to 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)) => { + // Apply the undo: restore spent UTXOs, remove created outputs + // Each UndoEntry has: + // - outpoint: the outpoint that was spent + // - previous_utxo: the UTXO that existed before (needs to be restored) + for entry in undo_log.entries.iter() { + // Restore the spent UTXO + if let Some(ref prev_utxo) = entry.previous_utxo { + utxo_set.insert(entry.outpoint, prev_utxo.clone()); + } + } + // Remove the outputs created by this block + if let Ok(Some(block)) = blockstore.get_block(&block_hash) { + for tx in block.transactions.iter() { + let txid = blvm_protocol::block::calculate_tx_id(tx); + for (out_idx, _) in tx.outputs.iter().enumerate() { + let outpoint = blvm_protocol::OutPoint { + hash: txid, + index: out_idx as u32, + }; + utxo_set.remove(&outpoint); + } + } + } } - 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; } } } + } + + if !rewind_failed { + utxo_set_for_level4 = Some(utxo_set); + } + } else { + errors.push("Level 4 validation requires protocol engine".to_string()); + } + } - // Check level 3: Verify block header linkage + // 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 +917,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 +940,55 @@ impl BlockchainRpc { } } } + + // Check level 4: Full UTXO validation (reconnect block) + #[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(); + 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}" + )); + break; + } + }; + + match validate_block_with_context( + blockstore.as_ref(), + engine_arc, + &block, + &witnesses, + utxo_set, + height, + ) { + Ok(blvm_protocol::ValidationResult::Valid) => {} + 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 7b9c8b2d..8d45f6cb 100644 --- a/tests/blockchain_rpc_tests.rs +++ b/tests/blockchain_rpc_tests.rs @@ -50,3 +50,204 @@ 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![0x04, 0xff, 0xff, 0x00, 0x1d], + 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}" + ); +} From c6c33d4f85cdc2beec7b0f3c88dddbaa898d0620 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 6 Oct 2026 22:07:33 +0000 Subject: [PATCH 2/5] Fix verifychain level 4: consensus disconnect semantics + per-block MTP Level 4 undo log rewind: - Use correct disconnect order: remove new_utxo first, then restore previous_utxo - This matches blvm_consensus::reorganization disconnect_block semantics Level 4 MTP fix: - Use headers_before_height_for_mtp(height) for per-block MTP - Previously used tip-relative MTP which was incorrect during replay Test improvements: - Fix BIP34 height encoding (height 0 = OP_0 + padding) - Simplify level 4 test to use coinbase-only blocks (avoids 100-block maturity requirement for spending coinbase outputs) - Test verifies undo log rewind + block replay mechanics Closes #10 Co-authored-by: Secure Sovereign --- Cargo.toml | 14 +-- src/rpc/blockchain.rs | 92 ++++++++++------ tests/blockchain_rpc_tests.rs | 190 +++++++++++++++++++++++++++++++++- 3 files changed, 258 insertions(+), 38 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index e2c414c8..dc6f83c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -309,13 +309,13 @@ compression = ["block-compression", "utxo-compression", "witness-compression"] # After bump: run plain `cargo audit`, `cargo tree -i quinn-proto`, `cargo tree -i time`, then remove obsolete ignores. [patch.crates-io] quinn-proto = { git = "https://github.com/quinn-rs/quinn", tag = "quinn-proto-0.11.15" } -# Local monorepo: sibling paths override the blvm-* version pins above. -blvm-protocol = { path = "../blvm-protocol" } -blvm-consensus = { path = "../blvm-consensus" } -blvm-primitives = { path = "../blvm-primitives" } -blvm-secp256k1 = { path = "../blvm-secp256k1" } -blvm-spec-lock = { path = "../blvm-spec-lock" } -blvm-muhash = { path = "../blvm-muhash" } +# Local monorepo paths commented out for CI builds (strip-patch-crates-io action handles this). +# blvm-protocol = { path = "../blvm-protocol" } +# blvm-consensus = { path = "../blvm-consensus" } +# blvm-primitives = { path = "../blvm-primitives" } +# blvm-secp256k1 = { path = "../blvm-secp256k1" } +# blvm-spec-lock = { path = "../blvm-spec-lock" } +# blvm-muhash = { path = "../blvm-muhash" } [dev-dependencies] blvm-protocol = { version = ">=0.1, <1", features = ["test-utils"] } diff --git a/src/rpc/blockchain.rs b/src/rpc/blockchain.rs index fd7966c6..255c038c 100644 --- a/src/rpc/blockchain.rs +++ b/src/rpc/blockchain.rs @@ -837,33 +837,24 @@ impl BlockchainRpc { .map_err(|e| anyhow::anyhow!("Failed to get UTXO set: {}", e))?; // Rewind UTXO set from tip down to start_height using undo logs. - // Each undo log contains: spent UTXOs to restore + created outputs to remove. + // Must apply disconnect semantics matching blvm_consensus::reorganization: + // 1. First remove created outputs (new_utxo) + // 2. Then restore spent inputs (previous_utxo) 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)) => { - // Apply the undo: restore spent UTXOs, remove created outputs - // Each UndoEntry has: - // - outpoint: the outpoint that was spent - // - previous_utxo: the UTXO that existed before (needs to be restored) + // Phase 1: Remove created outputs (reverse the creation) for entry in undo_log.entries.iter() { - // Restore the spent UTXO - if let Some(ref prev_utxo) = entry.previous_utxo { - utxo_set.insert(entry.outpoint, prev_utxo.clone()); + if entry.new_utxo.is_some() { + utxo_set.remove(&entry.outpoint); } } - // Remove the outputs created by this block - if let Ok(Some(block)) = blockstore.get_block(&block_hash) { - for tx in block.transactions.iter() { - let txid = blvm_protocol::block::calculate_tx_id(tx); - for (out_idx, _) in tx.outputs.iter().enumerate() { - let outpoint = blvm_protocol::OutPoint { - hash: txid, - index: out_idx as u32, - }; - utxo_set.remove(&outpoint); - } + // Phase 2: Restore spent inputs (reverse the spend) + for entry in undo_log.entries.iter() { + if let Some(ref prev_utxo) = entry.previous_utxo { + utxo_set.insert(entry.outpoint, prev_utxo.clone()); } } } @@ -942,20 +933,21 @@ 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(); - let witnesses_result = prepare_block_validation_context( + + // Load witnesses for this block + let witnesses = match crate::node::block_processor::load_witnesses_for_block( blockstore.as_ref(), &block, height, protocol_version, - ); - - let witnesses = match witnesses_result { - Ok((w, _)) => w, + ) { + Ok(w) => w, Err(e) => { errors.push(format!( "Block at height {height} witness load error: {e}" @@ -964,16 +956,56 @@ impl BlockchainRpc { } }; - match validate_block_with_context( - blockstore.as_ref(), - engine_arc, + // 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, - utxo_set, + &owned_utxo, height, + mtp_headers.as_deref(), + &context, + Some(blockstore.difficulty_ancestor_lookup()), ) { - Ok(blvm_protocol::ValidationResult::Valid) => {} - Ok(blvm_protocol::ValidationResult::Invalid(reason)) => { + 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}" )); diff --git a/tests/blockchain_rpc_tests.rs b/tests/blockchain_rpc_tests.rs index 8d45f6cb..668a221c 100644 --- a/tests/blockchain_rpc_tests.rs +++ b/tests/blockchain_rpc_tests.rs @@ -73,7 +73,7 @@ async fn test_verify_chain_with_spends_returns_true() { hash: [0u8; 32], index: 0xffffffff, }, - script_sig: vec![0x04, 0xff, 0xff, 0x00, 0x1d], + script_sig: vec![0x00, 0xff], // BIP34 height 0: OP_0 + padding sequence: 0xffffffff, }], outputs: blvm_protocol::tx_outputs![blvm_protocol::TransactionOutput { @@ -251,3 +251,191 @@ async fn test_verify_chain_with_spends_returns_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}" + ); +} From 9f94ed9cf6d5a1048de27b5ba82daf8f77b808b9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 6 Oct 2026 22:09:04 +0000 Subject: [PATCH 3/5] Fix formatting (trailing space in doc comment + rustfmt) Co-authored-by: Secure Sovereign --- src/rpc/blockchain.rs | 30 +++++++++++++----------- tests/blockchain_rpc_tests.rs | 44 ++++++++++++++++++++++++----------- 2 files changed, 47 insertions(+), 27 deletions(-) diff --git a/src/rpc/blockchain.rs b/src/rpc/blockchain.rs index 255c038c..f5480c90 100644 --- a/src/rpc/blockchain.rs +++ b/src/rpc/blockchain.rs @@ -941,20 +941,21 @@ impl BlockchainRpc { 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!( + 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; - } - }; + break; + } + }; // Get MTP headers at this block's height (not tip-relative) let mtp_headers = blockstore @@ -1005,7 +1006,10 @@ impl BlockchainRpc { Ok((blvm_protocol::ValidationResult::Valid, new_utxo)) => { *utxo_set = new_utxo; } - Ok((blvm_protocol::ValidationResult::Invalid(reason), _)) => { + Ok(( + blvm_protocol::ValidationResult::Invalid(reason), + _, + )) => { errors.push(format!( "Block at height {height} invalid: {reason}" )); diff --git a/tests/blockchain_rpc_tests.rs b/tests/blockchain_rpc_tests.rs index 668a221c..ba48a7c8 100644 --- a/tests/blockchain_rpc_tests.rs +++ b/tests/blockchain_rpc_tests.rs @@ -53,7 +53,7 @@ async fn test_get_chain_name_without_protocol() { /// 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. @@ -307,7 +307,10 @@ async fn test_verify_chain_level4_with_undo_logs() { storage.chain().initialize(&genesis_header).unwrap(); // Store genesis header for MTP - storage.blocks().store_recent_header(0, &genesis_header).unwrap(); + 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 @@ -315,7 +318,10 @@ async fn test_verify_chain_level4_with_undo_logs() { .iter() .map(|tx| tx.inputs.iter().map(|_| Vec::new()).collect()) .collect(); - storage.blocks().store_witness(&genesis_hash, &genesis_witnesses).unwrap(); + 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); @@ -342,7 +348,10 @@ async fn test_verify_chain_level4_with_undo_logs() { new_utxo: Some(StdArc::new(genesis_coinbase_utxo.clone())), }], }; - storage.blocks().store_undo_log(&genesis_hash, &genesis_undo).unwrap(); + 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 { @@ -382,7 +391,10 @@ async fn test_verify_chain_level4_with_undo_logs() { 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 + .blocks() + .store_recent_header(1, &block1_header) + .unwrap(); storage .chain() .update_tip(&block1_hash, &block1_header, 1) @@ -394,7 +406,10 @@ async fn test_verify_chain_level4_with_undo_logs() { .iter() .map(|tx| tx.inputs.iter().map(|_| Vec::new()).collect()) .collect(); - storage.blocks().store_witness(&block1_hash, &block1_witnesses).unwrap(); + 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); @@ -415,15 +430,16 @@ async fn test_verify_chain_level4_with_undo_logs() { // 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)), - }, - ], + 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(); + storage + .blocks() + .store_undo_log(&block1_hash, &block1_undo) + .unwrap(); // Test verifychain at level 4 let protocol = Arc::new(BitcoinProtocolEngine::new(ProtocolVersion::Regtest).unwrap()); From 86f8ce02809e46112f0c132a6b66df2ed1ec128f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 6 Oct 2026 22:20:35 +0000 Subject: [PATCH 4/5] Fix level 4 rewind: one-pass-per-entry disconnect semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change from two-pass (all removes then all restores) to one-pass-per-entry matching blvm_consensus::reorganization::disconnect_block exactly: for entry in undo_log.entries.iter() { if entry.new_utxo.is_some() { remove } if entry.previous_utxo.is_some() { restore } } This correctly handles same-block create→spend: restore wins over remove when operating on the same outpoint. Co-authored-by: Secure Sovereign --- src/rpc/blockchain.rs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/rpc/blockchain.rs b/src/rpc/blockchain.rs index f5480c90..859b8224 100644 --- a/src/rpc/blockchain.rs +++ b/src/rpc/blockchain.rs @@ -837,22 +837,20 @@ impl BlockchainRpc { .map_err(|e| anyhow::anyhow!("Failed to get UTXO set: {}", e))?; // Rewind UTXO set from tip down to start_height using undo logs. - // Must apply disconnect semantics matching blvm_consensus::reorganization: - // 1. First remove created outputs (new_utxo) - // 2. Then restore spent inputs (previous_utxo) + // 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)) => { - // Phase 1: Remove created outputs (reverse the creation) 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); } - } - // Phase 2: Restore spent inputs (reverse the spend) - for entry in undo_log.entries.iter() { + // 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()); } From 10e608e30c97f172c1d08a06109af1fd21830177 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 6 Oct 2026 22:24:07 +0000 Subject: [PATCH 5/5] Fix: revert Cargo.toml to main, remove trailing space in docstring Co-authored-by: Secure Sovereign --- Cargo.toml | 14 +++++++------- src/rpc/blockchain.rs | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index dc6f83c3..e2c414c8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -309,13 +309,13 @@ compression = ["block-compression", "utxo-compression", "witness-compression"] # After bump: run plain `cargo audit`, `cargo tree -i quinn-proto`, `cargo tree -i time`, then remove obsolete ignores. [patch.crates-io] quinn-proto = { git = "https://github.com/quinn-rs/quinn", tag = "quinn-proto-0.11.15" } -# Local monorepo paths commented out for CI builds (strip-patch-crates-io action handles this). -# blvm-protocol = { path = "../blvm-protocol" } -# blvm-consensus = { path = "../blvm-consensus" } -# blvm-primitives = { path = "../blvm-primitives" } -# blvm-secp256k1 = { path = "../blvm-secp256k1" } -# blvm-spec-lock = { path = "../blvm-spec-lock" } -# blvm-muhash = { path = "../blvm-muhash" } +# Local monorepo: sibling paths override the blvm-* version pins above. +blvm-protocol = { path = "../blvm-protocol" } +blvm-consensus = { path = "../blvm-consensus" } +blvm-primitives = { path = "../blvm-primitives" } +blvm-secp256k1 = { path = "../blvm-secp256k1" } +blvm-spec-lock = { path = "../blvm-spec-lock" } +blvm-muhash = { path = "../blvm-muhash" } [dev-dependencies] blvm-protocol = { version = ">=0.1, <1", features = ["test-utils"] } diff --git a/src/rpc/blockchain.rs b/src/rpc/blockchain.rs index 859b8224..74b68892 100644 --- a/src/rpc/blockchain.rs +++ b/src/rpc/blockchain.rs @@ -787,7 +787,7 @@ impl BlockchainRpc { /// /// Bitcoin Core checklevel semantics: /// - Level 0: Check block exists - /// - Level 1: Check block can be deserialized + /// - 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)