oxen-core/tests/network_tests/daemons.py

356 lines
13 KiB
Python

#!/usr/bin/python3
import sys
import random
import requests
import subprocess
import time
# On linux we can pick a random 127.x.y.z IP which is highly likely to not have anything listening
# on it (so we make bind conflicts highly unlikely). On most other OSes we have to listen on
# 127.0.0.1 instead, so we pick a random starting port instead to try to minimize bind conflicts.
LISTEN_IP, NEXT_PORT = (
('127.' + '.'.join(str(random.randint(1, 254)) for _ in range(3)), 1100)
if sys.platform == 'linux' else
('127.0.0.1', random.randint(5000, 20000)))
def next_port():
global NEXT_PORT
port = NEXT_PORT
NEXT_PORT += 1
return port
class ProcessExited(RuntimeError):
pass
class TransferFailed(RuntimeError):
def __init__(self, message, json):
super().__init__(message)
self.message = message
self.json = json
class RPCDaemon:
def __init__(self, name):
self.name = name
self.proc = None
self.terminated = False
def __del__(self):
self.stop()
def terminate(self, repeat=False):
"""Sends a TERM signal if one hasn't already been sent (or even if it has, with
repeat=True). Does not wait for exit."""
if self.proc and (repeat or not self.terminated):
self.proc.terminate()
self.terminated = True
def start(self):
if self.proc and self.proc.poll() is None:
raise RuntimeError("Cannot start process that is already running!")
self.proc = subprocess.Popen(self.arguments(),
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
self.terminated = False
def stop(self):
"""Tries stopping with a term at first, then a kill if the term hasn't worked after 10s"""
if self.proc:
self.terminate()
try:
self.proc.wait(timeout=10)
except subprocess.TimeoutExpired:
print("{} took more than 10s to exit, killing it".format(self.name))
self.proc.kill()
self.proc = None
def arguments(self):
"""Returns the startup arguments; default is just self.args, but subclasses can override."""
return self.args
def json_rpc(self, method, params=None, *, timeout=10):
"""Sends a json_rpc request to the rpc port. Returns the response object."""
if not self.proc:
raise RuntimeError("Cannot make rpc request before calling start()")
json = {
"jsonrpc": "2.0",
"id": "0",
"method": method,
}
if params:
json["params"] = params
return requests.post('http://{}:{}/json_rpc'.format(self.listen_ip, self.rpc_port), json=json, timeout=timeout)
def rpc(self, path, params=None, *, timeout=10):
"""Sends a non-json_rpc rpc request to the rpc port at path `path`, e.g. /get_info. Returns the response object."""
if not self.proc:
raise RuntimeError("Cannot make rpc request before calling start()")
return requests.post('http://{}:{}{}'.format(self.listen_ip, self.rpc_port, path), json=params, timeout=timeout)
def wait_for_json_rpc(self, method, params=None, *, timeout=10):
"""Calls `json_rpc', sleeping if it fails for up time `timeout' seconds. Returns the
response if it succeeds, raises the last exception if timeout is reached. If the process
exit, raises a RuntimeError"""
until = time.time() + timeout
now = time.time()
while now < until:
exit_status = self.proc.poll()
if exit_status is not None:
raise ProcessExited("{} exited ({}) while waiting for an RPC response".format(self.name, exit_status))
timeout = until - now
try:
return self.json_rpc(method, params, timeout=timeout)
except:
if time.time() + .25 >= until:
raise
time.sleep(.25)
now = time.time()
if now >= until:
raise
class Daemon(RPCDaemon):
base_args = ('--dev-allow-local-ips', '--fixed-difficulty=1', '--regtest', '--non-interactive', '--rpc-ssl=disabled', '--rpc-long-poll-connections=0')
def __init__(self, *,
oxend='oxend',
listen_ip=None, p2p_port=None, rpc_port=None, zmq_port=None, qnet_port=None, ss_port=None,
name=None,
datadir=None,
service_node=False,
log_level=2,
peers=()):
self.rpc_port = rpc_port or next_port()
if name is None:
name = 'oxend@{}'.format(self.rpc_port)
super().__init__(name)
self.listen_ip = listen_ip or LISTEN_IP
self.p2p_port = p2p_port or next_port()
self.zmq_port = zmq_port or next_port()
self.qnet_port = qnet_port or next_port()
self.ss_port = ss_port or next_port()
self.peers = []
self.args = [oxend] + list(self.__class__.base_args)
self.args += (
'--data-dir={}/oxen-{}-{}'.format(datadir or '.', self.listen_ip, self.rpc_port),
'--log-level={}'.format(log_level),
'--log-file=oxen.log'.format(self.listen_ip, self.p2p_port),
'--p2p-bind-ip={}'.format(self.listen_ip),
'--p2p-bind-port={}'.format(self.p2p_port),
'--rpc-bind-ip={}'.format(self.listen_ip),
'--rpc-bind-port={}'.format(self.rpc_port),
'--zmq-rpc-bind-ip={}'.format(self.listen_ip),
'--zmq-rpc-bind-port={}'.format(self.zmq_port),
'--quorumnet-port={}'.format(self.qnet_port),
)
for d in peers:
self.add_peer(d)
if service_node:
self.args += (
'--service-node',
'--service-node-public-ip={}'.format(self.listen_ip),
'--storage-server-port={}'.format(self.ss_port),
)
def arguments(self):
return self.args + [
'--add-exclusive-node={}:{}'.format(node.listen_ip, node.p2p_port) for node in self.peers]
def ready(self):
"""Waits for the daemon to get ready, i.e. for it to start returning something to a
`get_info` rpc request. Calls start() if it hasn't already been called."""
if not self.proc:
self.start()
self.wait_for_json_rpc("get_info")
def add_peer(self, node):
"""Adds a peer. Must be called before starting."""
if self.proc:
raise RuntimeError("add_peer needs to be called before start()")
self.peers.append(node)
def remove_peer(self, node):
"""Removes a peer. Must be called before starting."""
if self.proc:
raise RuntimeError("remove_peer needs to be called before start()")
self.peers.remove(node)
def mine_blocks(self, num_blocks, wallet, *, slow=True):
a = wallet.address()
self.rpc('/start_mining', {
"miner_address": a,
"threads_count": 1,
"num_blocks": num_blocks,
"slow_mining": slow
});
def height(self):
return self.rpc("/get_height").json()["height"]
def txpool_hashes(self):
return [x['id_hash'] for x in self.rpc("/get_transaction_pool").json()['transactions']]
def ping(self, *, storage=True, lokinet=True):
"""Sends fake storage server and lokinet pings to the running oxend"""
if storage:
self.json_rpc("storage_server_ping", { "version_major": 9, "version_minor": 9, "version_patch": 9 })
if lokinet:
self.json_rpc("lokinet_ping", { "version": [9,9,9] })
def p2p_resync(self):
"""Triggers a p2p resync to happen soon (i.e. at the next p2p idle loop)."""
self.json_rpc("test_trigger_p2p_resync")
class Wallet(RPCDaemon):
base_args = ('--disable-rpc-login', '--non-interactive', '--password','', '--regtest', '--disable-rpc-long-poll',
'--rpc-ssl=disabled', '--daemon-ssl=disabled')
def __init__(
self,
node,
*,
rpc_wallet='oxen-wallet-rpc',
name=None,
datadir=None,
listen_ip=None,
rpc_port=None,
log_level=2):
self.listen_ip = listen_ip or LISTEN_IP
self.rpc_port = rpc_port or next_port()
self.node = node
self.name = name or 'wallet@{}'.format(self.rpc_port)
super().__init__(self.name)
self.walletdir = '{}/wallet-{}-{}'.format(datadir or '.', self.listen_ip, self.rpc_port)
self.args = [rpc_wallet] + list(self.__class__.base_args)
self.args += (
'--rpc-bind-ip={}'.format(self.listen_ip),
'--rpc-bind-port={}'.format(self.rpc_port),
'--log-level={}'.format(log_level),
'--log-file={}/log.txt'.format(self.walletdir),
'--daemon-address={}:{}'.format(node.listen_ip, node.rpc_port),
'--wallet-dir={}'.format(self.walletdir),
)
self.wallet_address = None
def ready(self, wallet="wallet", existing=False):
"""Makes the wallet ready, waiting for it to start up and create a new wallet (or load an
existing one, if `existing`) within the rpc wallet. Calls `start()` first if it hasn't
already been called. Does *not* explicitly refresh."""
if not self.proc:
self.start()
self.wallet_filename = wallet
if existing:
r = self.wait_for_json_rpc("open_wallet", {"filename": wallet, "password": ""})
else:
r = self.wait_for_json_rpc("create_wallet", {"filename": wallet, "password": "", "language": "English"})
if 'result' not in r.json():
raise RuntimeError("Cannot open or create wallet: {}".format(r['error'] if 'error' in r else 'Unexpected response: {}'.format(r)))
def refresh(self):
return self.json_rpc('refresh')
def address(self):
if not self.wallet_address:
self.wallet_address = self.json_rpc("get_address").json()["result"]["address"]
return self.wallet_address
def new_wallet(self):
self.wallet_address = None
r = self.wait_for_json_rpc("close_wallet")
if 'result' not in r.json():
raise RuntimeError("Cannot close current wallet: {}".format(r['error'] if 'error' in r else 'Unexpected response: {}'.format(r)))
if not hasattr(self, 'wallet_suffix'):
self.wallet_suffix = 2
else:
self.wallet_suffix += 1
r = self.wait_for_json_rpc("create_wallet", {"filename": "{}_{}".format(self.wallet_filename, self.wallet_suffix), "password": "", "language": "English"})
if 'result' not in r.json():
raise RuntimeError("Cannot create wallet: {}".format(r['error'] if 'error' in r else 'Unexpected response: {}'.format(r)))
def balances(self, refresh=False):
"""Returns (total, unlocked) balances. Can optionally refresh first."""
if refresh:
self.refresh()
b = self.json_rpc("get_balance").json()['result']
return (b['balance'], b['unlocked_balance'])
def transfer(self, to, amount=None, *, priority=None, sweep=False):
"""Attempts a transfer. Throws TransferFailed if it gets rejected by the daemon, otherwise
returns the 'result' key."""
if priority is None:
priority = 1
if sweep and not amount:
r = self.json_rpc("sweep_all", {"address": to.address(), "priority": priority})
elif amount and not sweep:
r = self.json_rpc("transfer_split", {"destinations": [{"address": to.address(), "amount": amount}], "priority": priority})
else:
raise RuntimeError("Wallet.transfer: either `sweep` or `amount` must be given")
r = r.json()
if 'error' in r:
raise TransferFailed("Transfer failed: {}".format(r['error']['message']), r)
return r['result']
def find_transfers(self, txids, in_=True, pool=True, out=True, pending=False, failed=False):
transfers = self.json_rpc('get_transfers', {'in':in_, 'pool':pool, 'out':out, 'pending':pending, 'failed':failed }).json()['result']
def find_tx(txid):
for type_, txs in transfers.items():
for tx in txs:
if tx['txid'] == txid:
return tx
return [find_tx(txid) for txid in txids]
def register_sn(self, sn):
r = sn.json_rpc("get_service_node_registration_cmd", {
"operator_cut": "100",
"contributions": [{"address": self.address(), "amount": 100000000000}],
"staking_requirement": 100000000000
}).json()
if 'error' in r:
raise RuntimeError("Registration cmd generation failed: {}".format(r['error']['message']))
cmd = r['result']['registration_cmd']
r = self.json_rpc("register_service_node", {"register_service_node_str": cmd}).json()
if 'error' in r:
raise RuntimeError("Failed to submit service node registration tx: {}".format(r['error']['message']))