From e6565ca481c47a7e237072c384d84b7991843caf Mon Sep 17 00:00:00 2001 From: jaoleal Date: Wed, 25 Feb 2026 10:24:23 -0300 Subject: [PATCH 1/3] feat(rpc): implement getchaintips RPC method Implement the getchaintips RPC method that returns information about all known tips in the block tree, including the main chain and orphaned branches. - Add ChainTipStatus enum and ChainTip struct to floresta-rpc types - Add get_chain_tips() to FlorestaRPC trait - Add server-side handler in floresta-node json_rpc - Wire dispatch for "getchaintips" in the JSON-RPC server - Add doc/rpc/getchaintips.md documentation --- bin/floresta-cli/src/main.rs | 10 ++++ .../floresta-node/src/json_rpc/blockchain.rs | 56 +++++++++++++++++++ crates/floresta-node/src/json_rpc/res.rs | 33 +++++++++++ crates/floresta-node/src/json_rpc/server.rs | 4 ++ crates/floresta-rpc/src/rpc.rs | 10 ++++ crates/floresta-rpc/src/rpc_types.rs | 33 +++++++++++ doc/rpc/getchaintips.md | 43 ++++++++++++++ 7 files changed, 189 insertions(+) create mode 100644 doc/rpc/getchaintips.md diff --git a/bin/floresta-cli/src/main.rs b/bin/floresta-cli/src/main.rs index 2a77e6bdd..a748b326e 100644 --- a/bin/floresta-cli/src/main.rs +++ b/bin/floresta-cli/src/main.rs @@ -57,6 +57,7 @@ fn do_request(cmd: &Cli, client: Client) -> anyhow::Result { Ok(match cmd.methods.clone() { // Handle each possible RPC method and serialize the result to a pretty JSON string Methods::GetBlockchainInfo => serde_json::to_string_pretty(&client.get_blockchain_info()?)?, + Methods::GetChainTips => serde_json::to_string_pretty(&client.get_chain_tips()?)?, Methods::GetBlockHash { height } => { serde_json::to_string_pretty(&client.get_block_hash(height)?)? } @@ -289,6 +290,15 @@ pub enum Methods { verbosity: Option, }, + #[doc = include_str!("../../../doc/rpc/getchaintips.md")] + #[command( + name = "getchaintips", + about = "Return information about all known tips in the block tree.", + long_about = Some(include_str!("../../../doc/rpc/getchaintips.md")), + disable_help_subcommand = true + )] + GetChainTips, + /// Returns information about the peers we are connected to #[command(name = "getpeerinfo")] GetPeerInfo, diff --git a/crates/floresta-node/src/json_rpc/blockchain.rs b/crates/floresta-node/src/json_rpc/blockchain.rs index a33e75484..19a9fff2c 100644 --- a/crates/floresta-node/src/json_rpc/blockchain.rs +++ b/crates/floresta-node/src/json_rpc/blockchain.rs @@ -29,6 +29,8 @@ use super::res::GetTxOutProof; use super::res::JsonRpcError; use super::server::RpcChain; use super::server::RpcImpl; +use crate::json_rpc::res::ChainTip; +use crate::json_rpc::res::ChainTipStatus; use crate::json_rpc::res::GetBlockRes; use crate::json_rpc::res::RescanConfidence; @@ -310,7 +312,61 @@ impl RpcImpl { // getblockstats // getchainstates + // getchaintips + pub(super) fn get_chain_tips(&self) -> Result, JsonRpcError> { + let tips = self + .chain + .get_chain_tips() + .map_err(|_| JsonRpcError::Chain)?; + + let (best_height, best_hash) = self + .chain + .get_best_block() + .map_err(|_| JsonRpcError::Chain)?; + + let mut result = Vec::with_capacity(tips.len()); + + for tip in tips { + if tip == best_hash { + result.push(ChainTip { + height: best_height, + hash: tip.to_string(), + branchlen: 0, + status: ChainTipStatus::Active, + }); + continue; + } + let tip_height = self + .chain + .get_block_height(&tip) + .map_err(|_| JsonRpcError::Chain)? + .ok_or(JsonRpcError::Chain)?; + + let fork_point = self + .chain + .get_fork_point(tip) + .map_err(|_| JsonRpcError::Chain)?; + + let fork_height = self + .chain + .get_block_height(&fork_point) + .map_err(|_| JsonRpcError::Chain)? + .ok_or(JsonRpcError::Chain)?; + + let branchlen = tip_height.saturating_sub(fork_height); + + result.push(ChainTip { + height: tip_height, + hash: tip.to_string(), + branchlen, + status: ChainTipStatus::ValidFork, + }); + } + + Ok(result) + } + // getchaintxstats // getdeploymentinfo // getdifficulty diff --git a/crates/floresta-node/src/json_rpc/res.rs b/crates/floresta-node/src/json_rpc/res.rs index 98a68d88e..63e846211 100644 --- a/crates/floresta-node/src/json_rpc/res.rs +++ b/crates/floresta-node/src/json_rpc/res.rs @@ -143,6 +143,39 @@ pub struct RpcError { #[derive(Debug, Deserialize, Serialize)] pub struct GetTxOutProof(pub Vec); +/// The validation status of a chain tip +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum ChainTipStatus { + /// The current best chain tip + Active, + + /// A fully validated fork that is not the active chain + ValidFork, + + /// Headers received but blocks not yet fully validated + HeadersOnly, + + /// The chain contains at least one invalid block + Invalid, +} + +#[derive(Debug, Deserialize, Serialize)] +/// A chain tip returned by the `getchaintips` RPC +pub struct ChainTip { + /// The height of this chain tip + pub height: u32, + + /// The block hash of this chain tip + pub hash: String, + + /// The length of the branch that connects this tip to the main chain (0 for the active tip) + pub branchlen: u32, + + /// The validation status of this chain tip + pub status: ChainTipStatus, +} + #[derive(Debug)] pub enum JsonRpcError { /// There was a rescan request but we do not have any addresses in the watch-only wallet. diff --git a/crates/floresta-node/src/json_rpc/server.rs b/crates/floresta-node/src/json_rpc/server.rs index 925a5af98..a26737479 100644 --- a/crates/floresta-node/src/json_rpc/server.rs +++ b/crates/floresta-node/src/json_rpc/server.rs @@ -279,6 +279,10 @@ async fn handle_json_rpc_request( .map(|h| serde_json::to_value(h).unwrap()) } + "getchaintips" => state + .get_chain_tips() + .map(|v| serde_json::to_value(v).unwrap()), + "gettxout" => { 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..8e0c899e9 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<()>; + + /// Returns information about all known chain tips in the block tree + /// + /// This includes the main chain tip and any orphaned branches. Each tip includes + /// its height, hash, branch length (distance to the main chain), and validation status. + fn get_chain_tips(&self) -> Result>; } /// Since the workflow for jsonrpc is the same for all methods, we can implement a trait @@ -336,4 +342,8 @@ impl FlorestaRPC for T { fn ping(&self) -> Result<()> { self.call("ping", &[]) } + + fn get_chain_tips(&self) -> Result> { + self.call("getchaintips", &[]) + } } diff --git a/crates/floresta-rpc/src/rpc_types.rs b/crates/floresta-rpc/src/rpc_types.rs index 1ebce51d7..e0575339d 100644 --- a/crates/floresta-rpc/src/rpc_types.rs +++ b/crates/floresta-rpc/src/rpc_types.rs @@ -306,6 +306,39 @@ pub enum GetMemInfoRes { MallocInfo(String), } +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +/// The validation status of a chain tip +pub enum ChainTipStatus { + /// The current best chain tip + Active, + + /// A fully validated fork that is not the active chain + ValidFork, + + /// Headers received but blocks not yet fully validated + HeadersOnly, + + /// The chain contains at least one invalid block + Invalid, +} + +#[derive(Debug, Deserialize, Serialize)] +/// A chain tip returned by the `getchaintips` RPC +pub struct ChainTip { + /// The height of this chain tip + pub height: u32, + + /// The block hash of this chain tip + pub hash: String, + + /// The length of the branch that connects this tip to the main chain (0 for the active tip) + pub branchlen: u32, + + /// The validation status of this chain tip + pub status: ChainTipStatus, +} + #[derive(Debug, Serialize, Deserialize)] pub struct ActiveCommand { pub method: String, diff --git a/doc/rpc/getchaintips.md b/doc/rpc/getchaintips.md new file mode 100644 index 000000000..5214562e8 --- /dev/null +++ b/doc/rpc/getchaintips.md @@ -0,0 +1,43 @@ +# `getchaintips` + +Return information about all known tips in the block tree, including the +main chain as well as orphaned branches. + +## Usage + +### Synopsis + +``` +floresta-cli getchaintips +``` + +### Examples + +```bash +floresta-cli getchaintips +``` + +## Arguments + +This method takes no arguments. + +## Returns + +### Ok Response + +A JSON array of objects, one per chain tip: + +- `height` - (numeric) The height of the chain tip +- `hash` - (string) The block hash of the chain tip, hex-encoded +- `branchlen` - (numeric) Length of the branch connecting the tip to the main chain (0 for the active tip) +- `status` - (string) The validation status of the chain tip. One of: + * `active` - This is the current best chain tip + * `valid-fork` - This is a fully validated branch that is not part of the active chain + * `headers-only` - Headers have been received but blocks are not yet fully validated + * `invalid` - The branch contains at least one invalid block + +## Notes + +- There is always exactly one tip with status `active` +- The `active` tip always has `branchlen` equal to 0 +- For non-active tips, `branchlen` measures the distance from the fork point to the tip From 5ef68916b7be61c619b581f143e9ac2d59ae3a8e Mon Sep 17 00:00:00 2001 From: jaoleal Date: Wed, 25 Feb 2026 10:25:27 -0300 Subject: [PATCH 2/3] test(rpc): add getchaintips functional test Add integration test exercising `getchaintips` on both florestad and bitcoind, comparing their outputs across six scenarios. A) Genesis only B) Synced 10-block chain C) submit_header for an unknown block D) invalidateblock to produce an invalid tip E) Fork via invalidation at height 5 + mining 10 new blocks F) Second fork via invalidation at height 8 + mining 10 more Add get_chain_tips, invalidate_block and submit_header wrappers to the BitcoinRPC class so bitcoind can be exercised in the same way. --- .../floresta-node/src/json_rpc/blockchain.rs | 2 +- tests/floresta-cli/getchaintips.py | 215 ++++++++++++++++++ tests/test_framework/rpc/bitcoin.py | 12 + tests/test_framework/rpc/floresta.py | 4 + tests/test_runner.py | 1 + 5 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 tests/floresta-cli/getchaintips.py diff --git a/crates/floresta-node/src/json_rpc/blockchain.rs b/crates/floresta-node/src/json_rpc/blockchain.rs index 19a9fff2c..fcba5d965 100644 --- a/crates/floresta-node/src/json_rpc/blockchain.rs +++ b/crates/floresta-node/src/json_rpc/blockchain.rs @@ -313,7 +313,6 @@ impl RpcImpl { // getblockstats // getchainstates - // getchaintips pub(super) fn get_chain_tips(&self) -> Result, JsonRpcError> { let tips = self .chain @@ -337,6 +336,7 @@ impl RpcImpl { }); continue; } + let tip_height = self .chain .get_block_height(&tip) diff --git a/tests/floresta-cli/getchaintips.py b/tests/floresta-cli/getchaintips.py new file mode 100644 index 000000000..2ce808b31 --- /dev/null +++ b/tests/floresta-cli/getchaintips.py @@ -0,0 +1,215 @@ +""" +getchaintips.py + +Tests `getchaintips` RPC by running identical operations on both florestad +and bitcoind, comparing their outputs to ensure floresta matches Bitcoin +Core's behavior. + +Scenarios: + A) Only genesis block exists + B) Synced a 10-block chain (no forks) + C) Submit a header for a block the node doesn't have yet + D) Use invalidateblock to produce an "invalid" chain tip + E) Create one fork by invalidating block 5 and mining 10 new blocks + F) Create a second fork by invalidating block 8 and mining 10 more +""" + +import re +import time + +from test_framework import FlorestaTestFramework +from test_framework.node import NodeType + +REGTEST_GENESIS_HASH = ( + "0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206" +) + +VALID_STATUSES = {"active", "valid-fork", "headers-only", "invalid", "valid-headers"} + + +class GetChainTipsTest(FlorestaTestFramework): + """Test the getchaintips RPC by comparing florestad with bitcoind.""" + + 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", + ], + ) + self.bitcoind = self.add_node_default_args(variant=NodeType.BITCOIND) + + def tips_by_status(self, tips, status): + """Find all tips with the given status.""" + return [t for t in tips if t["status"] == status] + + def tip_by_hash(self, tips, block_hash): + """Find a tip by its block hash.""" + for t in tips: + if t["hash"] == block_hash: + return t + return None + + def log_tips(self, label, tips): + """Pretty-print chain tips.""" + self.log(f"{label}:") + for t in tips: + self.log( + f" height={t['height']} status={t['status']} " + f"branchlen={t['branchlen']} hash={t['hash'][:16]}..." + ) + + def run_test(self): + # Start all nodes + self.run_node(self.florestad) + self.run_node(self.utreexod) + self.run_node(self.bitcoind) + + # ── Scenario A: genesis only ───────────────────────────────── + self.log("=== Scenario A: genesis only") + + f_tips = self.florestad.rpc.get_chain_tips() + b_tips = self.bitcoind.rpc.get_chain_tips() + + self.log_tips("florestad", f_tips) + self.log_tips("bitcoind", b_tips) + + self.assertEqual(len(f_tips), 1) + self.assertEqual(len(b_tips), 1) + self.assertEqual(f_tips[0]["status"], b_tips[0]["status"]) + self.assertEqual(f_tips[0]["height"], b_tips[0]["height"]) + + # ── Scenario B: synced 10-block chain ──────────────────────── + self.log("=== Scenario B: synced chain, no forks") + + self.log("Mining 10 blocks on utreexod") + self.utreexod.rpc.generate(10) + + # Connect both florestad and bitcoind to utreexod + self.log("Connecting florestad to utreexod") + self.connect_nodes(self.florestad, self.utreexod) + + self.log("Connecting bitcoind to utreexod") + self.connect_nodes(self.bitcoind, self.utreexod) + + self.log("Waiting for sync...") + time.sleep(20) + + f_tips = self.florestad.rpc.get_chain_tips() + b_tips = self.bitcoind.rpc.get_chain_tips() + + self.log_tips("florestad", f_tips) + self.log_tips("bitcoind", b_tips) + + self.assertEqual(len(f_tips), 1) + self.assertEqual(len(b_tips), 1) + self.assertEqual(f_tips[0]["status"], b_tips[0]["status"]) + self.assertEqual(f_tips[0]["height"], b_tips[0]["height"]) + + utreexo_best = self.utreexod.rpc.get_bestblockhash() + + # ── Scenario C: submit_header ──────────────────────────────── + self.log("=== Scenario C: submit_header for unknown block") + + # Mine 1 more block on utreexod but DON'T let florestad/bitcoind + # sync it via P2P yet — we only submit the header. + new_hashes = self.utreexod.rpc.generate(1) + block_11_hash = new_hashes[0] + + # Get the raw header (first 160 hex chars of raw block) + raw_block = self.utreexod.rpc.get_block(block_11_hash, 0) + header_hex = raw_block[:160] + + # Submit header to both + self.florestad.rpc.submit_header(header_hex) + self.bitcoind.rpc.submit_header(header_hex) + + f_tips = self.florestad.rpc.get_chain_tips() + b_tips = self.bitcoind.rpc.get_chain_tips() + + self.log_tips("florestad", f_tips) + self.log_tips("bitcoind", b_tips) + + # Log the differences for analysis — don't assert equality yet, + # we want to SEE what each node reports. + self.log( + f"florestad tip count: {len(f_tips)}, bitcoind tip count: {len(b_tips)}" + ) + + # Now let both nodes sync the full block + self.log("Waiting for sync of block 11...") + time.sleep(20) + + f_tips = self.florestad.rpc.get_chain_tips() + b_tips = self.bitcoind.rpc.get_chain_tips() + + self.log_tips("florestad (after sync)", f_tips) + self.log_tips("bitcoind (after sync)", b_tips) + + # ── Scenario D: invalidateblock ────────────────────────────── + self.log("=== Scenario D: invalid status via invalidateblock") + + block_at_8 = self.florestad.rpc.get_blockhash(8) + self.florestad.rpc.invalidate_block(block_at_8) + self.bitcoind.rpc.invalidate_block(block_at_8) + + f_tips = self.florestad.rpc.get_chain_tips() + b_tips = self.bitcoind.rpc.get_chain_tips() + + self.log_tips("florestad", f_tips) + self.log_tips("bitcoind", b_tips) + + self.log( + f"florestad tip count: {len(f_tips)}, bitcoind tip count: {len(b_tips)}" + ) + + # ── Scenario E: fork via invalidation at height 5 ─────────── + self.log("=== Scenario E: single fork via invalidation at height 5") + + block_at_5 = self.utreexod.rpc.get_blockhash(5) + self.utreexod.rpc.invalidate_block(block_at_5) + + self.log("Mining 10 blocks on new chain") + self.utreexod.rpc.generate(10) + + self.log("Waiting for sync...") + time.sleep(20) + + f_tips = self.florestad.rpc.get_chain_tips() + b_tips = self.bitcoind.rpc.get_chain_tips() + + self.log_tips("florestad", f_tips) + self.log_tips("bitcoind", b_tips) + + self.log( + f"florestad tip count: {len(f_tips)}, bitcoind tip count: {len(b_tips)}" + ) + + # ── Scenario F: second fork via invalidation at height 8 ───── + self.log("=== Scenario F: second fork via invalidation at height 8") + + block_at_8 = self.utreexod.rpc.get_blockhash(8) + self.utreexod.rpc.invalidate_block(block_at_8) + + self.log("Mining 10 blocks on second alternative chain") + self.utreexod.rpc.generate(10) + + self.log("Waiting for sync...") + time.sleep(20) + + f_tips = self.florestad.rpc.get_chain_tips() + b_tips = self.bitcoind.rpc.get_chain_tips() + + self.log_tips("florestad", f_tips) + self.log_tips("bitcoind", b_tips) + + self.log( + f"florestad tip count: {len(f_tips)}, bitcoind tip count: {len(b_tips)}" + ) + + +if __name__ == "__main__": + GetChainTipsTest().main() diff --git a/tests/test_framework/rpc/bitcoin.py b/tests/test_framework/rpc/bitcoin.py index 085b3061c..ed7f8d4a5 100644 --- a/tests/test_framework/rpc/bitcoin.py +++ b/tests/test_framework/rpc/bitcoin.py @@ -46,3 +46,15 @@ def generate_block(self, nblocks: int) -> list: """ address = "bcrt1q3ml87jemlfvk7lq8gfs7pthvj5678ndnxnw9ch" return self.generate_block_to_address(nblocks, address) + + def get_chain_tips(self): + """Returns information about all known chain tips in the block tree""" + return self.perform_request("getchaintips") + + 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]) diff --git a/tests/test_framework/rpc/floresta.py b/tests/test_framework/rpc/floresta.py index 5664ea91d..6dcf6e645 100644 --- a/tests/test_framework/rpc/floresta.py +++ b/tests/test_framework/rpc/floresta.py @@ -34,3 +34,7 @@ def get_memoryinfo(self, mode: str): raise ValueError(f"Invalid getmemoryinfo mode: '{mode}'") return self.perform_request("getmemoryinfo", params=[mode]) + + def get_chain_tips(self): + """Returns information about all known chain tips in the block tree""" + return self.perform_request("getchaintips") diff --git a/tests/test_runner.py b/tests/test_runner.py index 6050e5445..50c897ea7 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -69,6 +69,7 @@ ("floresta-cli", "getmemoryinfo"), ("floresta-cli", "getpeerinfo"), ("floresta-cli", "getblockchaininfo"), + ("floresta-cli", "getchaintips"), ("floresta-cli", "getblockheader"), ("example", "bitcoin"), ("example", "utreexod"), From c5e73049923a961936588605f333c8ad090520e8 Mon Sep 17 00:00:00 2001 From: jaoleal Date: Mon, 9 Mar 2026 18:16:54 -0300 Subject: [PATCH 3/3] fix(getchaintips)!: new ChainTipStatus, switch chains whitout overriding block validation state, Add ChainTipInfo and ChainTipStatus types to floresta-chain so that get_chain_tips() returns validation state (Active, ValidFork, HeadersOnly, Invalid) alongside each tip hash. mark_chain_as_active: preserve FullyValid status for blocks that were already validated instead of overwriting as HeadersOnly. reorg: properly manage alternative_tips during chain reorganizations. Remove entries that become ancestors of the new active chain, and only add the old active tip when it is a true leaf (not an ancestor of any existing alternative tip). getchaintips: fix branchlen calculation by using a FindFork-style ancestor walk that checks heights are within the active chain, preventing stale block index entries from producing branchlen=0. --- crates/floresta-chain/src/extensions.rs | 3 +- .../src/pruned_utreexo/chain_state.rs | 91 +++++++++++- .../src/pruned_utreexo/chainstore.rs | 26 ++++ .../floresta-chain/src/pruned_utreexo/mod.rs | 13 +- .../src/pruned_utreexo/partial_chain.rs | 3 +- .../floresta-node/src/json_rpc/blockchain.rs | 133 ++++++++++++------ crates/floresta-node/src/json_rpc/res.rs | 7 +- .../src/p2p_wire/node/chain_selector_ctx.rs | 9 +- 8 files changed, 227 insertions(+), 58 deletions(-) diff --git a/crates/floresta-chain/src/extensions.rs b/crates/floresta-chain/src/extensions.rs index 29fb77b9b..8ffe7a6f3 100644 --- a/crates/floresta-chain/src/extensions.rs +++ b/crates/floresta-chain/src/extensions.rs @@ -292,6 +292,7 @@ mod tests { use super::*; use crate::BlockConsumer; use crate::BlockchainError; + use crate::ChainTipInfo; use crate::UtxoData; #[derive(Debug)] @@ -409,7 +410,7 @@ mod tests { unimplemented!() } - fn get_chain_tips(&self) -> Result, Self::Error> { + fn get_chain_tips(&self) -> Result, Self::Error> { unimplemented!() } diff --git a/crates/floresta-chain/src/pruned_utreexo/chain_state.rs b/crates/floresta-chain/src/pruned_utreexo/chain_state.rs index 637258755..d9cad59da 100644 --- a/crates/floresta-chain/src/pruned_utreexo/chain_state.rs +++ b/crates/floresta-chain/src/pruned_utreexo/chain_state.rs @@ -52,6 +52,8 @@ use tracing::warn; use super::chain_state_builder::BlockchainBuilderError; use super::chain_state_builder::ChainStateBuilder; use super::chainparams::ChainParams; +use super::chainstore::ChainTipInfo; +use super::chainstore::ChainTipStatus; use super::chainstore::DiskBlockHeader; use super::consensus::Consensus; use super::error::BlockValidationErrors; @@ -301,8 +303,13 @@ impl ChainState { let mut header = DiskBlockHeader::HeadersOnly(*new_tip, height); while !self.is_genesis(&header) && header.block_hash() != fork_point { - let disk_header = DiskBlockHeader::HeadersOnly(*header, height); - let hash = disk_header.block_hash(); + let hash = header.block_hash(); + + // Preserve FullyValid status for blocks we already validated. + let disk_header = match self.get_disk_block_header(&hash) { + Ok(DiskBlockHeader::FullyValid(h, _)) => DiskBlockHeader::FullyValid(h, height), + _ => DiskBlockHeader::HeadersOnly(*header, height), + }; self.update_header_and_index(&disk_header, hash, height)?; @@ -393,6 +400,7 @@ impl ChainState { // This method should only be called after we validate the new branch fn reorg(&self, new_tip: BlockHeader) -> Result<(), BlockchainError> { let current_best_block = self.get_block_header(&self.get_best_block()?.1)?; + let old_best_hash = self.get_best_block()?.1; let fork_point = self.find_fork_point(&new_tip)?; self.mark_chain_as_inactive(¤t_best_block, fork_point.block_hash())?; @@ -404,6 +412,60 @@ impl ChainState { self.change_active_chain(&new_tip, validation_index, depth); self.reorg_acc(&fork_point)?; + // Update alternative_tips: remove any tips that are now ancestors of + // the new active chain, and add the old active tip. + // + // Collect hashes on the new active chain (tip → fork point) before + // acquiring the write lock to avoid re-entrant lock issues. + let mut new_chain_hashes = alloc::collections::BTreeSet::new(); + { + let mut h = new_tip; + let fork_hash = fork_point.block_hash(); + while h.block_hash() != fork_hash && !self.is_genesis(&h) { + new_chain_hashes.insert(h.block_hash()); + h = *self.get_ancestor(&h)?; + } + } + // Check whether old_best_hash is already an ancestor of any + // remaining alternative tip. If so, it is not a true leaf tip and + // should not be added (mirrors Bitcoin Core which identifies tips as + // blocks with no children). + let old_best_is_ancestor = { + let inner = read_lock!(self); + inner.best_block.alternative_tips.iter().any(|&tip_hash| { + if new_chain_hashes.contains(&tip_hash) { + return false; // will be removed + } + // Walk ancestors of this alt tip looking for old_best_hash + let mut h = tip_hash; + loop { + if h == old_best_hash { + return true; + } + match inner.chainstore.get_header(&h) { + Ok(Some(dh)) => { + let prev = dh.prev_blockhash; + if prev == h { + return false; // genesis + } + h = prev; + } + _ => return false, + } + } + }) + }; + { + let mut inner = write_lock!(self); + inner + .best_block + .alternative_tips + .retain(|h| !new_chain_hashes.contains(h)); + if !old_best_is_ancestor { + inner.best_block.alternative_tips.push(old_best_hash); + } + } + Ok(()) } @@ -985,12 +1047,23 @@ impl BlockchainInterface for ChainState Result, Self::Error> { + fn get_chain_tips(&self) -> Result, Self::Error> { let inner = read_lock!(self); - let mut tips = Vec::new(); - - tips.push(inner.best_block.best_block); - tips.extend(inner.best_block.alternative_tips.iter()); + let best = inner.best_block.best_block; + + let mut tips = vec![ChainTipInfo { + hash: best, + status: ChainTipStatus::Active, + }]; + + for &hash in &inner.best_block.alternative_tips { + let status = match inner.chainstore.get_header(&hash)? { + Some(DiskBlockHeader::InvalidChain(_)) => ChainTipStatus::Invalid, + Some(DiskBlockHeader::HeadersOnly(_, _)) => ChainTipStatus::HeadersOnly, + _ => ChainTipStatus::ValidFork, + }; + tips.push(ChainTipInfo { hash, status }); + } Ok(tips) } @@ -1204,6 +1277,10 @@ impl UpdatableChainstate for ChainState, ) -> Result; - /// Returns all known chain tips, including the best one and forks - fn get_chain_tips(&self) -> Result, Self::Error>; + /// Returns all known chain tips, including the active tip and any forks. + /// + /// Each returned [ChainTipInfo] contains the tip's block hash and its + /// [ChainTipStatus], which reflects the validation state of that branch + /// (e.g. active, valid fork, headers-only, or invalid). + /// + /// The first element is always the active chain tip. + fn get_chain_tips(&self) -> Result, Self::Error>; /// Validates a block according to Bitcoin's rules, without modifying our chain fn validate_block( @@ -335,7 +342,7 @@ impl BlockchainInterface for Arc { T::update_acc(self, acc, block, height, proof, del_hashes) } - fn get_chain_tips(&self) -> Result, Self::Error> { + fn get_chain_tips(&self) -> Result, Self::Error> { T::get_chain_tips(self) } diff --git a/crates/floresta-chain/src/pruned_utreexo/partial_chain.rs b/crates/floresta-chain/src/pruned_utreexo/partial_chain.rs index 1fedd1e0a..db177672a 100644 --- a/crates/floresta-chain/src/pruned_utreexo/partial_chain.rs +++ b/crates/floresta-chain/src/pruned_utreexo/partial_chain.rs @@ -34,6 +34,7 @@ use rustreexo::stump::Stump; use tracing::info; use super::chainparams::ChainParams; +use super::chainstore::ChainTipInfo; use super::consensus::Consensus; use super::error::BlockValidationErrors; use super::error::BlockchainError; @@ -395,7 +396,7 @@ impl BlockchainInterface for PartialChainState { unimplemented!("PartialChainState::get_block_header") } - fn get_chain_tips(&self) -> Result, Self::Error> { + fn get_chain_tips(&self) -> Result, Self::Error> { unimplemented!("PartialChainState::get_chain_tips") } diff --git a/crates/floresta-node/src/json_rpc/blockchain.rs b/crates/floresta-node/src/json_rpc/blockchain.rs index fcba5d965..0eaad2850 100644 --- a/crates/floresta-node/src/json_rpc/blockchain.rs +++ b/crates/floresta-node/src/json_rpc/blockchain.rs @@ -19,6 +19,7 @@ use corepc_types::v30::GetBlockVerboseOne; use corepc_types::ScriptPubkey; use floresta_chain::extensions::HeaderExt; use floresta_chain::extensions::WorkExt; +use floresta_chain::ChainTipStatus as ChainLevelStatus; use miniscript::descriptor::checksum; use serde_json::json; use serde_json::Value; @@ -319,52 +320,104 @@ impl RpcImpl { .get_chain_tips() .map_err(|_| JsonRpcError::Chain)?; - let (best_height, best_hash) = self + let (best_height, _) = self .chain .get_best_block() .map_err(|_| JsonRpcError::Chain)?; - let mut result = Vec::with_capacity(tips.len()); - - for tip in tips { - if tip == best_hash { - result.push(ChainTip { - height: best_height, - hash: tip.to_string(), - branchlen: 0, - status: ChainTipStatus::Active, - }); - continue; - } + tips.into_iter() + .map(|tip| { + let status = match tip.status { + ChainLevelStatus::Active => ChainTipStatus::Active, + ChainLevelStatus::ValidFork => ChainTipStatus::ValidFork, + ChainLevelStatus::HeadersOnly => ChainTipStatus::HeadersOnly, + ChainLevelStatus::Invalid => ChainTipStatus::Invalid, + }; - let tip_height = self - .chain - .get_block_height(&tip) - .map_err(|_| JsonRpcError::Chain)? - .ok_or(JsonRpcError::Chain)?; - - let fork_point = self - .chain - .get_fork_point(tip) - .map_err(|_| JsonRpcError::Chain)?; - - let fork_height = self - .chain - .get_block_height(&fork_point) - .map_err(|_| JsonRpcError::Chain)? - .ok_or(JsonRpcError::Chain)?; - - let branchlen = tip_height.saturating_sub(fork_height); - - result.push(ChainTip { - height: tip_height, - hash: tip.to_string(), - branchlen, - status: ChainTipStatus::ValidFork, - }); - } + if matches!(tip.status, ChainLevelStatus::Active) { + return Ok(ChainTip { + height: best_height, + hash: tip.hash.to_string(), + branchlen: 0, + status, + }); + } + + // For tips with known height (ValidFork, HeadersOnly), we + // can look up height and fork point directly. For Invalid + // tips the DiskBlockHeader doesn't store the height, so we + // walk back through ancestors until we find one whose height + // is known and derive the tip height from the distance. + let tip_height = match self + .chain + .get_block_height(&tip.hash) + .map_err(|_| JsonRpcError::Chain)? + { + Some(h) => h, + None => { + // Walk ancestors to compute height + let mut hash = tip.hash; + let mut depth = 0u32; + loop { + let header = self + .chain + .get_block_header(&hash) + .map_err(|_| JsonRpcError::Chain)?; + let parent = header.prev_blockhash; + depth += 1; + if let Some(parent_h) = self + .chain + .get_block_height(&parent) + .map_err(|_| JsonRpcError::Chain)? + { + break parent_h + depth; + } + hash = parent; + } + } + }; - Ok(result) + // Find the fork point: the most recent common ancestor with + // the active chain. Walk back through ancestors and check + // whether each block is actually on the active chain by + // comparing get_block_hash(height) with the block's hash. + // This mirrors Bitcoin Core's FindFork behaviour. + let fork_height = { + let mut hash = tip.hash; + let mut h = tip_height; + loop { + // A block is on the active chain when its height is + // within the active chain and the hash at that height + // matches its own hash. + if h <= best_height { + if let Ok(active_hash) = self.chain.get_block_hash(h) { + if active_hash == hash { + break h; + } + } + } + if h == 0 { + break 0; + } + let header = match self.chain.get_block_header(&hash) { + Ok(hdr) => hdr, + Err(_) => break 0, + }; + hash = header.prev_blockhash; + h -= 1; + } + }; + + let branchlen = tip_height.saturating_sub(fork_height); + + Ok(ChainTip { + height: tip_height, + hash: tip.hash.to_string(), + branchlen, + status, + }) + }) + .collect() } // getchaintxstats diff --git a/crates/floresta-node/src/json_rpc/res.rs b/crates/floresta-node/src/json_rpc/res.rs index 63e846211..b10f76d4d 100644 --- a/crates/floresta-node/src/json_rpc/res.rs +++ b/crates/floresta-node/src/json_rpc/res.rs @@ -143,7 +143,12 @@ pub struct RpcError { #[derive(Debug, Deserialize, Serialize)] pub struct GetTxOutProof(pub Vec); -/// The validation status of a chain tip +/// The validation status of a chain tip, serialized with kebab-case for +/// the `getchaintips` RPC response. +/// +/// Maps from [ChainTipStatus] in the chain layer. +/// +/// [ChainTipStatus]: floresta_chain::ChainTipStatus #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] pub enum ChainTipStatus { diff --git a/crates/floresta-wire/src/p2p_wire/node/chain_selector_ctx.rs b/crates/floresta-wire/src/p2p_wire/node/chain_selector_ctx.rs index 0b19ef393..6cb3cfcc1 100644 --- a/crates/floresta-wire/src/p2p_wire/node/chain_selector_ctx.rs +++ b/crates/floresta-wire/src/p2p_wire/node/chain_selector_ctx.rs @@ -716,7 +716,7 @@ where let (height, _) = self.chain.get_best_block()?; let validation_index = self.chain.get_validation_index()?; if (validation_index + 100) < height { - let mut tips = self.chain.get_chain_tips()?; + let tips = self.chain.get_chain_tips()?; let (height, hash) = self.chain.get_best_block()?; let acc = self.find_accumulator_for_block(height, hash).await?; @@ -728,13 +728,12 @@ where ); self.context.state = ChainSelectorState::Done; - self.chain.mark_chain_as_assumed(acc, tips[0]).unwrap(); + self.chain.mark_chain_as_assumed(acc, tips[0].hash).unwrap(); self.chain.toggle_ibd(false); } // if we have more than one tip, we need to check if our best chain has an invalid block - tips.remove(0); // no need to check our best one - for tip in tips { - self.is_our_chain_invalid(tip).await?; + for tip in tips.iter().skip(1) { + self.is_our_chain_invalid(tip.hash).await?; } return Ok(());