Skip to content

Commit 6d055d3

Browse files
committed
test: add stale-first reorg proxy with reorg validation
Add a stale-first historical reorg proxy and functional coverage: - add test framework helper to serve stale blocks before canonical blocks - add p2p_historical_reorg_proxy functional test - add contrib/historical_reorg_proxy.py runner for local/real-node replay - add debug.log reorg validation summary in the contrib runner - add docs and register test in test_runner.py Reproducer (local, stop at height 300000): BASE_DIR="/mnt/my_storage" SRC_DIR="/bitcoin" SRC_DATA_DIR="/BitcoinData" TGT_DATA_DIR="/ShallowBitcoinData" LOG_DIR="/logs" STALE_DIR="/mnt/my_storage/stale-blocks/blocks" # Terminal 1: upstream node (RPC required; cookie auth, no rpcuser/rpcpassword) "/build/bin/bitcoind" \ -datadir="" \ -server=1 \ -rpcbind=127.0.0.1 \ -rpcallowip=127.0.0.1 \ -rpcport=19443 \ -stopatheight=300000 \ -debuglogfile="/upstream-300k.log" # Terminal 2: stale-first proxy "/contrib/historical_reorg_proxy.py" \ --upstream-rpc=http://127.0.0.1:19443 \ --upstream-cookie-file="/.cookie" \ --stale-blocks-dir="" \ --target-debug-log="/shallow-300k.log" \ --listen-host=127.0.0.1 \ --listen-port=8338 \ --network=mainnet # Terminal 3: target node under test (no -server=1) "/build/bin/bitcoind" \ -datadir="" \ -prune=550 \ -noconnect \ -addnode=127.0.0.1:8338 \ -listen=0 \ -dnsseed=0 \ -fixedseeds=0 \ -discover=0 \ -stopatheight=300000 \ -debuglogfile="/shallow-300k.log" After the target exits at 300000, stop the proxy with Ctrl-C to print the reorg validation summary from debug.log. ---- Reproducer (local, stop at height 300000): BASE_DIR="/mnt/my_storage" SRC_DIR="$BASE_DIR/bitcoin" SRC_DATA_DIR="$BASE_DIR/BitcoinData" TGT_DATA_DIR="$BASE_DIR/ShallowBitcoinData" LOG_DIR="$BASE_DIR/logs" STALE_DIR="/mnt/my_storage/stale-blocks/blocks" # Terminal 1: upstream node (RPC required; cookie auth, no rpcuser/rpcpassword) "$SRC_DIR/build/bin/bitcoind" \ -datadir="$SRC_DATA_DIR" \ -server=1 \ -rpcbind=127.0.0.1 \ -rpcallowip=127.0.0.1 \ -rpcport=19443 \ -stopatheight=300000 \ -debuglogfile="$LOG_DIR/upstream-300k.log" # Terminal 2: stale-first proxy "$SRC_DIR/contrib/historical_reorg_proxy.py" \ --upstream-rpc=http://127.0.0.1:19443 \ --upstream-cookie-file="$SRC_DATA_DIR/.cookie" \ --stale-blocks-dir="$STALE_DIR" \ --target-debug-log="$LOG_DIR/shallow-300k.log" \ --listen-host=127.0.0.1 \ --listen-port=8338 \ --network=mainnet # Terminal 3: target node under test (no -server=1) "$SRC_DIR/build/bin/bitcoind" \ -datadir="$TGT_DATA_DIR" \ -prune=550 \ -noconnect \ -addnode=127.0.0.1:8338 \ -listen=0 \ -dnsseed=0 \ -fixedseeds=0 \ -discover=0 \ -stopatheight=300000 \ -debuglogfile="$LOG_DIR/shallow-300k.log" After the target exits at 300000, stop the proxy with Ctrl-C to print the reorg validation summary from debug.log.
1 parent d9c7364 commit 6d055d3

5 files changed

Lines changed: 837 additions & 0 deletions

File tree

contrib/historical_reorg_proxy.py

