tests: add base for electrum functional tests

This commit is contained in:
tecnovert
2026-02-06 11:36:34 +02:00
parent 8fa0668079
commit 604171c3eb
5 changed files with 384 additions and 74 deletions

View File

@@ -2,7 +2,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Copyright (c) 2020-2024 tecnovert # Copyright (c) 2020-2024 tecnovert
# Copyright (c) 2024-2025 The Basicswap developers # Copyright (c) 2024-2026 The Basicswap developers
# Distributed under the MIT software license, see the accompanying # Distributed under the MIT software license, see the accompanying
# file LICENSE.txt or http://www.opensource.org/licenses/mit-license.php. # file LICENSE.txt or http://www.opensource.org/licenses/mit-license.php.
@@ -15,6 +15,7 @@ import subprocess
from urllib.request import urlopen from urllib.request import urlopen
from .util import read_json_api from .util import read_json_api
from basicswap.basicswap import Coins
from basicswap.rpc import callrpc from basicswap.rpc import callrpc
from basicswap.util import toBool from basicswap.util import toBool
from basicswap.contrib.rpcauth import generate_salt, password_to_hmac from basicswap.contrib.rpcauth import generate_salt, password_to_hmac
@@ -125,6 +126,65 @@ def prepareDataDir(
return node_dir return node_dir
def prepare_balance(
use_delay_event,
coin,
amount: float,
port_target_node: int,
port_take_from_node: int,
test_balance: bool = True,
) -> None:
if coin == Coins.PART_BLIND:
coin_ticker: str = "PART"
balance_type: str = "blind_balance"
address_type: str = "stealth_address"
type_to: str = "blind"
elif coin == Coins.PART_ANON:
coin_ticker: str = "PART"
balance_type: str = "anon_balance"
address_type: str = "stealth_address"
type_to: str = "anon"
else:
coin_ticker: str = coin.name
balance_type: str = "balance"
address_type: str = "deposit_address"
js_w = read_json_api(port_target_node, "wallets")
current_balance: float = float(js_w[coin_ticker][balance_type])
if test_balance and current_balance >= amount:
return
post_json = {
"value": amount,
"address": js_w[coin_ticker][address_type],
"subfee": False,
}
if coin in (Coins.XMR, Coins.WOW):
post_json["sweepall"] = False
if coin in (Coins.PART_BLIND, Coins.PART_ANON):
post_json["type_to"] = type_to
json_rv = read_json_api(
port_take_from_node,
"wallets/{}/withdraw".format(coin_ticker.lower()),
post_json,
)
assert len(json_rv["txid"]) == 64
wait_for_amount: float = amount
if not test_balance:
wait_for_amount += current_balance
delay_iterations = 100 if coin == Coins.NAV else 20
delay_time = 5 if coin == Coins.NAV else 3
wait_for_balance(
use_delay_event,
"http://127.0.0.1:{}/json/wallets/{}".format(
port_target_node, coin_ticker.lower()
),
balance_type,
wait_for_amount,
iterations=delay_iterations,
delay_time=delay_time,
)
def checkForks(ro): def checkForks(ro):
try: try:
if "bip9_softforks" in ro: if "bip9_softforks" in ro:

View File

@@ -2,7 +2,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Copyright (c) 2020-2024 tecnovert # Copyright (c) 2020-2024 tecnovert
# Copyright (c) 2024-2025 The Basicswap developers # Copyright (c) 2024-2026 The Basicswap developers
# Distributed under the MIT software license, see the accompanying # Distributed under the MIT software license, see the accompanying
# file LICENSE or http://www.opensource.org/licenses/mit-license.php. # file LICENSE or http://www.opensource.org/licenses/mit-license.php.
@@ -139,6 +139,7 @@ def run_prepare(
use_rpcauth=False, use_rpcauth=False,
extra_settings={}, extra_settings={},
port_ofs=0, port_ofs=0,
extra_args=[],
): ):
config_path = os.path.join(datadir_path, cfg.CONFIG_FILENAME) config_path = os.path.join(datadir_path, cfg.CONFIG_FILENAME)
@@ -180,7 +181,7 @@ def run_prepare(
"-noextractover", "-noextractover",
"-noreleasesizecheck", "-noreleasesizecheck",
"-xmrrestoreheight=0", "-xmrrestoreheight=0",
] ] + extra_args
if mnemonic_in: if mnemonic_in:
testargs.append(f'-particl_mnemonic="{mnemonic_in}"') testargs.append(f'-particl_mnemonic="{mnemonic_in}"')
@@ -644,6 +645,7 @@ class XmrTestBase(TestBase):
runSystem.main() runSystem.main()
def start_processes(self): def start_processes(self):
multiprocessing.set_start_method("fork")
self.delay_event.clear() self.delay_event.clear()
for i in range(3): for i in range(3):

