diff --git a/tests/ledger/conftest.py b/tests/ledger/conftest.py index 14540355f..ae5ca110b 100644 --- a/tests/ledger/conftest.py +++ b/tests/ledger/conftest.py @@ -65,3 +65,8 @@ def alice(net): @pytest.fixture def bob(net): return net.bob + +# Gives you an (unstaked) sn +@pytest.fixture +def sn(net): + return net.unstaked_sns[0] diff --git a/tests/ledger/test_ledger.py b/tests/ledger/test_ledger.py index 6b8c675ae..513fb390d 100644 --- a/tests/ledger/test_ledger.py +++ b/tests/ledger/test_ledger.py @@ -1,6 +1,7 @@ import pytest import time import re +from functools import partial from service_node_network import coins, vprint from ledgerapi import LedgerAPI @@ -61,7 +62,7 @@ def test_send(net, mike, alice, hal, ledger): run_with_interactions( ledger, - lambda: hal.transfer(alice, coins(42.5)), + partial(hal.transfer, alice, coins(42.5)), ExactScreen(["Processing TX"]), MatchScreen([r"^Confirm Fee$", r"^(0.01\d{1,7})$"], store_fee, fail_index=1), Do.right, @@ -83,6 +84,7 @@ def test_send(net, mike, alice, hal, ledger): Do.left, ExactScreen(["Accept"]), Do.both, + ExactScreen(["Processing TX"]), ) net.mine(1) @@ -129,7 +131,7 @@ def test_multisend(net, mike, alice, bob, hal, ledger): hal.timeout = 120 # creating this tx with the ledger takes ages run_with_interactions( ledger, - lambda: hal.multi_transfer((alice, bob, alice, alice, hal), coins(18, 19, 20, 21, 22)), + partial(hal.multi_transfer, (alice, bob, alice, alice, hal), coins(18, 19, 20, 21, 22)), ExactScreen(["Processing TX"]), MatchScreen([r"^Confirm Fee$", r"^(0.\d{1,9})$"], store_fee, fail_index=1), Do.right, @@ -165,6 +167,7 @@ def test_multisend(net, mike, alice, bob, hal, ledger): Do.right, ExactScreen(["Accept"]), Do.both, + ExactScreen(["Processing TX"]), timeout=120, ) @@ -183,6 +186,130 @@ def test_multisend(net, mike, alice, bob, hal, ledger): assert alice.balances(refresh=True) == coins(18 + 20 + 21, 0) assert bob.balances(refresh=True) == coins(19, 0) net.mine(9) - assert hal.balances(refresh=True) == tuple([remaining] * 2) - assert alice.balances(refresh=True) == tuple(coins([18 + 20 + 21] * 2)) + assert hal.balances(refresh=True) == (remaining,) * 2 + assert alice.balances(refresh=True) == coins((18 + 20 + 21,) * 2) assert bob.balances(refresh=True) == coins(19, 19) + + +def check_sn_rewards(net, hal, sn, starting_bal, reward): + net.mine(5) # 5 blocks until it starts earning rewards (testnet/fakenet) + + hal_bal = hal.balances(refresh=True) + + batch_offset = None + assert hal_bal == coins(starting_bal, 0) + # We don't know where our batch payment occurs yet, but let's look for it: + for i in range(20): + net.mine(1) + if hal.balances(refresh=True)[0] > coins(starting_bal): + batch_offset = sn.height() % 20 + break + + assert batch_offset is not None + + hal_bal = hal.balances() + + net.mine(19) + assert hal.balances(refresh=True)[0] == hal_bal[0] + net.mine(1) # Should be our batch height + assert hal.balances(refresh=True)[0] == hal_bal[0] + coins(20 * reward) + + +def test_sn_register(net, mike, hal, ledger, sn): + mike.transfer(hal, coins(101)) + net.mine() + + assert hal.balances(refresh=True) == coins(101, 101) + + fee = None + + def store_fee(_, m): + nonlocal fee + fee = float(m[1][1]) + + run_with_interactions( + ledger, + partial(hal.register_sn, sn), + ExactScreen(["Processing Stake"]), + MatchScreen([r"^Confirm Fee$", r"^(0.01\d{1,7})$"], store_fee, fail_index=1), + Do.right, + ExactScreen(["Accept"]), + Do.both, + ExactScreen(["Confirm Stake", "100.0"], fail_index=1), + Do.right, + ExactScreen(["Accept"]), + Do.right, + ExactScreen(["Reject"]), + Do.left, + Do.both, + ExactScreen(["Processing Stake"]), + ) + + # We are half the SN network, so get half of the block reward per block: + reward = 0.5 * 16.5 + check_sn_rewards(net, hal, sn, 101 - fee, reward) + + +def test_sn_stake(net, mike, alice, hal, ledger, sn): + mike.multi_transfer([hal, alice], coins(13.02, 87.02)) + net.mine() + + assert hal.balances(refresh=True) == coins(13.02, 13.02) + assert alice.balances(refresh=True) == coins(87.02, 87.02) + + alice.register_sn(sn, stake=coins(87)) + net.mine(1) + + fee = None + + def store_fee(_, m): + nonlocal fee + fee = float(m[1][1]) + + run_with_interactions( + ledger, + partial(hal.stake_sn, sn, coins(13)), + ExactScreen(["Processing Stake"]), + MatchScreen([r"^Confirm Fee$", r"^(0.01\d{1,7})$"], store_fee, fail_index=1), + Do.right, + ExactScreen(["Accept"]), + Do.both, + ExactScreen(["Confirm Stake", "13.0"], fail_index=1), + Do.right, + ExactScreen(["Accept"]), + Do.right, + ExactScreen(["Reject"]), + Do.left, + Do.both, + ExactScreen(["Processing Stake"]), + ) + + # Our SN is 1 or 2 registered, so we get 50% of the 16.5 reward, 10% is removed for operator + # fee, then hal gets 13/100 of the rest: + reward = 0.5 * 16.5 * 0.9 * 0.13 + + check_sn_rewards(net, hal, sn, 13 - fee, reward) + + +def test_sn_unstake(net, mike, hal, ledger, sn): + # Do the full registration: + test_sn_register(net, mike, hal, ledger, sn) + + run_with_interactions( + ledger, + partial(hal.unstake_sn, sn), + ExactScreen(["Confirm Service", "Node Unlock"]), + Do.right, + ExactScreen(["Accept"]), + Do.right, + ExactScreen(["Reject"]), + Do.left, + Do.both, + ) + # A fakechain unlock takes 30 blocks, plus add another 20 just so we are sure we've received the + # last batch reward: + net.mine(30 + 20) + + hal_bal = hal.balances(refresh=True) + net.mine(20) + assert hal.balances(refresh=True) == hal_bal diff --git a/tests/network_tests/daemons.py b/tests/network_tests/daemons.py index 27931ef0b..9980b0b47 100644 --- a/tests/network_tests/daemons.py +++ b/tests/network_tests/daemons.py @@ -6,6 +6,16 @@ import requests import subprocess import time + +def coins(*args): + if len(args) != 1: + return tuple(coins(x) for x in args) + x = args[0] + if type(x) in (tuple, list): + return type(x)(coins(i) for i in x) + return round(x * 1_000_000_000) + + # 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. @@ -208,6 +218,9 @@ class Daemon(RPCDaemon): {"miner_address": a, "threads_count": 1, "num_blocks": num_blocks, "slow_mining": slow}, ) + def sn_pubkey(self): + return self.json_rpc("get_service_keys").json()["result"]["service_node_pubkey"] + def height(self): return self.rpc("/get_height").json()["height"] @@ -414,20 +427,39 @@ class Wallet(RPCDaemon): return [find_tx(txid) for txid in txids] - def register_sn(self, sn): + def register_sn(self, sn, stake=coins(100), fee=10): r = sn.json_rpc( "get_service_node_registration_cmd", { - "operator_cut": "100", - "contributions": [{"address": self.address(), "amount": 100000000000}], - "staking_requirement": 100000000000, + "operator_cut": "100" if stake == coins(100) else f"{fee}", + "contributions": [{"address": self.address(), "amount": stake}], + "staking_requirement": coins(100), }, ).json() if "error" in r: raise RuntimeError(f"Registration cmd generation failed: {r['error']['message']}") cmd = r["result"]["registration_cmd"] + if cmd == "": + # everything about this command is dumb, include its error handling + raise RuntimeError(f"Registration cmd generation failed: {r['result']['status']}") + 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"]) ) + + def stake_sn(self, sn, stake): + r = self.json_rpc( + "stake", + {"destination": self.address(), "amount": stake, "service_node_key": sn.sn_pubkey()}, + ).json() + if "error" in r: + raise RuntimeError(f"Failed to submit stake: {r['error']['message']}") + + def unstake_sn(self, sn): + r = self.json_rpc("request_stake_unlock", {"service_node_key": sn.sn_pubkey()}).json() + if "error" in r: + raise RuntimeError(f"Failed to submit unstake: {r['error']['message']}") + if not r["result"]["unlocked"]: + raise RuntimeError(f"Failed to submit unstake: {r['result']['msg']}") diff --git a/tests/network_tests/service_node_network.py b/tests/network_tests/service_node_network.py index ce9c20249..fedbb480f 100644 --- a/tests/network_tests/service_node_network.py +++ b/tests/network_tests/service_node_network.py @@ -16,7 +16,7 @@ # wallets and nodes each time (see the fixture for details). -from daemons import Daemon, Wallet +from daemons import Daemon, Wallet, coins import vprint as v from vprint import vprint import random @@ -26,15 +26,6 @@ import uuid import pytest -def coins(*args): - if len(args) != 1: - return tuple(coins(x) for x in args) - x = args[0] - if type(x) in (tuple, list): - return type(x)(coins(i) for i in x) - return round(x * 1_000_000_000) - - def wait_for(callback, timeout=10): expires = time.time() + timeout while True: @@ -49,7 +40,7 @@ def wait_for(callback, timeout=10): class SNNetwork: - def __init__(self, datadir, *, binpath, sns=20, nodes=3): + def __init__(self, datadir, *, binpath, sns=20, nodes=3, unstaked_sns=0): self.datadir = datadir self.binpath = binpath @@ -58,9 +49,10 @@ class SNNetwork: nodeopts = dict(oxend=self.binpath + "/oxend", datadir=datadir) self.sns = [Daemon(service_node=True, **nodeopts) for _ in range(sns)] + self.unstaked_sns = [Daemon(service_node=True, **nodeopts) for _ in range(unstaked_sns)] self.nodes = [Daemon(**nodeopts) for _ in range(nodes)] - self.all_nodes = self.sns + self.nodes + self.all_nodes = self.sns + self.unstaked_sns + self.nodes self.wallets = [] for name in ("Alice", "Bob", "Mike"): @@ -86,7 +78,7 @@ class SNNetwork: "Starting new oxend service nodes with RPC on {} ports".format(self.sns[0].listen_ip), end="", ) - for sn in self.sns: + for sn in self.sns + self.unstaked_sns: vprint(" {}".format(sn.rpc_port), end="", flush=True, timestamp=False) sn.start() vprint(timestamp=False) @@ -308,7 +300,7 @@ def basic_net(pytestconfig, tmp_path, binary_dir): v.verbose = pytestconfig.getoption("verbose") >= 2 if v.verbose: print("\nConstructing initial node network") - snn = SNNetwork(datadir=tmp_path, binpath=binary_dir, sns=1, nodes=1) + snn = SNNetwork(datadir=tmp_path, binpath=binary_dir, sns=1, nodes=1, unstaked_sns=1) else: snn.alice.new_wallet() snn.bob.new_wallet()