Skip to content

Commit cdeee33

Browse files
committed
test(rpc): add getchaintips functional test
Add integration test exercising `getchaintips` in two scenarios: - Scenario A: Fresh node returns single active tip at genesis (height 0) - Scenario B: After syncing 10 mined blocks, tip is at height 10 - Scenario C: Create one fork by invalidating block 5 and mining 10 new blocks - Scenario D: Create a second fork by invalidating block 8 and mining 10 more.
1 parent fadcf67 commit cdeee33

File tree

5 files changed

+237
-7
lines changed

5 files changed

+237
-7
lines changed

crates/floresta-node/src/json_rpc/blockchain.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,6 @@ impl<Blockchain: RpcChain> RpcImpl<Blockchain> {
311311
// getblockstats
312312
// getchainstates
313313

314-
// getchaintips
315314
pub(super) fn get_chain_tips(&self) -> Result<Vec<ChainTip>, JsonRpcError> {
316315
let tips = self
317316
.chain
@@ -335,6 +334,7 @@ impl<Blockchain: RpcChain> RpcImpl<Blockchain> {
335334
});
336335
continue;
337336
}
337+
338338
let tip_height = self
339339
.chain
340340
.get_block_height(&tip)

tests/floresta-cli/getchaintips.py

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
"""
2+
getchaintips.py
3+
4+
Tests `getchaintips` RPC through four scenarios:
5+
A) Only genesis block exists
6+
B) Synced a 10-block chain (no forks)
7+
C) Create one fork by invalidating block 5 and mining 10 new blocks
8+
D) Create a second fork by invalidating block 8 and mining 10 more
9+
10+
Each scenario checks the response shape and that fork tips are correct.
11+
"""
12+
13+
import re
14+
import time
15+
16+
from test_framework import FlorestaTestFramework
17+
from test_framework.node import NodeType
18+
19+
REGTEST_GENESIS_HASH = (
20+
"0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206"
21+
)
22+
23+
VALID_STATUSES = {"active", "valid-fork", "headers-only", "invalid"}
24+
25+
26+
class GetChainTipsTest(FlorestaTestFramework):
27+
"""Test the getchaintips RPC across genesis and synced chain scenarios."""
28+
29+
def set_test_params(self):
30+
self.florestad = self.add_node_default_args(variant=NodeType.FLORESTAD)
31+
self.utreexod = self.add_node_extra_args(
32+
variant=NodeType.UTREEXOD,
33+
extra_args=[
34+
"--miningaddr=bcrt1q4gfcga7jfjmm02zpvrh4ttc5k7lmnq2re52z2y",
35+
"--utreexoproofindex",
36+
"--prune=0",
37+
],
38+
)
39+
40+
def validate_chain_tips_structure(self, tips):
41+
"""Validate the structure of the getchaintips response."""
42+
expected_keys = {"height", "hash", "branchlen", "status"}
43+
44+
self.assertTrue(isinstance(tips, list))
45+
self.assertTrue(len(tips) >= 1)
46+
47+
active_count = 0
48+
for tip in tips:
49+
# Exactly the expected keys
50+
self.assertEqual(set(tip.keys()), expected_keys)
51+
52+
# Type checks
53+
self.assertTrue(isinstance(tip["height"], int))
54+
self.assertTrue(isinstance(tip["branchlen"], int))
55+
self.assertTrue(isinstance(tip["hash"], str))
56+
self.assertTrue(isinstance(tip["status"], str))
57+
58+
# Hash is a 64-char hex string
59+
self.assertTrue(bool(re.fullmatch(r"[a-f0-9]{64}", tip["hash"])))
60+
61+
# Status is one of the valid values
62+
self.assertIn(tip["status"], VALID_STATUSES)
63+
64+
# branchlen is non-negative
65+
self.assertTrue(tip["branchlen"] >= 0)
66+
67+
if tip["status"] == "active":
68+
active_count += 1
69+
# Active tip always has branchlen 0
70+
self.assertEqual(tip["branchlen"], 0)
71+
else:
72+
# Non-active tips must have branchlen > 0
73+
self.assertTrue(tip["branchlen"] > 0)
74+
75+
# Exactly one active tip
76+
self.assertEqual(active_count, 1)
77+
78+
def get_tip_by_status(self, tips, status):
79+
"""Find the first tip with the given status."""
80+
for tip in tips:
81+
if tip["status"] == status:
82+
return tip
83+
return None
84+
85+
def get_tip_by_hash(self, tips, block_hash):
86+
"""Find a tip by its block hash."""
87+
for tip in tips:
88+
if tip["hash"] == block_hash:
89+
return tip
90+
return None
91+
92+
def get_tips_by_status(self, tips, status):
93+
"""Find all tips with the given status."""
94+
return [tip for tip in tips if tip["status"] == status]
95+
96+
def run_test(self):
97+
self.run_node(self.florestad)
98+
99+
tips = self.florestad.rpc.get_chain_tips()
100+
self.validate_chain_tips_structure(tips)
101+
102+
# Scenario A: `getchaintips()` while on genesis.
103+
self.log("=== Scenario A: genesis only")
104+
self.assertEqual(len(tips), 1)
105+
106+
active_tip = self.get_tip_by_status(tips, "active")
107+
self.assertIsSome(active_tip)
108+
self.assertEqual(active_tip["height"], 0)
109+
self.assertEqual(active_tip["branchlen"], 0)
110+
self.assertEqual(active_tip["hash"], REGTEST_GENESIS_HASH)
111+
112+
# Scenario B: `getchaintips()` on synced chain with 10 blocks
113+
self.log("=== Scenario B: synced chain, no forks")
114+
self.run_node(self.utreexod)
115+
116+
self.log("Mining 10 blocks on utreexod")
117+
self.utreexod.rpc.generate(10)
118+
119+
self.log("Connecting florestad to utreexod")
120+
self.connect_nodes(self.florestad, self.utreexod)
121+
122+
self.log("Waiting for sync...")
123+
time.sleep(20)
124+
125+
tips = self.florestad.rpc.get_chain_tips()
126+
self.validate_chain_tips_structure(tips)
127+
128+
self.log("Assert: exactly 1 tip (no forks)")
129+
self.assertEqual(len(tips), 1)
130+
131+
active_tip = self.get_tip_by_status(tips, "active")
132+
self.assertIsSome(active_tip)
133+
self.assertEqual(active_tip["height"], 10)
134+
self.assertEqual(active_tip["branchlen"], 0)
135+
136+
utreexo_best = self.utreexod.rpc.get_bestblockhash()
137+
self.assertEqual(active_tip["hash"], utreexo_best)
138+
139+
# Scenario C: Invalidate block 5 and mine 10 new blocks.
140+
# The chain splits at height 4: the new branch grows to height 14
141+
# and becomes active, the old blocks 5..10 become a fork tip.
142+
#
143+
self.log("=== Scenario C: single fork via invalidation at height 5")
144+
145+
block_at_5 = self.utreexod.rpc.get_blockhash(5)
146+
self.utreexod.rpc.invalidate_block(block_at_5)
147+
148+
self.log("Mining 10 blocks on new chain")
149+
new_hashes = self.utreexod.rpc.generate(10)
150+
151+
self.log("Waiting for sync...")
152+
time.sleep(20)
153+
154+
tips = self.florestad.rpc.get_chain_tips()
155+
self.log(f"Chain tips after first fork: {tips}")
156+
self.validate_chain_tips_structure(tips)
157+
158+
self.log("Assert: exactly 2 tips (1 fork)")
159+
self.assertEqual(len(tips), 2)
160+
161+
# The new chain tip at height 14
162+
utreexo_best = self.utreexod.rpc.get_bestblockhash()
163+
active_tip = self.get_tip_by_hash(tips, utreexo_best)
164+
self.assertIsSome(active_tip)
165+
self.assertEqual(active_tip["status"], "active")
166+
self.assertEqual(active_tip["height"], 14)
167+
self.assertEqual(active_tip["branchlen"], 0)
168+
169+
# The old tip at height 10 is now a fork tip
170+
fork_hash = new_hashes[5] # height 10 on the new chain
171+
fork_tip = self.get_tip_by_hash(tips, fork_hash)
172+
self.assertIsSome(fork_tip)
173+
self.assertEqual(fork_tip["status"], "valid-fork")
174+
self.assertEqual(fork_tip["height"], 10)
175+
self.assertEqual(fork_tip["branchlen"], 1)
176+
177+
# Scenario D: Invalidate block 8 and mine 10 more.
178+
# The chain now splits at height 7: the new branch grows to 17
179+
# (active), old blocks 8..14 become a second fork tip, and the
180+
# fork from scenario C is still around at height 10.
181+
self.log("=== Scenario D: second fork via invalidation at height 8")
182+
183+
block_at_8 = self.utreexod.rpc.get_blockhash(8)
184+
self.utreexod.rpc.invalidate_block(block_at_8)
185+
186+
self.log("Mining 10 blocks on second alternative chain")
187+
new_hashes_d = self.utreexod.rpc.generate(10)
188+
189+
self.log("Waiting for sync...")
190+
time.sleep(20)
191+
192+
tips = self.florestad.rpc.get_chain_tips()
193+
self.log(f"Chain tips after second fork: {tips}")
194+
self.validate_chain_tips_structure(tips)
195+
196+
self.log("Assert: exactly 3 tips (2 forks)")
197+
self.assertEqual(len(tips), 3)
198+
199+
# The new chain tip at height 17
200+
utreexo_best = self.utreexod.rpc.get_bestblockhash()
201+
active_tip = self.get_tip_by_hash(tips, utreexo_best)
202+
self.assertIsSome(active_tip)
203+
self.assertEqual(active_tip["status"], "active")
204+
self.assertEqual(active_tip["height"], 17)
205+
self.assertEqual(active_tip["branchlen"], 0)
206+
207+
# The old tip at height 14 is now a fork tip
208+
fork_hash_d = new_hashes_d[6] # height 14 on the newest chain
209+
fork_tip_d = self.get_tip_by_hash(tips, fork_hash_d)
210+
self.assertIsSome(fork_tip_d)
211+
self.assertEqual(fork_tip_d["status"], "valid-fork")
212+
self.assertEqual(fork_tip_d["height"], 14)
213+
self.assertEqual(fork_tip_d["branchlen"], 1)
214+
215+
# The fork from scenario C is still here, but its branchlen
216+
# grew from 1 to 3 because the active chain now diverges at
217+
# height 7 instead of height 4.
218+
fork_tip_c = self.get_tip_by_hash(tips, fork_hash)
219+
self.assertIsSome(fork_tip_c)
220+
self.assertEqual(fork_tip_c["status"], "valid-fork")
221+
self.assertEqual(fork_tip_c["height"], 10)
222+
self.assertEqual(fork_tip_c["branchlen"], 3)
223+
224+
225+
if __name__ == "__main__":
226+
GetChainTipsTest().main()

