diff --git a/bin/floresta-cli/src/main.rs b/bin/floresta-cli/src/main.rs index 2a77e6bdd..fb0f5c155 100644 --- a/bin/floresta-cli/src/main.rs +++ b/bin/floresta-cli/src/main.rs @@ -128,6 +128,12 @@ fn do_request(cmd: &Cli, client: Client) -> anyhow::Result { Methods::Uptime => serde_json::to_string_pretty(&client.uptime()?)?, Methods::ListDescriptors => serde_json::to_string_pretty(&client.list_descriptors()?)?, Methods::Ping => serde_json::to_string_pretty(&client.ping()?)?, + Methods::InvalidateBlock { blockhash } => { + serde_json::to_string_pretty(&client.invalidate_block(blockhash)?)? + } + Methods::SubmitHeader { hexdata } => { + serde_json::to_string_pretty(&client.submit_header(hexdata)?)? + } }) } @@ -373,4 +379,22 @@ pub enum Methods { /// Result: json null #[command(name = "ping")] Ping, + + #[doc = include_str!("../../../doc/rpc/invalidateblock.md")] + #[command( + name = "invalidateblock", + about = "Permanently marks a block as invalid, as if it violated a consensus rule.", + long_about = Some(include_str!("../../../doc/rpc/invalidateblock.md")), + disable_help_subcommand = true + )] + InvalidateBlock { blockhash: BlockHash }, + + #[doc = include_str!("../../../doc/rpc/submitheader.md")] + #[command( + name = "submitheader", + about = "Decodes the given hex data as a block header and submits it as a candidate chain tip if valid.", + long_about = Some(include_str!("../../../doc/rpc/submitheader.md")), + disable_help_subcommand = true + )] + SubmitHeader { hexdata: String }, } diff --git a/crates/floresta-chain/src/pruned_utreexo/chain_state.rs b/crates/floresta-chain/src/pruned_utreexo/chain_state.rs index 637258755..c3216c488 100644 --- a/crates/floresta-chain/src/pruned_utreexo/chain_state.rs +++ b/crates/floresta-chain/src/pruned_utreexo/chain_state.rs @@ -848,10 +848,20 @@ impl ChainState { Ok(()) } - fn update_tip(&self, best_block: BlockHash, height: u32) { + /// Updates the chain tip state to point at `best_block` at the given `height`. + /// + /// This sets both `best_block` and `validation_index` to the same hash, + /// so it should only be used when the caller knows the target block is + /// fully validated. If `acc` is provided, the in-memory accumulator is + /// also replaced. + fn update_tip(&self, best_block: BlockHash, height: u32, acc: Option) { let mut inner = write_lock!(self); + inner.best_block.validation_index = best_block; inner.best_block.best_block = best_block; inner.best_block.depth = height; + if let Some(unwrapped_acc) = acc { + inner.acc = unwrapped_acc + } } fn verify_script(&self, height: u32) -> Result { @@ -1201,23 +1211,25 @@ impl UpdatableChainstate for ChainState Result<(), BlockchainError> { - let height = self.get_disk_block_header(&block)?.try_height()?; + let height_to_invalidate = self.get_disk_block_header(&block)?.try_height()?; let current_height = self.get_height()?; // Mark all blocks after this one as invalid - for h in height..=current_height { + for h in height_to_invalidate..=current_height { let hash = self.get_block_hash(h)?; let header = self.get_block_header(&hash)?; let new_header = DiskBlockHeader::InvalidChain(header); self.update_header(&new_header)?; } - // Row back to our previous state. Note that acc doesn't actually change in this case - // only the currently best known block. - self.update_tip( - self.get_ancestor(&self.get_block_header(&block)?)? - .block_hash(), - height - 1, - ); + + let new_tip = self + .get_ancestor(&self.get_block_header(&block)?)? + .block_hash(); + + // Get the acc for the new tip, if theres any. + let new_acc = self.get_roots_for_block(height_to_invalidate - 1)?; + + self.update_tip(new_tip, height_to_invalidate - 1, new_acc); Ok(()) } @@ -1472,6 +1484,7 @@ mod test { use crate::pruned_utreexo::utxo_data::UtxoData; use crate::AssumeValidArg; use crate::BlockchainError; + use crate::ChainStore; use crate::FlatChainStore; fn setup_test_chain( @@ -1777,10 +1790,97 @@ mod test { assert_eq!(chain.get_height().unwrap() as usize, random_height - 1); // update_tip - chain.update_tip(headers[1].prev_blockhash, 1); + chain.update_tip(headers[1].prev_blockhash, 1, None); assert_eq!( read_lock!(chain).best_block.best_block, - headers[1].prev_blockhash + headers[1].prev_blockhash, + ); + } + + /// After connecting FullyValid blocks, invalidating one must roll back the + /// validation_index so that `get_validation_index()` still returns a valid + /// height. Before the fix, `invalidate_block` left `validation_index` + /// pointing at a now-InvalidChain block, causing `get_validation_index()` + /// to fail with `BadValidationIndex`. + #[test] + fn test_invalidateblock_updatesvalidationindex() { + let chain = setup_test_chain(Network::Regtest, AssumeValidArg::Hardcoded); + let json_blocks = include_str!("../../testdata/test_reorg.json"); + let blocks: Vec> = serde_json::from_str(json_blocks).unwrap(); + + // Connect the first 10 blocks (they become FullyValid) + let short_chain: Vec = blocks[0] + .iter() + .map(|s| deserialize_hex(s).unwrap()) + .collect(); + + for block in &short_chain { + chain.accept_header(block.header).unwrap(); + chain + .connect_block(block, Proof::default(), HashMap::new(), Vec::new()) + .unwrap(); + } + + let expected_height = 10; + + assert_eq!(chain.get_height().unwrap(), expected_height); + + // validation_index should be the same as height. + let val_before = chain.get_validation_index().unwrap(); + assert!(val_before == expected_height); + + // Invalidate block at height 5 + let hash_at_5 = chain.get_block_hash(5).unwrap(); + + chain.invalidate_block(hash_at_5).unwrap(); + + assert_eq!(chain.get_height().unwrap(), 4); + + // This is the critical check. Before the commit fixing + // the update of validation_index, this will break. Otherwise will pass fine. + let validation_index_after = chain.get_validation_index().unwrap(); + assert!(validation_index_after == 4); + + // The blocks before `hash_at_5`, 4..0 should be FullyValid. + + // check the tip: + let val_hash = read_lock!(chain).best_block.validation_index; + let val_header = chain.get_disk_block_header(&val_hash).unwrap(); + assert!( + matches!(val_header, DiskBlockHeader::FullyValid(..)), + "expected FullyValid, got: {val_header:?}" ); + + // check the rest of the blocks. + for i in 0..=4 { + let val_hash = read_lock!(chain) + .chainstore + .get_block_hash(i) + .unwrap() + .unwrap(); + + let val_header = chain.get_disk_block_header(&val_hash).unwrap(); + + assert!( + matches!(val_header, DiskBlockHeader::FullyValid(..)), + "expected FullyValid, got: {val_header:?}" + ); + } + + // The blocks after `hash_at_5`, including `hash_at_5`, 5..10 should be InvalidChain. + for i in 5..10 { + let invalid_hash = read_lock!(chain) + .chainstore + .get_block_hash(i) + .unwrap() + .unwrap(); + + let invalid_header = chain.get_disk_block_header(&invalid_hash).unwrap(); + + assert!( + matches!(invalid_header, DiskBlockHeader::InvalidChain(..)), + "expected InvalidChain, got: {invalid_header:?}" + ); + } } } diff --git a/crates/floresta-node/src/json_rpc/blockchain.rs b/crates/floresta-node/src/json_rpc/blockchain.rs index a33e75484..f358912bf 100644 --- a/crates/floresta-node/src/json_rpc/blockchain.rs +++ b/crates/floresta-node/src/json_rpc/blockchain.rs @@ -4,6 +4,7 @@ use bitcoin::block::Header; use bitcoin::consensus::encode::serialize_hex; use bitcoin::consensus::Encodable; use bitcoin::constants::genesis_block; +use bitcoin::hashes::hex::FromHex; use bitcoin::hashes::Hash; use bitcoin::Address; use bitcoin::Block; @@ -312,6 +313,26 @@ impl RpcImpl { // getchainstates // getchaintips // getchaintxstats + + // invalidateblock + pub(super) fn invalidate_block(&self, hash: BlockHash) -> Result<(), JsonRpcError> { + self.chain + .invalidate_block(hash) + .map_err(|_| JsonRpcError::BlockNotFound) + } + + // submitheader + pub(super) fn submit_header(&self, hex: String) -> Result<(), JsonRpcError> { + let bytes = Vec::from_hex(&hex).map_err(|_| JsonRpcError::InvalidHex)?; + + let header: Header = bitcoin::consensus::deserialize(&bytes) + .map_err(|e| JsonRpcError::Decode(e.to_string()))?; + + self.chain + .accept_header(header) + .map_err(|_| JsonRpcError::Chain) + } + // getdeploymentinfo // getdifficulty // getmempoolancestors diff --git a/crates/floresta-node/src/json_rpc/server.rs b/crates/floresta-node/src/json_rpc/server.rs index 925a5af98..68d5bad81 100644 --- a/crates/floresta-node/src/json_rpc/server.rs +++ b/crates/floresta-node/src/json_rpc/server.rs @@ -315,6 +315,18 @@ async fn handle_json_rpc_request( "getroots" => state.get_roots().map(|v| serde_json::to_value(v).unwrap()), + "invalidateblock" => { + let hash = get_hash(¶ms, 0, "blockhash")?; + state.invalidate_block(hash)?; + Ok(Value::Null) + } + + "submitheader" => { + let hex = get_string(¶ms, 0, "hexdata")?; + state.submit_header(hex)?; + Ok(Value::Null) + } + "findtxout" => { let txid = get_hash(¶ms, 0, "txid")?; let vout = get_numeric(¶ms, 1, "vout")?; diff --git a/crates/floresta-rpc/src/rpc.rs b/crates/floresta-rpc/src/rpc.rs index 66df3d723..d03260373 100644 --- a/crates/floresta-rpc/src/rpc.rs +++ b/crates/floresta-rpc/src/rpc.rs @@ -144,6 +144,12 @@ pub trait FlorestaRPC { fn list_descriptors(&self) -> Result>; /// Sends a ping to all peers, checking if they are still alive fn ping(&self) -> Result<()>; + + #[doc = include_str!("../../../doc/rpc/invalidateblock.md")] + fn invalidate_block(&self, blockhash: BlockHash) -> Result<()>; + + #[doc = include_str!("../../../doc/rpc/submitheader.md")] + fn submit_header(&self, hexdata: String) -> Result<()>; } /// Since the workflow for jsonrpc is the same for all methods, we can implement a trait @@ -336,4 +342,12 @@ impl FlorestaRPC for T { fn ping(&self) -> Result<()> { self.call("ping", &[]) } + + fn invalidate_block(&self, blockhash: BlockHash) -> Result<()> { + self.call("invalidateblock", &[Value::String(blockhash.to_string())]) + } + + fn submit_header(&self, hexdata: String) -> Result<()> { + self.call("submitheader", &[Value::String(hexdata)]) + } } diff --git a/doc/rpc/invalidateblock.md b/doc/rpc/invalidateblock.md new file mode 100644 index 000000000..47768801e --- /dev/null +++ b/doc/rpc/invalidateblock.md @@ -0,0 +1,39 @@ +# `invalidateblock` + +Permanently marks a block as invalid, as if it violated a consensus rule. All descendants of the block are also marked invalid and the tip rolls back to the parent of the invalidated block. + +## Usage + +### Synopsis + +```bash +floresta-cli invalidateblock +``` + +### Examples + +```bash +# Invalidate a specific block +floresta-cli invalidateblock "00000000000000000002a7c4c1e48d76c5a37902165a270156b7a8d72f9a68c5" +``` + +## Arguments + +`blockhash` - (string, required) The hash of the block to mark as invalid, in hexadecimal format (64 characters). + +## Returns + +### Ok Response + +Returns `null` on success. + +### Error Enum `CommandError` + +* `JsonRpcError::BlockNotFound` - If the specified block hash is not found in the blockchain or the invalidation fails. + +## Notes + +- This is a hidden RPC in Bitcoin Core, intended for testing and debugging. +- The accumulator state is not modified; only the best known block and validation index are rolled back. +- All blocks from the invalidated height up to the current tip are marked as `InvalidChain`. +- There is currently no `reconsiderblock` equivalent to undo this operation. diff --git a/doc/rpc/submitheader.md b/doc/rpc/submitheader.md new file mode 100644 index 000000000..ae841deed --- /dev/null +++ b/doc/rpc/submitheader.md @@ -0,0 +1,40 @@ +# `submitheader` + +Decodes the given hex data as a block header and submits it as a candidate chain tip if valid. The header is accepted but the full block data is not required. + +## Usage + +### Synopsis + +```bash +floresta-cli submitheader +``` + +### Examples + +```bash +# Submit a raw 80-byte block header in hex (160 hex characters) +floresta-cli submitheader "0000002006226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f..." +``` + +## Arguments + +`hexdata` - (string, required) The hex-encoded block header data (160 hex characters representing 80 bytes). + +## Returns + +### Ok Response + +Returns `null` on success. + +### Error Enum `CommandError` + +- `JsonRpcError::InvalidHex` - If the provided hex string is not valid hexadecimal. +- `JsonRpcError::Decode` - If the hex data cannot be deserialized as a block header. +- `JsonRpcError::Chain` - If the header is invalid (e.g., unknown previous block, invalid proof of work). + +## Notes + +- The header's `prev_blockhash` must reference a block already known to the node. +- On success the node's tip advances but the block is stored as `HeadersOnly`, itll become `FullyValid` after validating its transactions. +- This is useful for testing header-first sync scenarios without providing full block data. diff --git a/tests/floresta-cli/invalidateblock.py b/tests/floresta-cli/invalidateblock.py new file mode 100644 index 000000000..9929b9054 --- /dev/null +++ b/tests/floresta-cli/invalidateblock.py @@ -0,0 +1,97 @@ +""" +invalidateblock.py + +Test the `invalidateblock` RPC on florestad. + +We mine blocks via utreexod, sync them to florestad, then call `invalidateblock` +on florestad to mark a block as invalid. We verify that florestad's tip rolls back +to the parent of the invalidated block. + +After invalidation, we extend the tip on utreexod and verify that florestad can +still sync new blocks — this catches accumulator bugs where invalidate_block +leaves the in-memory acc stale, causing subsequent block validation to fail. +""" + +import time + +from test_framework import FlorestaTestFramework +from test_framework.node import NodeType + + +class InvalidateBlockTest(FlorestaTestFramework): + """Test that invalidateblock marks a block invalid and rolls back the tip.""" + + def set_test_params(self): + self.florestad = self.add_node_default_args(variant=NodeType.FLORESTAD) + self.utreexod = self.add_node_extra_args( + variant=NodeType.UTREEXOD, + extra_args=[ + "--miningaddr=bcrt1q4gfcga7jfjmm02zpvrh4ttc5k7lmnq2re52z2y", + "--utreexoproofindex", + "--prune=0", + ], + ) + + def run_test(self): + self.run_node(self.florestad) + self.run_node(self.utreexod) + + # Mine 10 blocks on utreexod and sync to florestad + self.log("=== Mining 10 blocks with utreexod") + self.utreexod.rpc.generate(10) + + self.log("=== Connecting florestad to utreexod") + self.connect_nodes(self.florestad, self.utreexod) + + self.log("=== Waiting for sync") + time.sleep(20) + + # Verify both nodes are synced + floresta_info = self.florestad.rpc.get_blockchain_info() + utreexo_info = self.utreexod.rpc.get_blockchain_info() + self.assertEqual(floresta_info["height"], utreexo_info["blocks"]) + self.assertEqual(floresta_info["best_block"], utreexo_info["bestblockhash"]) + + # Get the hash of block 5 and its parent (block 4) + hash_at_5 = self.florestad.rpc.get_blockhash(5) + hash_at_4 = self.florestad.rpc.get_blockhash(4) + + # Invalidate block 5 on florestad + self.log(f"=== Invalidating block at height 5: {hash_at_5}") + self.florestad.rpc.invalidate_block(hash_at_5) + + # Verify florestad's tip rolled back to block 4 + new_info = self.florestad.rpc.get_blockchain_info() + self.assertEqual(new_info["height"], 4) + self.assertEqual(new_info["best_block"], hash_at_4) + + # Now extend the alternative tip to assert floresta can correctly sync with the new chain. + # + # This asserts that invalidate_block doesnt let florestad on a broken state. + self.log("=== Mining 5 more blocks on utreexod to extend the tip") + # Sanity check, if this matches we can invalidate that same hash so utreexod is on the same state as floresta is. + self.assertEqual(self.utreexod.rpc.get_blockhash(5), hash_at_5) + self.utreexod.rpc.invalidate_block(hash_at_5) + + # Mine 2 new blocks, extending from hash_at_4. + self.utreexod.rpc.generate(2) + + self.log("=== Waiting for florestad to sync new blocks") + time.sleep(120) + + # Verify florestad picked up the new blocks + floresta_info = self.florestad.rpc.get_blockchain_info() + utreexo_info = self.utreexod.rpc.get_blockchain_info() + self.assertEqual(floresta_info["height"], utreexo_info["blocks"]) + self.assertEqual(floresta_info["best_block"], utreexo_info["bestblockhash"]) + + # Verify the accumulators match + floresta_roots = self.florestad.rpc.get_roots() + utreexo_roots = self.utreexod.rpc.get_utreexo_roots( + utreexo_info["bestblockhash"] + ) + self.assertEqual(floresta_roots, utreexo_roots["roots"]) + + +if __name__ == "__main__": + InvalidateBlockTest().main() diff --git a/tests/floresta-cli/submitheader.py b/tests/floresta-cli/submitheader.py new file mode 100644 index 000000000..19701857c --- /dev/null +++ b/tests/floresta-cli/submitheader.py @@ -0,0 +1,88 @@ +""" +submitheader.py + +Test the `submitheader` RPC on florestad. + +We mine a block on bitcoind to obtain a valid header, then shut it down. +We start florestad in isolation and exercise submitheader: + - reject invalid hex + - reject truncated (non-80-byte) data + - reject a header whose prev_blockhash is unknown + - accept a valid header and advance the tip to height 1 + - accept a duplicate submission idempotently +""" + +from requests import HTTPError +from test_framework import FlorestaTestFramework +from test_framework.bitcoin import BlockHeader +from test_framework.node import NodeType + +REGTEST_GENESIS = "0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206" + + +class SubmitHeaderTest(FlorestaTestFramework): + """Test that submitheader accepts valid headers and rejects invalid ones.""" + + def set_test_params(self): + self.florestad = self.add_node_default_args(variant=NodeType.FLORESTAD) + self.bitcoind = self.add_node_default_args(variant=NodeType.BITCOIND) + + def run_test(self): + # Start bitcoind, mine 1 block, grab its header + self.run_node(self.bitcoind) + + self.log("=== Mining 1 block with bitcoind") + self.bitcoind.rpc.generate_block(1) + + block_hash = self.bitcoind.rpc.get_blockhash(1) + raw_block_hex = self.bitcoind.rpc.get_block(block_hash, 0) + + # First 160 hex chars = 80-byte block header + header_hex = raw_block_hex[:160] + + self.log("=== Stopping bitcoind") + self.bitcoind.stop() + + # Start florestad in isolation + self.run_node(self.florestad) + + info_before = self.florestad.rpc.get_blockchain_info() + self.assertEqual(info_before["height"], 0) + + # --- Error case 1: invalid hex --- + self.log("=== Submitting invalid hex string") + with self.assertRaises(HTTPError): + self.florestad.rpc.submit_header("zzzz_not_hex") + + # --- Error case 2: valid hex but wrong length (not 80 bytes) --- + self.log("=== Submitting truncated header (too short)") + with self.assertRaises(HTTPError): + self.florestad.rpc.submit_header(header_hex[:80]) + + # --- Error case 3: 80-byte header with unknown prev_blockhash --- + self.log("=== Submitting header with unknown prev_blockhash") + header = BlockHeader.deserialize(bytes.fromhex(header_hex)) + header.prev_blockhash = "aa" * 32 # unknown parent + bad_parent_hex = header.serialize().hex() + with self.assertRaises(HTTPError): + self.florestad.rpc.submit_header(bad_parent_hex) + + # --- Happy path: valid header --- + self.log(f"=== Submitting valid header for block {block_hash}") + self.florestad.rpc.submit_header(header_hex) + + info_after = self.florestad.rpc.get_blockchain_info() + self.assertEqual(info_after["height"], 1) + self.assertEqual(info_after["best_block"], block_hash) + + # --- Submitting the same header again, should succeed. --- + self.log("=== Submitting the same header again (duplicate)") + self.florestad.rpc.submit_header(header_hex) + + info_dup = self.florestad.rpc.get_blockchain_info() + self.assertEqual(info_dup["height"], 1) + self.assertEqual(info_dup["best_block"], block_hash) + + +if __name__ == "__main__": + SubmitHeaderTest().main() diff --git a/tests/test_framework/rpc/floresta.py b/tests/test_framework/rpc/floresta.py index 5664ea91d..f64ebfe7f 100644 --- a/tests/test_framework/rpc/floresta.py +++ b/tests/test_framework/rpc/floresta.py @@ -26,6 +26,14 @@ def get_roots(self): """ return self.perform_request("getroots") + def invalidate_block(self, blockhash: str): + """Marks a block as invalid""" + return self.perform_request("invalidateblock", params=[blockhash]) + + def submit_header(self, hexdata: str): + """Submits a raw block header as a candidate chain tip""" + return self.perform_request("submitheader", params=[hexdata]) + def get_memoryinfo(self, mode: str): """ Returns stats about our memory usage performing diff --git a/tests/test_runner.py b/tests/test_runner.py index 6050e5445..14cb714bd 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -24,8 +24,8 @@ import os import subprocess from collections import defaultdict -from threading import Thread from queue import Queue +from threading import Thread from time import time from test_framework.util import Utility @@ -73,6 +73,8 @@ ("example", "bitcoin"), ("example", "utreexod"), ("florestad", "node-info"), + ("floresta-cli", "invalidateblock"), + ("floresta-cli", "submitheader"), ] # Before running the tests, we check if the number of tests @@ -208,7 +210,6 @@ def run_test_worker(task_queue: Queue, results_queue: Queue, args: argparse.Name with open( test_log_name, "wt", encoding="utf-8", buffering=args.log_buffer ) as log_file: - # Avoid using 'with' for `subprocess.Popen` here, as we need the # process to start and stream output immediately for port detection # to work correctly. Using 'with' might delay output flushing,