View File

@@ -2,7 +2,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Copyright (c) 2021-2024 tecnovert # Copyright (c) 2021-2024 tecnovert
# Copyright (c) 2024-2025 The Basicswap developers # Copyright (c) 2024-2026 The Basicswap developers
# Distributed under the MIT software license, see the accompanying # Distributed under the MIT software license, see the accompanying
# file LICENSE or http://www.opensource.org/licenses/mit-license.php. # file LICENSE or http://www.opensource.org/licenses/mit-license.php.
@@ -270,7 +270,7 @@ def signal_handler(self, sig, frame):
self.delay_event.set() self.delay_event.set()
def run_thread(self, client_id): def run_process(client_id):
client_path = os.path.join(test_path, "client{}".format(client_id)) client_path = os.path.join(test_path, "client{}".format(client_id))
testargs = [ testargs = [
"basicswap-run", "basicswap-run",
@@ -283,16 +283,14 @@ def run_thread(self, client_id):
def start_processes(self): def start_processes(self):
multiprocessing.set_start_method("spawn")
self.delay_event.clear() self.delay_event.clear()
for i in range(NUM_NODES): for i in range(NUM_NODES):
self.processes.append( self.processes.append(
multiprocessing.Process( multiprocessing.Process(
target=run_thread, target=run_process,
args=( args=(i,),
self,
i,
),
) )
) )
self.processes[-1].start() self.processes[-1].start()
@@ -302,7 +300,7 @@ def start_processes(self):
wallets = read_json_api(UI_PORT + 1, "wallets") wallets = read_json_api(UI_PORT + 1, "wallets")
if "monero" in TEST_COINS_LIST: if "monero" in self.test_coins_list:
xmr_auth = None xmr_auth = None
if os.getenv("XMR_RPC_USER", "") != "": if os.getenv("XMR_RPC_USER", "") != "":
xmr_auth = (os.getenv("XMR_RPC_USER", ""), os.getenv("XMR_RPC_PWD", "")) xmr_auth = (os.getenv("XMR_RPC_USER", ""), os.getenv("XMR_RPC_PWD", ""))
@@ -336,7 +334,7 @@ def start_processes(self):
callbtcrpc(0, "generatetoaddress", [num_blocks, self.btc_addr]) callbtcrpc(0, "generatetoaddress", [num_blocks, self.btc_addr])
logging.info("BTC blocks: {}".format(callbtcrpc(0, "getblockcount"))) logging.info("BTC blocks: {}".format(callbtcrpc(0, "getblockcount")))
if "litecoin" in TEST_COINS_LIST: if "litecoin" in self.test_coins_list:
self.ltc_addr = callltcrpc( self.ltc_addr = callltcrpc(
0, "getnewaddress", ["mining_addr"], wallet="wallet.dat" 0, "getnewaddress", ["mining_addr"], wallet="wallet.dat"
) )
@@ -367,7 +365,7 @@ def start_processes(self):
wallet="wallet.dat", wallet="wallet.dat",
) )
if "decred" in TEST_COINS_LIST: if "decred" in self.test_coins_list:
if RESET_TEST: if RESET_TEST:
_ = calldcrrpc(0, "getnewaddress") _ = calldcrrpc(0, "getnewaddress")
# assert (addr == self.dcr_addr) # assert (addr == self.dcr_addr)
@@ -397,7 +395,7 @@ def start_processes(self):
self.update_thread_dcr = threading.Thread(target=updateThreadDCR, args=(self,)) self.update_thread_dcr = threading.Thread(target=updateThreadDCR, args=(self,))
self.update_thread_dcr.start() self.update_thread_dcr.start()
if "firo" in TEST_COINS_LIST: if "firo" in self.test_coins_list:
self.firo_addr = callfirorpc(0, "getnewaddress", ["mining_addr"]) self.firo_addr = callfirorpc(0, "getnewaddress", ["mining_addr"])
num_blocks: int = 200 num_blocks: int = 200
have_blocks: int = callfirorpc(0, "getblockcount") have_blocks: int = callfirorpc(0, "getblockcount")
@@ -413,7 +411,7 @@ def start_processes(self):
[num_blocks - have_blocks, self.firo_addr], [num_blocks - have_blocks, self.firo_addr],
) )
if "bitcoincash" in TEST_COINS_LIST: if "bitcoincash" in self.test_coins_list:
self.bch_addr = callbchrpc( self.bch_addr = callbchrpc(
0, "getnewaddress", ["mining_addr"], wallet="wallet.dat" 0, "getnewaddress", ["mining_addr"], wallet="wallet.dat"
) )
@@ -432,7 +430,7 @@ def start_processes(self):
wallet="wallet.dat", wallet="wallet.dat",
) )
if "dogecoin" in TEST_COINS_LIST: if "dogecoin" in self.test_coins_list:
self.doge_addr = calldogerpc(0, "getnewaddress", ["mining_addr"]) self.doge_addr = calldogerpc(0, "getnewaddress", ["mining_addr"])
num_blocks: int = 200 num_blocks: int = 200
have_blocks: int = calldogerpc(0, "getblockcount") have_blocks: int = calldogerpc(0, "getblockcount")
@@ -446,7 +444,7 @@ def start_processes(self):
0, "generatetoaddress", [num_blocks - have_blocks, self.doge_addr] 0, "generatetoaddress", [num_blocks - have_blocks, self.doge_addr]
) )
if "namecoin" in TEST_COINS_LIST: if "namecoin" in self.test_coins_list:
self.nmc_addr = callnmcrpc(0, "getnewaddress", ["mining_addr", "bech32"]) self.nmc_addr = callnmcrpc(0, "getnewaddress", ["mining_addr", "bech32"])
num_blocks: int = 500 num_blocks: int = 500
have_blocks: int = callnmcrpc(0, "getblockcount") have_blocks: int = callnmcrpc(0, "getblockcount")
@@ -540,6 +538,22 @@ class BaseTestWithPrepare(unittest.TestCase):
bch_addr = None bch_addr = None
doge_addr = None doge_addr = None
initialised = False initialised = False
test_coins_list = TEST_COINS_LIST
@classmethod
def modifyConfig(cls, test_path, i):
modifyConfig(test_path, i)
@classmethod
def setupNodes(cls):
logging.info(f"Preparing {NUM_NODES} nodes.")
prepare_nodes(
NUM_NODES,
cls.test_coins_list,
True,
{"min_sequence_lock_seconds": 60},
PORT_OFS,
)
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
@@ -550,17 +564,9 @@ class BaseTestWithPrepare(unittest.TestCase):
if os.path.exists(test_path) and not RESET_TEST: if os.path.exists(test_path) and not RESET_TEST:
logging.info(f"Continuing with existing directory: {test_path}") logging.info(f"Continuing with existing directory: {test_path}")
else: else:
logging.info(f"Preparing {NUM_NODES} nodes.") cls.setupNodes()
prepare_nodes(
NUM_NODES,
TEST_COINS_LIST,
True,
{"min_sequence_lock_seconds": 60},
PORT_OFS,
)
for i in range(NUM_NODES): for i in range(NUM_NODES):
modifyConfig(test_path, i) cls.modifyConfig(test_path, i)
signal.signal( signal.signal(
signal.SIGINT, lambda signal, frame: signal_handler(cls, signal, frame) signal.SIGINT, lambda signal, frame: signal_handler(cls, signal, frame)

View File

@@ -0,0 +1,282 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Copyright (c) 2026 The Basicswap developers
# Distributed under the MIT software license, see the accompanying
# file LICENSE or http://www.opensource.org/licenses/mit-license.php.
"""
# Ensure Electrumx is installed to a venv in ELECTRUMX_SRC_DIR/venv
# Example setup with default paths:
The leveldb system package may be required to install plyvel:
sudo pacman -S leveldb
cd ~/tmp/
git clone git@github.com:spesmilo/electrumx.git
cd electrumx
python3 -m venv venv
. venv/bin/activate
pip install ".[ujson]"
# Run test
export TEST_PATH=/tmp/test_electrum
mkdir -p ${TEST_PATH}/bin
cp -r ~/tmp/basicswap_bin/* ${TEST_PATH}/bin
export ELECTRUMX_SRC_DIR="~/tmp/electrumx"
export EXTRA_CONFIG_JSON="{\"btc0\":[\"txindex=1\",\"rpcworkqueue=1100\"]}"
export TEST_COINS_LIST="bitcoin"
export PYTHONPATH=$(pwd)
python tests/basicswap/test_electrum.py
# Optionally copy coin releases to permanent storage for faster subsequent startups
cp -r ${TEST_PATH}/bin/* ~/tmp/basicswap_bin/
"""
import json
import logging
import os
import random
import shutil
import subprocess
import sys
import unittest
import basicswap.config as cfg
from basicswap.basicswap import Coins
from basicswap.util.daemon import Daemon
from tests.basicswap.common import (
prepare_balance,
stopDaemons,
waitForNumBids,
waitForNumOffers,
)
from tests.basicswap.common_xmr import run_prepare, TEST_PATH
from tests.basicswap.extended.test_xmr_persistent import (
BaseTestWithPrepare,
NUM_NODES,
PORT_OFS,
RESET_TEST,
)
from tests.basicswap.mnemonics import mnemonics
from tests.basicswap.util import (
read_json_api,
post_json_api,
)
logger = logging.getLogger()
logger.level = logging.DEBUG
if not len(logger.handlers):
logger.addHandler(logging.StreamHandler(sys.stdout))
def modifyConfig(test_path, i):
if i == 1:
config_path = os.path.join(test_path, f"client{i}", cfg.CONFIG_FILENAME)
with open(config_path) as fp:
settings = json.load(fp)
settings["fetchpricesthread"] = False
with open(config_path, "w") as fp:
json.dump(settings, fp, indent=4)
class Test(BaseTestWithPrepare):
update_min = 2
daemons = []
@classmethod
def setUpClass(cls):
super(Test, cls).setUpClass()
logger.info("Starting Electrumx for BTC")
ELECTRUMX_SRC_DIR = os.path.expanduser(os.getenv("ELECTRUMX_SRC_DIR"))
if ELECTRUMX_SRC_DIR is None:
raise ValueError("Please set ELECTRUMX_SRC_DIR")
ELECTRUMX_VENV = os.getenv(
"ELECTRUMX_VENV", os.path.join(ELECTRUMX_SRC_DIR, "venv")
)
BTC_BASE_RPC_PORT = 32793 # client0
ELECTRUMX_DATADIR_BTC = os.path.join(TEST_PATH, "electrumx_btc")
SSL_CERTFILE = f"{ELECTRUMX_DATADIR_BTC}/certfile.crt"
SSL_KEYFILE = f"{ELECTRUMX_DATADIR_BTC}/keyfile.key"
if os.path.isdir(ELECTRUMX_DATADIR_BTC):
if RESET_TEST:
logger.info("Removing " + ELECTRUMX_DATADIR_BTC)
shutil.rmtree(ELECTRUMX_DATADIR_BTC)
if not os.path.exists(ELECTRUMX_DATADIR_BTC):
os.makedirs(os.path.join(ELECTRUMX_DATADIR_BTC, "db"))
with open(os.path.join(ELECTRUMX_DATADIR_BTC, "banner"), "w") as fp:
fp.write("TEST BANNER")
try:
stdout = subprocess.check_output(
[
"openssl",
"req",
"-nodes",
"-new",
"-x509",
"-keyout",
SSL_KEYFILE,
"-out",
SSL_CERTFILE,
"-subj",
'/C=CA/ST=Quebec/L=Montreal/O="Poutine LLC"/OU=devops/CN=*.poutine.net\n',
],
text=True,
)
logger.info(f"openssl {stdout}")
except subprocess.CalledProcessError as e:
logger.info(f"Error openssl {e.output}")
electrumx_env = {
"COIN": "Bitcoin",
"NET": "regtest",
"LOG_LEVEL": "debug",
"SERVICES": "tcp://:50001,ssl://:50002,rpc://",
"CACHE_MB": "400",
"DAEMON_URL": f"http://test_btc_0:test_btc_pwd_0@127.0.0.1:{BTC_BASE_RPC_PORT}",
"DB_DIRECTORY": f"{ELECTRUMX_DATADIR_BTC}/db",
"SSL_CERTFILE": f"{ELECTRUMX_DATADIR_BTC}/certfile.crt",
"SSL_KEYFILE": f"{ELECTRUMX_DATADIR_BTC}/keyfile.key",
"BANNER_FILE": f"{ELECTRUMX_DATADIR_BTC}/banner",
"DAEMON_POLL_INTERVAL_BLOCKS": "1000",
"DAEMON_POLL_INTERVAL_MEMPOOL": "1000",
}
opened_files = []
stdout_dest = open(f"{ELECTRUMX_DATADIR_BTC}/electrumx.log", "w")
stderr_dest = stdout_dest
cls.daemons.append(
Daemon(
subprocess.Popen(
[
os.path.join(ELECTRUMX_VENV, "bin", "python"),
os.path.join(ELECTRUMX_SRC_DIR, "electrumx_server"),
],
shell=False,
stdin=subprocess.PIPE,
stdout=stdout_dest,
stderr=stderr_dest,
cwd=ELECTRUMX_SRC_DIR,
env=electrumx_env,
),
[
opened_files,
],
"electrumx_btc",
)
)
@classmethod
def modifyConfig(cls, test_path, i):
modifyConfig(test_path, i)
@classmethod
def setupNodes(cls):
logger.info(f"Preparing {NUM_NODES} nodes.")
bins_path = os.path.join(TEST_PATH, "bin")
for i in range(NUM_NODES):
logger.info(f"Preparing node: {i}.")
client_path = os.path.join(TEST_PATH, f"client{i}")
try:
shutil.rmtree(client_path)
except Exception as ex:
logger.warning(f"setupNodes {ex}")
extra_args = []
if i == 1:
extra_args = [
"--btc-mode=electrum",
"--btc-electrum-server=127.0.0.1:50001",
]
run_prepare(
i,
client_path,
bins_path,
cls.test_coins_list,
mnemonics[i] if i < len(mnemonics) else None,
num_nodes=NUM_NODES,
use_rpcauth=True,
extra_settings={"min_sequence_lock_seconds": 60},
port_ofs=PORT_OFS,
extra_args=extra_args,
)
@classmethod
def tearDownClass(cls):
logger.info("Finalising Test")
super().tearDownClass()
stopDaemons(cls.daemons)
def test_electrum(self):
port_node_from: int = 12701
port_node_to: int = 12702
prepare_balance(self.delay_event, Coins.BTC, 100, 12702, 12701, True)
amt_from_str = f"{random.uniform(0.5, 10.0):.{8}f}"
amt_to_str = f"{random.uniform(0.5, 10.0):.{8}f}"
data = {
"addr_from": "-1",
"coin_from": "part",
"coin_to": "btc",
"amt_from": amt_from_str,
"amt_to": amt_to_str,
"lockhrs": "24",
"swap_type": "adaptor_sig",
}
logger.info(f"Creating offer {amt_from_str} PART -> {amt_to_str} BTC")
offer_id: str = post_json_api(port_node_from, "offers/new", data)["offer_id"]
summary = read_json_api(port_node_from)
assert summary["num_sent_offers"] == 1
logger.info(f"Waiting for offer: {offer_id}")
waitForNumOffers(self.delay_event, port_node_to, 1)
offers = read_json_api(port_node_to, "offers")
offer = offers[0]
data = {
"offer_id": offer["offer_id"],
"amount_from": offer["amount_from"],
"validmins": 60,
}
post_json_api(port_node_to, "bids/new", data)
waitForNumBids(self.delay_event, port_node_from, 1)
for i in range(20):
bids = read_json_api(port_node_from, "bids")
bid = bids[0]
if bid["bid_state"] == "Received":
break
self.delay_event.wait(1)
assert bid["bid_state"] == "Received"
data = {"accept": True}
rv = post_json_api(12701, "bids/{}".format(bid["bid_id"]), data)
assert rv["bid_state"] == "Accepted"
logger.info("Completing swap")
for i in range(240):
if self.delay_event.is_set():
raise ValueError("Test stopped.")
self.delay_event.wait(4)
rv = read_json_api(12701, "bids/{}".format(bid["bid_id"]))
if rv["bid_state"] == "Completed":
break
assert rv["bid_state"] == "Completed"
# Wait for bid to be removed from in-progress
waitForNumBids(self.delay_event, 12701, 0)
if __name__ == "__main__":
unittest.main()

View File

@@ -60,6 +60,7 @@ from tests.basicswap.util import (
from tests.basicswap.common import ( from tests.basicswap.common import (
callrpc_cli, callrpc_cli,
prepareDataDir, prepareDataDir,
prepare_balance,
make_rpc_func, make_rpc_func,
checkForks, checkForks,
stopDaemons, stopDaemons,
@@ -1055,54 +1056,13 @@ class BaseTest(unittest.TestCase):
port_take_from_node: int, port_take_from_node: int,
test_balance: bool = True, test_balance: bool = True,
) -> None: ) -> None:
delay_iterations = 100 if coin == Coins.NAV else 20 prepare_balance(
delay_time = 5 if coin == Coins.NAV else 3
if coin == Coins.PART_BLIND:
coin_ticker: str = "PART"
balance_type: str = "blind_balance"
address_type: str = "stealth_address"
type_to: str = "blind"
elif coin == Coins.PART_ANON:
coin_ticker: str = "PART"
balance_type: str = "anon_balance"
address_type: str = "stealth_address"
type_to: str = "anon"
else:
coin_ticker: str = coin.name
balance_type: str = "balance"
address_type: str = "deposit_address"
js_w = read_json_api(port_target_node, "wallets")
current_balance: float = float(js_w[coin_ticker][balance_type])
if test_balance and current_balance >= amount:
return
post_json = {
"value": amount,
"address": js_w[coin_ticker][address_type],
"subfee": False,
}
if coin in (Coins.XMR, Coins.WOW):
post_json["sweepall"] = False
if coin in (Coins.PART_BLIND, Coins.PART_ANON):
post_json["type_to"] = type_to
json_rv = read_json_api(
port_take_from_node,
"wallets/{}/withdraw".format(coin_ticker.lower()),
post_json,
)
assert len(json_rv["txid"]) == 64
wait_for_amount: float = amount
if not test_balance:
wait_for_amount += current_balance
wait_for_balance(
test_delay_event, test_delay_event,
"http://127.0.0.1:{}/json/wallets/{}".format( coin,
port_target_node, coin_ticker.lower() amount,
), port_target_node,
balance_type, port_take_from_node,
wait_for_amount, test_balance,
iterations=delay_iterations,
delay_time=delay_time,
) )