tests/test_framework/rpc/base.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,19 @@
66
"""
77

88
import json
9+
import re
910
import socket
1011
import time
11-
import re
12+
from abc import ABC, abstractmethod
1213
from datetime import datetime, timezone
1314
from typing import Any, Dict, List, Optional
1415
from urllib.parse import quote
15-
from abc import ABC, abstractmethod
1616

1717
from requests import post
1818
from requests.exceptions import HTTPError
1919
from requests.models import HTTPBasicAuth
20-
from test_framework.rpc.exceptions import JSONRPCError
2120
from test_framework.rpc import ConfigRPC
21+
from test_framework.rpc.exceptions import JSONRPCError
2222

2323

2424
# pylint: disable=too-many-public-methods
@@ -38,7 +38,7 @@ class BaseRPC(ABC):
3838
Subclasses should use `perform_request` to implement RPC calls.
3939
"""
4040

41-
TIMEOUT: int = 15 # seconds
41+
TIMEOUT: int = 30 # seconds
4242

4343
def __init__(self, config: ConfigRPC):
4444
self._config = config

tests/test_framework/rpc/floresta.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,7 @@ def get_memoryinfo(self, mode: str):
3232
raise ValueError(f"Invalid getmemoryinfo mode: '{mode}'")
3333

3434
return self.perform_request("getmemoryinfo", params=[mode])
35+
36+
def get_chain_tips(self):
37+
"""Returns information about all known chain tips in the block tree"""
38+
return self.perform_request("getchaintips")

tests/test_runner.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@
2222
import os
2323
import subprocess
2424
from collections import defaultdict
25-
from threading import Thread
2625
from queue import Queue
26+
from threading import Thread
2727
from time import time
2828

2929
from test_framework.util import Utility
@@ -67,6 +67,7 @@
6767
("floresta-cli", "getmemoryinfo"),
6868
("floresta-cli", "getpeerinfo"),
6969
("floresta-cli", "getblockchaininfo"),
70+
("floresta-cli", "getchaintips"),
7071
("floresta-cli", "getblockheader"),
7172
("example", "bitcoin"),
7273
("example", "utreexod"),
@@ -206,7 +207,6 @@ def run_test_worker(task_queue: Queue, results_queue: Queue, args: argparse.Name
206207
with open(
207208
test_log_name, "wt", encoding="utf-8", buffering=args.log_buffer
208209
) as log_file:
209-
210210
# Avoid using 'with' for `subprocess.Popen` here, as we need the
211211
# process to start and stream output immediately for port detection
212212
# to work correctly. Using 'with' might delay output flushing,

0 commit comments

Comments
 (0)