test: add a regression test for private broadcast v1 retries
What changed, and why it matters
This commit only adds a new regression test file and registers it in the test runner. It does not change any production code in Bitcoin Core. The test verifies that if a private broadcast connection using the newer v2 transport fails, the node falls back to the older v1 transport through the Tor proxy. There is no security patch or vulnerability fix here.
No security action needed. Review as a normal test addition if desired.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The commit introduces test/functional/p2p_private_broadcast_retry_v1.py, a functional test that simulates SOCKS5 proxies and P2P listeners to confirm v2-to-v1 transport downgrade retry behavior for private transaction broadcasts over a Tor proxy. It also adds the test to test/functional/test_runner.py’s BASE_SCRIPTS list. No C++ or Python production logic is modified.
Changed components
test/functional/p2p_private_broadcast_retry_v1.pytest/functional/test_runner.pyInspect captured patch +200 / −0
diff --git a/test/functional/p2p_private_broadcast_retry_v1.py b/test/functional/p2p_private_broadcast_retry_v1.py
new file mode 100755
index 00000000..87fa440a
--- /dev/null
+++ b/test/functional/p2p_private_broadcast_retry_v1.py
@@ -0,0 +1,199 @@
+#!/usr/bin/env python3
+# Copyright (c) 2026-present The Bitcoin Core developers
+# Distributed under the MIT software license, see the accompanying
+# file COPYING or http://www.opensource.org/licenses/mit-license.php.
+"""
+Ensure that when v2 private broadcast connection to IPv4 fails the v1 retry
+will also be made through the Tor proxy.
+
+The test does:
+* Add a bunch of IPv4 addresses to the node's addrman (they will be added without P2P_V2 flag).
+* Get them to report P2P_V2 in their service flags and connect to each one, so that the flags
+ in addrman are updated to contain P2P_V2.
+* Get one successful connection to a Tor peer (.onion) so that bitcoind assumes the configured
+ Tor proxy works and is indeed a proxy to the Tor network. This will make it open private
+ broadcast connections also to IPv4 addresses via that proxy.
+* Start some private broadcast connections.
+* Remember the destination IPv4 address of the first connection and get it to fail the v2
+ transport.
+* Wait for a subsequent connection also through the Tor proxy to the same IPv4 and expect
+ it to be v1, i.e. the v2->v1 downgrade retry.
+"""
+
+from test_framework.netutil import (
+ format_addr_port
+)
+from test_framework.p2p import (
+ P2PConnection,
+ P2PInterface,
+ P2P_SERVICES,
+ start_p2p_listener,
+)
+from test_framework.messages import (
+ CAddress,
+ NODE_P2P_V2,
+)
+from test_framework.socks5 import (
+ start_socks5_server,
+)
+from test_framework.test_framework import (
+ BitcoinTestFramework,
+)
+from test_framework.v2_p2p import (
+ EncryptedP2PState,
+)
+from test_framework.wallet import (
+ MiniWallet,
+)
+
+class P2PDetermineV2or1AndClose(P2PConnection):
+ def __init__(self, on_v2or1_determined):
+ super().__init__()
+ self.on_v2or1_determined = on_v2or1_determined
+
+ # https://docs.python.org/3/library/asyncio-protocol.html#asyncio.Protocol.data_received
+ def data_received(self, data):
+ self.recvbuf += data
+ if len(self.recvbuf) >= 4:
+ self.on_v2or1_determined(1 if self.recvbuf[0:4] == self.magic_bytes else 2)
+ self.peer_disconnect()
+
+ def on_open(self):
+ pass
+
+ def on_close(self):
+ pass
+
+class P2PPrivateBroadcastRetryV1(BitcoinTestFramework):
+ def set_test_params(self):
+ self.disable_autoconnect = False
+ self.num_nodes = 1
+
+ def ipv4_via_tor_proxy_conn_versions_append(self, v2or1):
+ """
+ Add to the transport versions (v2 or v1) tried towards the first IPv4 which
+ nodes[0] tries to connect to via the Tor proxy.
+ """
+ self.ipv4_via_tor_proxy_conn_versions.append(v2or1)
+
+ def setup_nodes(self):
+ def destinations_factory_all_proxy(requested_to_addr, requested_to_port):
+ """
+ Instruct the SOCKS5 proxy to redirect all connections to newly created P2PInterface
+ objects that claim support for P2P_V2.
+ """
+ listener = P2PInterface()
+ listener.peer_connect_helper(dstaddr="0.0.0.0", dstport=0, net=self.chain, timeout_factor=self.options.timeout_factor)
+ listener.peer_connect_send_version(services=P2P_SERVICES | NODE_P2P_V2)
+
+ actual_to_addr, actual_to_port = start_p2p_listener(self.network_thread, listener)
+
+ self.log.debug("Instructing the common proxy to redirect connection for "
+ f"{format_addr_port(requested_to_addr, requested_to_port)} to "
+ f"{format_addr_port(actual_to_addr, actual_to_port)} (Python {type(listener).__name__})")
+
+ return {
+ "actual_to_addr": actual_to_addr,
+ "actual_to_port": actual_to_port,
+ }
+
+ self.all_proxy = start_socks5_server(destinations_factory_all_proxy)
+
+ self.ipv4_via_tor_proxy_addr_port = None # Remember the first IPv4 address connected to via the Tor proxy.
+ self.ipv4_via_tor_proxy_conn_versions = [] # Transport versions tried on that address.
+
+ def destinations_factory_tor_proxy(requested_to_addr, requested_to_port):
+ """
+ Instruct the SOCKS5 proxy to redirect all connections to newly created P2PInterface,
+ except the first connection to an IPv4 address and all subsequent connections to that
+ address which are redirected to P2PDetermineV2or1AndClose.
+ """
+ requested_to = format_addr_port(requested_to_addr, requested_to_port)
+
+ if not requested_to_addr.endswith(".onion") and self.ipv4_via_tor_proxy_addr_port is None: # First IPv4
+ self.ipv4_via_tor_proxy_addr_port = requested_to
+
+ if self.ipv4_via_tor_proxy_addr_port == requested_to:
+ # This is either the first (v2) or the second (the expected v1 retry) connection to requested_to.
+ listener = P2PDetermineV2or1AndClose(self.ipv4_via_tor_proxy_conn_versions_append)
+ listener.peer_connect_helper(dstaddr="0.0.0.0", dstport=0, net=self.chain, timeout_factor=self.options.timeout_factor)
+ else:
+ listener = P2PInterface()
+ listener.peer_connect_helper(dstaddr="0.0.0.0", dstport=0, net=self.chain, timeout_factor=self.options.timeout_factor)
+ listener.peer_connect_send_version(services=P2P_SERVICES | NODE_P2P_V2)
+ if not requested_to_addr.endswith(".onion"):
+ listener.v2_state = EncryptedP2PState(initiating=False, net=self.chain)
+
+ actual_to_addr, actual_to_port = start_p2p_listener(self.network_thread, listener)
+
+ self.log.debug(f"Instructing the Tor proxy to redirect connection for {requested_to} to "
+ f"{format_addr_port(actual_to_addr, actual_to_port)} (Python {type(listener).__name__})")
+
+ return {
+ "actual_to_addr": actual_to_addr,
+ "actual_to_port": actual_to_port,
+ }
+
+ self.tor_proxy = start_socks5_server(destinations_factory_tor_proxy)
+
+ self.extra_args = [
+ [
+ "-privatebroadcast=1",
+ f"-proxy={self.all_proxy.conf.addr[0]}:{self.all_proxy.conf.addr[1]}",
+ f"-onion={self.tor_proxy.conf.addr[0]}:{self.tor_proxy.conf.addr[1]}",
+ "-test=addrman",
+ "-v2transport=0",
+ ],
+ ]
+
+ super().setup_nodes()
+
+ def setup_network(self):
+ self.setup_nodes()
+
+ def run_test(self):
+ node0 = self.nodes[0]
+
+ self.log.info("Filling node0's addrman with addresses")
+ self.fill_node_addrman(node_index=0, address_types_to_add=[CAddress.NET_IPV4])
+
+ self.log.info("Opening manual connections to all IPv4 addresses to add P2P_V2 flag to addrman entries")
+ for a in node0.getnodeaddresses(count=0, network="ipv4"):
+ node0.addnode(node=format_addr_port(a["address"], a["port"]), command="onetry", v2transport=False)
+
+ self.log.info("Waiting for all IPv4 addresses to get P2P_V2 as a result of peers advertising support")
+ self.wait_until(lambda: all(a["services"] & NODE_P2P_V2 != 0 for a in node0.getnodeaddresses(count=0, network="ipv4")))
+
+ # The destinations behind the -proxy= don't actually support v2. When bitcoind runs with -v2transport=1
+ # and tries v2 on them they would print benign "magic byte mismatch" warnings.
+ # Disable those since none of them are needed anymore.
+ self.all_proxy.conf.destinations_factory = None
+
+ self.restart_node(0, extra_args=self.extra_args[0] + ["-v2transport=1"])
+
+ self.log.info("Opening a connection to a Tor addresses, so bitcoind considers -onion= is a real Tor proxy")
+ node0.addnode(node="testonlyad777777777777777777777777777777777777777775b6qd.onion:1234", command="onetry", v2transport=False)
+
+ self.log.info("Waiting for at least one Tor connection")
+ self.wait_until(lambda: any(p["network"] == "onion" for p in node0.getpeerinfo()))
+
+ self.log.info("Starting private broadcast connections")
+ wallet = MiniWallet(node0)
+ tx = wallet.create_self_transfer()
+ node0.sendrawtransaction(hexstring=tx["hex"])
+
+ self.log.info("Tor proxy: waiting for connection to an IPv4 address")
+ self.wait_until(lambda: self.ipv4_via_tor_proxy_addr_port is not None)
+ self.log.info(f"Tor proxy: got {self.ipv4_via_tor_proxy_addr_port}, waiting for v2")
+ self.wait_until(lambda: 2 in self.ipv4_via_tor_proxy_conn_versions)
+ self.log.info(f"Tor proxy: got {self.ipv4_via_tor_proxy_addr_port} v2, waiting for v1")
+ self.wait_until(lambda: 1 in self.ipv4_via_tor_proxy_conn_versions)
+ self.log.info(f"Tor proxy: got {self.ipv4_via_tor_proxy_addr_port} v2, v1")
+
+ self.stop_node(0)
+ self.all_proxy.stop()
+ self.tor_proxy.stop()
+
+
+if __name__ == "__main__":
+ P2PPrivateBroadcastRetryV1(__file__).main()
diff --git a/test/functional/test_runner.py b/test/functional/test_runner.py
index 698742bc..813d1d95 100755
--- a/test/functional/test_runner.py
+++ b/test/functional/test_runner.py
@@ -368,6 +368,7 @@ BASE_SCRIPTS = [
'p2p_permissions.py',
'feature_blocksdir.py',
'wallet_startup.py',
+ 'p2p_private_broadcast_retry_v1.py',
'feature_remove_pruned_files_on_startup.py',
'p2p_i2p_ports.py',
'p2p_i2p_sessions.py',
Why this scored 15/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.