Lines changed: 368 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,368 @@
1+
#!/usr/bin/env python3
2+
# Copyright (c) 2026-present The Bitcoin Core developers
3+
# Distributed under the MIT software license, see the accompanying
4+
# file COPYING or http://www.opensource.org/licenses/mit-license.php.
5+
"""Run a stale-first block proxy for historical reorg replay."""
6+
7+
from argparse import ArgumentParser
8+
from collections import defaultdict
9+
import json
10+
from pathlib import Path
11+
import re
12+
import sys
13+
import threading
14+
import time
15+
from urllib.parse import (
16+
quote,
17+
urlsplit,
18+
urlunsplit,
19+
)
20+
21+
22+
REPO_ROOT = Path(__file__).resolve().parents[1]
23+
FUNCTIONAL_ROOT = REPO_ROOT / "test" / "functional"
24+
if str(FUNCTIONAL_ROOT) not in sys.path:
25+
sys.path.insert(0, str(FUNCTIONAL_ROOT))
26+
27+
from test_framework.authproxy import AuthServiceProxy # noqa: E402
28+
from test_framework.p2p import NetworkThread # noqa: E402
29+
from test_framework.stale_reorg_proxy import ( # noqa: E402
30+
HistoricalReorgProxy,
31+
RPCChainView,
32+
hash_int_to_hex,
33+
load_stale_blocks_from_dir,
34+
)
35+
36+
UPDATE_TIP_RE = re.compile(r"UpdateTip: new best=([0-9a-f]{64}) height=(\d+)")
37+
38+
39+
def parse_args():
40+
parser = ArgumentParser(description=__doc__)
41+
parser.add_argument(
42+
"--upstream-rpc",
43+
required=True,
44+
help="Upstream node RPC URL, e.g. http://127.0.0.1:8332",
45+
)
46+
parser.add_argument(
47+
"--upstream-cookie-file",
48+
default=None,
49+
help="Optional upstream RPC .cookie file used for authentication",
50+
)
51+
parser.add_argument(
52+
"--target-rpc",
53+
default=None,
54+
help="Optional target node RPC URL used for UTXO snapshot output",
55+
)
56+
parser.add_argument(
57+
"--target-cookie-file",
58+
default=None,
59+
help="Optional target RPC .cookie file used for authentication",
60+
)
61+
parser.add_argument(
62+
"--stale-blocks-dir",
63+
default=str(REPO_ROOT / "stale-blocks" / "blocks"),
64+
help="Directory containing stale block blobs (<height>-<hash>.bin)",
65+
)
66+
parser.add_argument(
67+
"--listen-host",
68+
default="127.0.0.1",
69+
help="Host to bind the proxy listener",
70+
)
71+
parser.add_argument(
72+
"--listen-port",
73+
type=int,
74+
default=8338,
75+
help="Port to bind the proxy listener",
76+
)
77+
parser.add_argument(
78+
"--network",
79+
choices=["mainnet", "testnet4", "signet", "regtest"],
80+
default="mainnet",
81+
help="P2P network magic to use",
82+
)
83+
parser.add_argument(
84+
"--max-height",
85+
type=int,
86+
default=None,
87+
help="Optional maximum active-chain height to serve",
88+
)
89+
parser.add_argument(
90+
"--snapshot-height",
91+
type=int,
92+
default=None,
93+
help="If --target-rpc is set, emit UTXO snapshot once this height is reached",
94+
)
95+
parser.add_argument(
96+
"--poll-seconds",
97+
type=float,
98+
default=5.0,
99+
help="Polling interval for --snapshot-height checks",
100+
)
101+
parser.add_argument(
102+
"--exit-after-snapshot",
103+
action="store_true",
104+
help="Exit immediately after printing snapshot data",
105+
)
106+
parser.add_argument(
107+
"--target-debug-log",
108+
default=None,
109+
help="Path to target node debug.log for reorg validation summary",
110+
)
111+
parser.add_argument(
112+
"--allow-missing-reorgs",
113+
action="store_true",
114+
help="Do not return non-zero if stale blocks lack reorg evidence in debug.log",
115+
)
116+
return parser.parse_args()
117+
118+
119+
def collect_snapshot(target_rpc):
120+
snapshot = target_rpc.gettxoutsetinfo("hash_serialized_3", use_index=False)
121+
return {
122+
"height": snapshot["height"],
123+
"bestblock": snapshot["bestblock"],
124+
"hash_serialized_3": snapshot.get("hash_serialized_3"),
125+
"txouts": snapshot["txouts"],
126+
"total_amount": snapshot["total_amount"],
127+
}
128+
129+
130+
def parse_update_tip_events(debug_log_path, start_offset=0):
131+
path = Path(debug_log_path)
132+
if not path.exists():
133+
return []
134+
135+
events = []
136+
with path.open("r", encoding="utf-8", errors="replace") as log_file:
137+
if start_offset > 0:
138+
file_size = path.stat().st_size
139+
if file_size >= start_offset:
140+
log_file.seek(start_offset)
141+
142+
for lineno, line in enumerate(log_file, start=1):
143+
match = UPDATE_TIP_RE.search(line)
144+
if match is None:
145+
continue
146+
events.append(
147+
{
148+
"lineno": lineno,
149+
"hash": match.group(1),
150+
"height": int(match.group(2)),
151+
}
152+
)
153+
return events
154+
155+
156+
def validate_reorgs_from_events(served_stale, update_tip_events):
157+
"""Validate that each served stale tip later gets replaced."""
158+
positions_by_hash = defaultdict(list)
159+
for idx, event in enumerate(update_tip_events):
160+
positions_by_hash[event["hash"]].append(idx)
161+
162+
per_block = []
163+
for stale in sorted(served_stale, key=lambda item: (item["height"], item["hash"])):
164+
stale_hash = stale["hash"]
165+
stale_height = stale["height"]
166+
positions = positions_by_hash.get(stale_hash, [])
167+
168+
connected_as_tip = bool(positions)
169+
reorged_out = False
170+
saw_disconnect_phase = False
171+
replaced_by = None
172+
173+
if connected_as_tip:
174+
first_pos = positions[0]
175+
for event in update_tip_events[first_pos + 1 :]:
176+
if event["height"] < stale_height:
177+
saw_disconnect_phase = True
178+
if event["hash"] != stale_hash and event["height"] >= stale_height:
179+
reorged_out = True
180+
replaced_by = {
181+
"hash": event["hash"],
182+
"height": event["height"],
183+
"lineno": event["lineno"],
184+
}
185+
break
186+
187+
per_block.append(
188+
{
189+
"hash": stale_hash,
190+
"height": stale_height,
191+
"connected_as_tip": connected_as_tip,
192+
"reorged_out": reorged_out,
193+
"saw_disconnect_phase": saw_disconnect_phase,
194+
"replaced_by": replaced_by,
195+
}
196+
)
197+
198+
connected = [block for block in per_block if block["connected_as_tip"]]
199+
reorged = [block for block in per_block if block["reorged_out"]]
200+
missing_tip = [block for block in per_block if not block["connected_as_tip"]]
201+
missing_reorg = [
202+
block
203+
for block in per_block
204+
if block["connected_as_tip"] and not block["reorged_out"]
205+
]
206+
saw_disconnect = [block for block in per_block if block["saw_disconnect_phase"]]
207+
208+
return {
209+
"served_stale": len(served_stale),
210+
"update_tip_events": len(update_tip_events),
211+
"connected_as_tip": len(connected),
212+
"reorged_out": len(reorged),
213+
"with_disconnect_phase": len(saw_disconnect),
214+
"missing_tip": missing_tip,
215+
"missing_reorg": missing_reorg,
216+
"details": per_block,
217+
}
218+
219+
220+
def print_reorg_summary(summary):
221+
print("Reorg validation summary")
222+
print(f" Served stale blocks: {summary['served_stale']}")
223+
print(f" UpdateTip log events: {summary['update_tip_events']}")
224+
print(f" Connected as tip: {summary['connected_as_tip']}")
225+
print(f" Reorged out: {summary['reorged_out']}")
226+
print(f" With disconnect evidence: {summary['with_disconnect_phase']}")
227+
228+
if summary["missing_tip"]:
229+
print(" Missing tip activations:")
230+
for block in summary["missing_tip"]:
231+
print(f" {block['height']}-{block['hash']}")
232+
233+
if summary["missing_reorg"]:
234+
print(" Missing reorg evidence:")
235+
for block in summary["missing_reorg"]:
236+
print(f" {block['height']}-{block['hash']}")
237+
238+
239+
def rpc_url_with_cookie(rpc_url, cookie_file):
240+
if cookie_file is None:
241+
return rpc_url
242+
243+
cookie_text = Path(cookie_file).read_text(encoding="utf-8").strip()
244+
if ":" not in cookie_text:
245+
raise ValueError(f"Malformed cookie file (missing user:pass): {cookie_file}")
246+
247+
cookie_user, cookie_pass = cookie_text.split(":", 1)
248+
normalized_url = rpc_url if "://" in rpc_url else f"http://{rpc_url}"
249+
split = urlsplit(normalized_url)
250+
251+
# If credentials already exist in URL, keep them.
252+
if split.username is not None:
253+
return normalized_url
254+
255+
netloc = f"{quote(cookie_user, safe='')}:{quote(cookie_pass, safe='')}@{split.netloc}"
256+
return urlunsplit((split.scheme, netloc, split.path, split.query, split.fragment))
257+
258+
259+
def main():
260+
args = parse_args()
261+
debug_log_offset = 0
262+
263+
upstream_rpc_url = rpc_url_with_cookie(args.upstream_rpc, args.upstream_cookie_file)
264+
upstream_rpc = AuthServiceProxy(upstream_rpc_url, timeout=300)
265+
266+
target_rpc = None
267+
if args.target_rpc:
268+
target_rpc_url = rpc_url_with_cookie(args.target_rpc, args.target_cookie_file)
269+
target_rpc = AuthServiceProxy(target_rpc_url, timeout=300)
270+
271+
stale_dir = Path(args.stale_blocks_dir)
272+
stale_blocks = []
273+
if stale_dir.exists():
274+
stale_blocks = load_stale_blocks_from_dir(stale_dir)
275+
else:
276+
print(f"Warning: stale blocks directory does not exist: {stale_dir}")
277+
278+
print(f"Loaded {len(stale_blocks)} stale blocks from {stale_dir}")
279+
280+
if args.target_debug_log:
281+
debug_log_path = Path(args.target_debug_log)
282+
if debug_log_path.exists():
283+
debug_log_offset = debug_log_path.stat().st_size
284+
print(
285+
f"Tracking debug log for reorg validation: {debug_log_path} "
286+
f"(start byte={debug_log_offset})"
287+
)
288+
289+
proxy = HistoricalReorgProxy(
290+
chain_view=RPCChainView(upstream_rpc),
291+
stale_blocks=stale_blocks,
292+
max_height=args.max_height,
293+
)
294+
proxy.peer_connect_helper("0", 0, args.network, 1)
295+
proxy.reconnect = False
296+
297+
network_thread = NetworkThread()
298+
network_thread.start()
299+
300+
ready = threading.Event()
301+
302+
def on_listen(addr, port):
303+
print(f"Proxy listening on {addr}:{port}")
304+
print(f"Connect target node with: -connect={addr}:{port}")
305+
ready.set()
306+
307+
NetworkThread.listen(proxy, on_listen, addr=args.listen_host, port=args.listen_port)
308+
if not ready.wait(timeout=10):
309+
raise RuntimeError("Timed out while waiting for proxy listener startup")
310+
311+
exit_code = 0
312+
snapshot_printed = False
313+
try:
314+
while True:
315+
if target_rpc and args.snapshot_height is not None:
316+
target_height = target_rpc.getblockcount()
317+
if target_height >= args.snapshot_height:
318+
print(json.dumps(collect_snapshot(target_rpc), indent=2, sort_keys=True))
319+
snapshot_printed = True
320+
if args.exit_after_snapshot:
321+
break
322+
args.snapshot_height = None
323+
time.sleep(args.poll_seconds)
324+
except KeyboardInterrupt:
325+
pass
326+
finally:
327+
if target_rpc and not snapshot_printed:
328+
print(json.dumps(collect_snapshot(target_rpc), indent=2, sort_keys=True))
329+
330+
if args.target_debug_log:
331+
served_stale = []
332+
seen_stale_hashes = set()
333+
for kind, height, block_hash_int in proxy.served_blocks:
334+
if kind != "stale":
335+
continue
336+
if block_hash_int in seen_stale_hashes:
337+
continue
338+
seen_stale_hashes.add(block_hash_int)
339+
served_stale.append(
340+
{
341+
"height": int(height),
342+
"hash": hash_int_to_hex(block_hash_int),
343+
}
344+
)
345+
346+
update_tip_events = parse_update_tip_events(
347+
args.target_debug_log,
348+
start_offset=debug_log_offset,
349+
)
350+
summary = validate_reorgs_from_events(served_stale, update_tip_events)
351+
print_reorg_summary(summary)
352+
353+
if summary["missing_tip"] or summary["missing_reorg"]:
354+
if args.allow_missing_reorgs:
355+
print(
356+
"Reorg validation did not fully pass, but continuing "
357+
"due to --allow-missing-reorgs."
358+
)
359+
else:
360+
print("Reorg validation failed.")
361+
exit_code = 1
362+
363+
network_thread.close(timeout=10)
364+
return exit_code
365+
366+
367+
if __name__ == "__main__":
368+
raise SystemExit(main())

0 commit comments

Comments
 (0)