import asyncio
from dataclasses import dataclass
import os
from binascii import hexlify, unhexlify
from base64 import b64encode
from decimal import Decimal, ROUND_DOWN
import hashlib
import json
import http.client
import signal
import subprocess
import time
import errno
import logging
from .serialize import deser_uint256, ser_uint256
from . import test_node
from .portseed import (
electrum_http_port,
electrum_rpc_port,
electrum_rpc_ssl_port,
electrum_ws_port,
electrum_ws_ssl_port,
electrum_https_port,
p2p_port,
rpc_port,
)
from .authproxy import AuthServiceProxy, JSONRPCException
from .environment import (
NodeFeature,
announce_enabled,
node as node_software,
node_supports,
rostrum_path,
full_node_path,
network,
Network,
process_wrapper,
Node,
testing_tcp_ssl,
testing_websocket,
testing_websocket_ssl,
testing_http,
testing_https,
)
DEFAULT_TX_FEE_PER_BYTE = 50
BTC = 100
def sha256(s):
return hashlib.new("sha256", s).digest()
def hash256(s):
return sha256(sha256(s))
def hash160(msg):
h = hashlib.new("ripemd160")
h.update(hashlib.sha256(msg).digest())
return h.digest()
def uint256_to_rpc_hex(b):
if isinstance(b, int):
b = ser_uint256(b)
return b[::-1].hex()
def rpc_hex_to_uint256(h):
b = bytes.fromhex(h)
return deser_uint256(b[::-1])
class TimeoutException(Exception):
pass
@dataclass
class UtilOptions:
no_ipv6_rpc_listen: bool = False
NEXAD_PROC_WAIT_TIMEOUT = 60
async def wait_for(timeout, fn, on_error="timeout in wait_for", sleep_amt=1.0):
timeout = float(timeout)
while True:
if asyncio.iscoroutinefunction(fn):
result = await fn()
else:
result = fn()
if not (result is None or result is False):
return result
if timeout <= 0:
if callable(on_error):
on_error = on_error()
raise TimeoutException(on_error)
await asyncio.sleep(sleep_amt)
timeout -= sleep_amt
def get_rpc_proxy(
hostname: str, port: int, cookie: bytes, *, timeout: int | None = None
):
proxy_kwargs = {}
if timeout is not None:
proxy_kwargs["timeout"] = timeout
proxy = AuthServiceProxy(hostname, port, cookie, **proxy_kwargs)
return proxy
def force_kill_process(process: subprocess.Popen | None, timeout: int = 60):
if process is None:
return
start_time = time.time()
print(f"Terminating process {process.pid}")
process.terminate()
while time.time() - start_time < timeout / 2:
if process.poll() is not None: print(f"Process {process.pid} exited gracefully.")
return
time.sleep(0.5)
if process.poll() is None:
print(f"Sending SIGKILL to process {process.pid}")
os.kill(process.pid, signal.SIGKILL)
start_time = time.time()
while time.time() - start_time < timeout / 2:
if process.poll() is not None: print(f"Process {process.pid} was killed.")
return
time.sleep(0.5)
print(f"Giving up on waiting for process {process.pid} to exit.")
def check_json_precision():
n = Decimal("20000000.03")
satoshis = int(json.loads(json.dumps(float(n))) * 1.0e2)
if satoshis != 2000000003:
raise RuntimeError("JSON encode/decode loses precision")
def count_bytes(hex_string):
return len(bytearray.fromhex(hex_string))
def bytes_to_hex_str(byte_str):
return hexlify(byte_str).decode("ascii")
def hex_str_to_bytes(hex_str):
return unhexlify(hex_str.encode("ascii"))
def str_to_b64str(string):
return b64encode(string.encode("utf-8")).decode("ascii")
def is_connected(gdict, vertices_encountered=None, start_vertex=None):
if vertices_encountered is None:
vertices_encountered = set()
vertices = list(gdict.keys()) if not start_vertex:
start_vertex = vertices[0]
vertices_encountered.add(start_vertex)
if len(vertices_encountered) != len(vertices):
for vertex in gdict[start_vertex]:
if vertex not in vertices_encountered:
if is_connected(gdict, vertices_encountered, vertex):
return True
else:
return True
return False
async def sync_blocks(rpc_connections, *, wait=1, verbose=1, timeout=60):
i = -1
stop_time = time.time() + timeout
while time.time() <= stop_time:
counts = [x.getblockcount() for x in rpc_connections]
if counts == [counts[0]] * len(counts):
return
if verbose and i > 2:
logging.info("sync blocks %s: %s", i, counts)
asyncio.sleep(wait)
i += 1
logging.info("sync_blocks timeout, printing debug info: ")
raise Exception(
"sync_blocks: blocks did not sync through various nodes before the timeout of {timeout} seconds kicked in",
)
async def sync_mempools(rpc_connections, wait=1, verbose=1):
count = 0
while True:
count += 1
pool = set(rpc_connections[0].getrawtxpool())
num_match = 1
pool_len = [len(pool)]
for i in range(1, len(rpc_connections)):
tmp = set(rpc_connections[i].getrawtxpool())
if tmp == pool:
num_match = num_match + 1
pool_len.append(len(tmp))
if verbose and count % 30 == 0:
logging.info("sync txpool: %s", pool_len)
if num_match == len(rpc_connections):
break
asyncio.sleep(wait)
def datadir_path(tmpdir, n) -> str:
datadir = os.path.join(tmpdir, f"node{n}")
if not os.path.isdir(datadir):
os.makedirs(datadir)
return datadir
async def rostrum_args(tmpdir: str, n: int) -> list:
node_datadir = datadir_path(tmpdir, n)
args = {
"daemon-dir": node_datadir,
"daemon-rpc-addr": f"127.0.0.1:{await rpc_port(n)}",
"daemon-p2p-addr": f"127.0.0.1:{await p2p_port(n)}",
"network": "regtest",
"wait-duration-secs": 10,
"db-dir": os.path.join(node_datadir, "rostrum"),
}
as_cmd_args = [f"--{k}={v}" for k, v in args.items()]
as_cmd_args.append("-vvvv")
if not announce_enabled():
as_cmd_args.append("--no-announce")
as_cmd_args.append("--no-metrics")
if testing_websocket():
as_cmd_args.append(f"--electrum-ws-addr=0.0.0.0:{await electrum_ws_port(n)}")
as_cmd_args.append("--no-tcp")
as_cmd_args.append("--no-tcp-ssl")
as_cmd_args.append("--no-websocket-ssl")
elif testing_http():
as_cmd_args.append("--http")
as_cmd_args.append(
f"--electrum-http-addr=0.0.0.0:{await electrum_http_port(n)}"
)
as_cmd_args.append("--no-tcp")
as_cmd_args.append("--no-websocket")
as_cmd_args.append("--no-tcp-ssl")
as_cmd_args.append("--no-websocket-ssl")
elif testing_tcp_ssl():
as_cmd_args.append(
f"--electrum-rpc-ssl-addr=0.0.0.0:{await electrum_rpc_ssl_port(n)}"
)
current_dir = os.path.dirname(os.path.abspath(__file__))
as_cmd_args.append(
f"--ssl-cert-file={os.path.join(current_dir, 'data', 'dummy_example_org_cert.pem')}"
)
as_cmd_args.append(
f"--ssl-key-file={os.path.join(current_dir, 'data', 'dummy_example_org_key.pem')}"
)
as_cmd_args.append("--no-websocket")
as_cmd_args.append("--no-tcp")
as_cmd_args.append("--no-websocket-ssl")
elif testing_websocket_ssl():
as_cmd_args.append(
f"--electrum-ws-ssl-addr=0.0.0.0:{await electrum_ws_ssl_port(n)}"
)
current_dir = os.path.dirname(os.path.abspath(__file__))
as_cmd_args.append(
f"--ssl-cert-file={os.path.join(current_dir, 'data', 'dummy_example_org_cert.pem')}"
)
as_cmd_args.append(
f"--ssl-key-file={os.path.join(current_dir, 'data', 'dummy_example_org_key.pem')}"
)
as_cmd_args.append("--no-websocket")
as_cmd_args.append("--no-tcp")
as_cmd_args.append("--no-tcp-ssl")
elif testing_https():
as_cmd_args.append("--https")
as_cmd_args.append(
f"--electrum-https-addr=0.0.0.0:{await electrum_https_port(n)}"
)
current_dir = os.path.dirname(os.path.abspath(__file__))
as_cmd_args.append(
f"--ssl-cert-file={os.path.join(current_dir, 'data', 'dummy_example_org_cert.pem')}"
)
as_cmd_args.append(
f"--ssl-key-file={os.path.join(current_dir, 'data', 'dummy_example_org_key.pem')}"
)
as_cmd_args.append("--no-tcp")
as_cmd_args.append("--no-websocket")
as_cmd_args.append("--no-tcp-ssl")
as_cmd_args.append("--no-websocket-ssl")
else:
as_cmd_args.append(f"--electrum-rpc-addr=0.0.0.0:{await electrum_rpc_port(n)}")
as_cmd_args.append("--no-websocket")
as_cmd_args.append("--no-websocket-ssl")
return as_cmd_args
async def initialize_datadir(tmpdir: str, n: int):
datadir = datadir_path(tmpdir, n)
defaults = {
"discover": 0,
"regtest": 1,
"printtoconsole": 1,
"listenonion": 0,
}
network_section = {
"port": await p2p_port(n),
"rpcport": str(await rpc_port(n)),
}
if node_software() == Node.NEXA:
defaults.update(
{
"bindallorfail": 1,
"debug": ["electrum", "rpc", "net"],
"keypool": 1,
}
)
if node_software() == Node.BCHN:
defaults.update(
{
"acceptnonstdtxn": 1,
}
)
if UtilOptions.no_ipv6_rpc_listen:
network_section.update({"rpcbind": "127.0.0.1", "rpcallowip": "127.0.0.1"})
if network() == Network.NEX:
file = "nexa.conf"
defaults.update(
{
"relay.minRelayTxFee": 0,
"relay.limitFreeRelay": 15,
"wallet.payTxFee": 1000,
}
)
elif network() == Network.BCH:
file = "bitcoin.conf"
else:
raise NotImplementedError()
if node_supports(node_software(), NodeFeature.SPAWN_ROSTRUM):
rostrum_raw = await rostrum_args(tmpdir, n)
defaults.update(
{
"electrum": 1,
"electrum.exec": rostrum_path(),
"electrum.rawarg": rostrum_raw,
}
)
config_file_path = os.path.join(datadir, file)
with open(config_file_path, "w", encoding="utf-8") as f:
for key, val in defaults.items():
if isinstance(val, list):
for v in val:
f.write(f"{str(key)}={str(v)}\n")
elif isinstance(val, (str, int, float)):
f.write(f"{key}={val}\n")
else:
raise TypeError(f"Unsupported type for key '{key}': {type(val)}")
f.write("debug=rpc\n")
if node_supports(node_software(), NodeFeature.SPAWN_ROSTRUM):
f.write("debug=electrum\n")
f.write("")
if node_software() == Node.BCHN:
f.write("[regtest]\n")
for key, val in network_section.items():
f.write(f"{str(key)}={str(val)}\n")
return datadir
def parse_cookie(datadir: str) -> bytes:
with open(os.path.join(datadir, "regtest", ".cookie"), "rb") as f:
return f.read().strip()
async def rpc_connection_info(i: int, datadir: str):
cookie = parse_cookie(datadir)
return "127.0.0.1", await rpc_port(i), cookie
async def wait_for_bitcoind_start(
process, i: int, datadir: str, *, timeout: int = 120
) -> AuthServiceProxy:
rpc = None
end_time = time.time() + timeout
ignored_error = None
while time.time() < end_time:
if process.poll() is not None:
raise Exception(
f"daemon exited with status {process.returncode} during initialization"
)
try:
hostname, port, cookie = await rpc_connection_info(i, datadir)
rpc = get_rpc_proxy(hostname, port, cookie)
rpc.getblockcount()
return rpc
except FileNotFoundError as e:
ignored_error = str(e)
except IOError as e:
if e.errno == errno.ECONNREFUSED: ignored_error = str(e)
else:
raise except JSONRPCException as e: if e.error["code"] == -28: ignored_error = str(e)
elif e.error["code"] == -342: ignored_error = str(e)
else:
raise await asyncio.sleep(0.25)
ignored_error = {f"(ignored: {ignored_error})"} if ignored_error else {""}
raise TimeoutException(
f"daemon (pid {process.pid}) did not start (node {i}) {ignored_error}"
)
async def initialize_chain_clean(test_dir: str, num_nodes: int):
await asyncio.gather(*(initialize_datadir(test_dir, i) for i in range(num_nodes)))
async def start_node(i: int, tmpdir, extra_args=None) -> test_node.TestNode:
datadir = datadir_path(tmpdir, i)
wrapper = process_wrapper()
if wrapper is not None:
args = [wrapper, full_node_path(), f"-datadir={datadir}"]
else:
args = [full_node_path(), f"-datadir={datadir}"]
if extra_args is not None:
args.extend(extra_args[i])
process = None
rostrum_process = None
r_args = []
spawn_rostrum = not node_supports(node_software(), NodeFeature.SPAWN_ROSTRUM)
try:
logging.debug("Running: %s", args)
process = subprocess.Popen(args)
proxy = await wait_for_bitcoind_start(
process,
i,
datadir,
)
logging.info("Running as pid %s node: %s datadir: %s", process.pid, i, datadir)
if spawn_rostrum:
if wrapper is not None:
r_args = [wrapper, rostrum_path()]
else:
r_args = [rostrum_path()]
r_args = r_args + await rostrum_args(tmpdir, i)
logging.info("Starting rostrum with args: %s", r_args)
rostrum_process = subprocess.Popen(r_args)
except Exception as e:
logging.error(
"Error starting node %s (%s) with args %s. %s rostrum with args %s: %s",
i,
node_software(),
args,
"Spawning" if spawn_rostrum else "Not spawning",
r_args,
e,
)
force_kill_process(rostrum_process)
force_kill_process(process)
raise
return test_node.TestNode(proxy, datadir, process, i, rostrum_process)
async def start_nodes(num_nodes, dirname, extra_args=None):
logging.info("Starting %d nodes.", num_nodes)
started = []
try:
for i in range(num_nodes):
started.append(await start_node(i, dirname, extra_args))
except Exception as e:
logging.error("Not all nodes managed to start: %s", e)
if started:
logging.info("Stopping %d previously started nodes.", len(started))
for i, node in enumerate(started):
try:
stop_node(node)
except Exception as error_2:
logging.error("Failed to stop node %d: %s", i, error_2)
raise e
return started
def node_regtest_dir(dirname, n_node):
return os.path.join(dirname, "node" + str(n_node), "regtest")
def log_filename(dirname, n_node, logname):
return os.path.join(node_regtest_dir(dirname, n_node), logname)
def stop_node(node: test_node.TestNode):
if node.rostrum_process is not None:
force_kill_process(node.rostrum_process)
try:
node.stop()
except http.client.CannotSendRequest as e:
logging.info("Unable to stop node via RPC, sending kill signal. Error: %s", e)
except Exception as e:
logging.info("Error sending stop signal to node: %s", e)
finally:
force_kill_process(node.process)
try:
node.process.wait(timeout=NEXAD_PROC_WAIT_TIMEOUT)
except Exception as e:
logging.warning(
"Error while waiting for node to exit, sending kill signal. Error: %s", e
)
force_kill_process(node.process)
def stop_nodes(nodes):
for n in nodes:
stop_node(n)
def set_node_times(nodes, t):
for node in nodes:
node.setmocktime(t)
def wait_bitcoinds(nodes):
for n in nodes:
n.process.wait(timeout=NEXAD_PROC_WAIT_TIMEOUT)
def wait_bitcoind_exit(node, timeout=NEXAD_PROC_WAIT_TIMEOUT):
node.process.wait(timeout=timeout)
def is_bitcoind_running(node):
return node.process.poll() is None
async def connect_nodes(from_connection, node_num_or_str):
if isinstance(node_num_or_str, int):
ip_port = "127.0.0.1:" + str(await p2p_port(node_num_or_str))
else:
ip_port = node_num_or_str
from_connection.addnode(ip_port, "onetry")
while any(peer["version"] == 0 for peer in from_connection.getpeerinfo()):
await asyncio.sleep(0.1)
async def connect_nodes_bi(nodes, a, b):
await connect_nodes(nodes[a], b)
await connect_nodes(nodes[b], a)
def assert_not_equal(thing1, thing2):
if thing1 == thing2:
raise AssertionError(f"{str(thing1)} != {str(thing2)}")
def assert_equal(thing1, thing2, message=None):
if thing1 != thing2:
error_msg = f"{repr(thing1)} != {repr(thing2)}"
if message:
error_msg = f"{message}: {error_msg}"
raise AssertionError(error_msg)
def assert_greater_than(thing1, thing2):
if thing1 <= thing2:
raise AssertionError(f"{str(thing1)} <= {str(thing2)}")
def assert_raises(exc, fun, *args, **kwds):
try:
fun(*args, **kwds)
except exc:
pass
except Exception as e:
raise AssertionError("Unexpected exception raised: " + type(e).__name__)
else:
raise AssertionError("No exception raised")
async def assert_raises_async(exc, fun, *args, **kwds):
try:
await fun(*args, **kwds)
except exc:
pass
except Exception as e:
raise AssertionError("Unexpected exception raised: " + type(e).__name__)
else:
raise AssertionError("No exception raised")
def assert_raises_rpc_error(code, message, fun, *args, **kwds):
assert try_rpc(code, message, fun, *args, **kwds), "No exception raised"
def try_rpc(code, message, fun, *args, **kwds):
try:
fun(*args, **kwds)
except JSONRPCException as e:
if (code is not None) and (code != e.error["code"]):
raise AssertionError(
f"Unexpected JSONRPC error code {e.error['code']} ({e})"
)
if (message is not None) and (message not in e.error["message"]):
raise AssertionError(
f"Expected substring '{message}' not found in '{e.error['message']}'"
)
return True
except Exception as e:
raise AssertionError("Unexpected exception raised: " + type(e).__name__)
else:
return False
def satoshi_round(amount):
return Decimal(amount).quantize(Decimal("0.01"), rounding=ROUND_DOWN)