test: add a regression test for private broadcast v1 retries

This commit is contained in:
Vasil Dimov
2026-05-29 06:47:58 +02:00
parent ab35a028ed
commit 5a3756d150
2 changed files with 200 additions and 0 deletions

View File

@@ -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()

View File

@@ -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',