# Provides a pytest fixture of a configured service node network with 20 service nodes, 3 regular # nodes, and 3 wallets (each connected to a different node). # # The 20 service nodes are registered, have mined enough to make the blink quorum active, and have # sent uptime proofs to each other. # # The 3 nodes are ordinary, non-service node nodes. # # The 3 wallets are named alice, bob, and mike. # - "mike the miner" is the miner and operator of the service nodes and so has an endlessly # increasing supply of coins as blocks are mined. # - alice and bob will have any existing funds transferred to mike but may still have tx history of # previous tests. (The wallet-emptying sweep to mike, however, may not yet be confirmed). # # A fourth malicious wallet is available by importing the fixture `chuck`, which generates new # wallets and nodes each time (see the fixture for details). from daemons import Daemon, Wallet import random import time from datetime import datetime 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 * 1000000000) def wait_for(callback, timeout=10): expires = time.time() + timeout while True: try: if callback(): return except: pass if time.time() >= expires: raise RuntimeError("task timeout expired") time.sleep(.25) verbose = False def vprint(*args, timestamp=True, **kwargs): global verbose if verbose: if timestamp: print(datetime.now(), end=" ") print(*args, **kwargs) class SNNetwork: def __init__(self, datadir, *, binpath='../../build/bin', sns=20, nodes=3): self.datadir = datadir self.binpath = binpath vprint("Using '{}' for data files and logs".format(datadir)) nodeopts = dict(oxend=self.binpath+'/oxend', datadir=datadir) self.sns = [Daemon(service_node=True, **nodeopts) for _ in range(sns)] self.nodes = [Daemon(**nodeopts) for _ in range(nodes)] self.all_nodes = self.sns + self.nodes self.wallets = [] for name in ('Alice', 'Bob', 'Mike'): self.wallets.append(Wallet( node=self.nodes[len(self.wallets) % len(self.nodes)], name=name, rpc_wallet=self.binpath+'/oxen-wallet-rpc', datadir=datadir)) self.alice, self.bob, self.mike = self.wallets # Interconnections for i in range(len(self.all_nodes)): for j in (2, 3, 5, 7, 11): k = (i + j) % len(self.all_nodes) if i != k: self.all_nodes[i].add_peer(self.all_nodes[k]) vprint("Starting new oxend service nodes with RPC on {} ports".format(self.sns[0].listen_ip), end="") for sn in self.sns: vprint(" {}".format(sn.rpc_port), end="", flush=True, timestamp=False) sn.start() vprint(timestamp=False) vprint("Starting new regular oxend nodes with RPC on {} ports".format(self.nodes[0].listen_ip), end="") for d in self.nodes: vprint(" {}".format(d.rpc_port), end="", flush=True, timestamp=False) d.start() vprint(timestamp=False) vprint("Waiting for all oxend's to get ready") for d in self.all_nodes: d.wait_for_json_rpc("get_info") vprint("Oxends are ready. Starting wallets") for w in self.wallets: vprint("Starting new RPC wallet {w.name} at {w.listen_ip}:{w.rpc_port}".format(w=w)) w.start() for w in self.wallets: w.ready() w.refresh() vprint("Wallet {w.name} is ready: {a}".format(w=w, a=w.address())) for w in self.wallets: w.wait_for_json_rpc("refresh") # Mine some blocks; we need 100 per SN registration, and we can nearly 600 on fakenet before # it hits HF16 and kills mining rewards. This lets us submit the first 5 SN registrations a # SN (at height 40, which is the earliest we can submit them without getting an occasional # spurious "Not enough outputs to use" error). # to unlock and the rest to have enough unlocked outputs for mixins), then more some more to # earn SN rewards. We need 100 per SN registration, and each mined block gives us an input # of 18.9, which means each registration requires 6 inputs. Thus we need a bare minimum of # 6(N-5) blocks, plus the 30 lock time on coinbase TXes = 6N more blocks (after the initial # 5 registrations). self.mine(50) vprint("Submitting first round of service node registrations: ", end="", flush=True) for sn in self.sns[0:5]: self.mike.register_sn(sn) vprint(".", end="", flush=True, timestamp=False) vprint(timestamp=False) if len(self.sns) > 5: vprint("Going back to mining", flush=True) self.mine(6*len(self.sns)) vprint("Submitting more service node registrations: ", end="", flush=True) for sn in self.sns[5:]: self.mike.register_sn(sn) vprint(".", end="", flush=True, timestamp=False) vprint(timestamp=False) vprint("Done.") self.print_wallet_balances() vprint("Mining 40 blocks (registrations + blink quorum lag) and waiting for nodes to sync") self.sync_nodes(self.mine(40)) self.print_wallet_balances() vprint("Sending fake lokinet/ss pings") for sn in self.sns: sn.ping() all_service_nodes_proofed = lambda sn: all(x['quorumnet_port'] > 0 for x in sn.json_rpc("get_n_service_nodes", {"fields":{"quorumnet_port":True}}).json()['result']['service_node_states']) vprint("Waiting for proofs to propagate: ", end="", flush=True) for sn in self.sns: wait_for(lambda: all_service_nodes_proofed(sn), timeout=120) vprint(".", end="", flush=True, timestamp=False) vprint(timestamp=False) vprint("Done.") vprint("Fake SN network setup complete!") def refresh_wallets(self, *, extra=[]): vprint("Refreshing wallets") for w in self.wallets + extra: w.refresh() vprint("All wallets refreshed") def mine(self, blocks=None, wallet=None, *, sync=False): """Mine some blocks to the given wallet (or self.mike if None) on the wallet's daemon. Returns the daemon's height after mining the blocks. If blocks is omitted, mines enough to confirm regular transfers (i.e. 10 blocks). If sync is specified, sync all nodes and then refresh all wallets after mining.""" if wallet is None: wallet = self.mike if blocks is None: blocks = 10 node = wallet.node vprint("Mining {} blocks to wallet {.name}".format(blocks, wallet)) start_height = node.height() end_height = start_height + blocks node.mine_blocks(blocks, wallet) while node.rpc("/mining_status").json()["active"]: height = node.height() vprint("Mined {}/{}".format(height, end_height)) time.sleep(0.05 if height >= end_height else 0.25) height = node.height() vprint("Mined {}/{}".format(height, end_height)) if sync: self.sync_nodes(height) self.refresh_wallets() return height def sync_nodes(self, height=None, *, extra=[], timeout=10): """Waits for all nodes to reach the given height, typically invoked after mine()""" nodes = self.all_nodes + extra heights = [x.height() for x in nodes] if height is None: height = max(heights) if min(heights) >= height: vprint("All nodes already synced to height >= {}".format(height)) return vprint("Waiting for all nodes to sync to height {}".format(height)) last = None expiry = time.time() + timeout while nodes and time.time() < expiry: if heights[-1] < height: heights[-1] = nodes[-1].height() if heights[-1] >= height: heights.pop() nodes.pop() last = None continue if heights[-1] != last: vprint("waiting for {} [{} -> {}]".format(nodes[-1].name, heights[-1], height)) last = heights[-1] time.sleep(0.1) if nodes: raise RuntimeError("Timed out waiting for node syncing") vprint("All nodes synced to height {}".format(height)) def sync(self, extra_nodes=[], extra_wallets=[]): """Synchronizes everything: waits for all nodes to sync, then refreshes all wallets. Can be given external wallets/nodes to sync.""" self.sync_nodes(extra=extra_nodes) self.refresh_wallets(extra=extra_wallets) def print_wallet_balances(self): """Instructs the wallets to refresh and prints their balances (does nothing in non-verbose mode)""" global verbose if not verbose: return vprint("Balances:") for w in self.wallets: b = w.balances(refresh=True) vprint(" {:5s}: {:.9f} (total) with {:.9f} (unlocked)".format( w.name, b[0] * 1e-9, b[1] * 1e-9)) def __del__(self): for n in self.all_nodes: n.terminate() for w in self.wallets: w.terminate() snn = None @pytest.fixture def net(pytestconfig, tmp_path, binary_dir): """Fixture that returns the service node network. It is persistent across tests: the first time it loads it starts the daemons and wallets, mines a bunch of blocks and submits SN registrations. On subsequent loads it mines 5 blocks so that mike always has some available funds, and sets alice and bob to new wallets.""" global snn, verbose if not snn: verbose = pytestconfig.getoption('verbose') >= 2 if verbose: print("\nConstructing initial service node network") snn = SNNetwork(datadir=tmp_path, binpath=binary_dir) else: snn.alice.new_wallet() snn.bob.new_wallet() # Flush pools because some tests leave behind impossible txes for n in snn.all_nodes: assert n.json_rpc("flush_txpool").json()['result']['status'] == 'OK' # Mine a few to clear out anything in the mempool that can be cleared snn.mine(5, sync=True) vprint("Alice has new wallet: {}".format(snn.alice.address())) vprint("Bob has new wallet: {}".format(snn.bob.address())) return snn # Shortcuts for accessing the named wallets @pytest.fixture def alice(net): return net.alice @pytest.fixture def bob(net): return net.bob @pytest.fixture def mike(net): return net.mike @pytest.fixture def chuck(net): """ `chuck` is the wallet of a potential attacker, with some extra add-ons. The main `chuck` wallet is connected to one of the three network nodes (like alice or bob), and starts out empty. Chuck also has a second copy of the same wallet, `chuck.hidden`, which is connected to his own private node, `chuck.hidden.node`. This node is connected to the network exclusively through a second node that Chuck runs, `chuck.bridge`. This allows chuck to disconnect from the network by stopping the bridge node and reconnect by restarting it. Note that the bridge and hidden nodes will not have received proofs (and so can't be used to submit blinks). """ chuck = Wallet(node=net.nodes[0], name='Chuck', rpc_wallet=net.binpath+'/oxen-wallet-rpc', datadir=net.datadir) chuck.ready(wallet="chuck") hidden_node = Daemon(oxend=net.binpath+'/oxend', datadir=net.datadir) bridge_node = Daemon(oxend=net.binpath+'/oxend', datadir=net.datadir) for x in (4, 7): bridge_node.add_peer(net.all_nodes[x]) bridge_node.add_peer(hidden_node) hidden_node.add_peer(bridge_node) vprint("Starting new chuck oxend bridge node with RPC on {}:{}".format(bridge_node.listen_ip, bridge_node.rpc_port)) bridge_node.start() bridge_node.wait_for_json_rpc("get_info") net.sync(extra_nodes=[bridge_node], extra_wallets=[chuck]) vprint("Starting new chuck oxend hidden node with RPC on {}:{}".format(hidden_node.listen_ip, hidden_node.rpc_port)) hidden_node.start() hidden_node.wait_for_json_rpc("get_info") net.sync(extra_nodes=[hidden_node, bridge_node], extra_wallets=[chuck]) vprint("Done syncing chuck nodes") # RPC wallet doesn't provide a way to import from a key or mnemonic, so we have to stop the rpc # wallet then copy the underlying wallet file. chuck.refresh() chuck.stop() chuck.hidden = Wallet(node=hidden_node, name='Chuck (hidden)', rpc_wallet=net.binpath+'/oxen-wallet-rpc', datadir=net.datadir) import shutil import os wallet_base = chuck.walletdir + '/chuck' assert os.path.exists(wallet_base) assert os.path.exists(wallet_base + '.keys') os.makedirs(chuck.hidden.walletdir, exist_ok=True) shutil.copy(wallet_base, chuck.hidden.walletdir + '/chuck2') shutil.copy(wallet_base + '.keys', chuck.hidden.walletdir + '/chuck2.keys') # Restart the regular wallet and the newly copied hidden wallet chuck.ready(wallet="chuck", existing=True) chuck.hidden.ready(wallet="chuck2", existing=True) chuck.refresh() chuck.hidden.refresh() assert chuck.address() == chuck.hidden.address() chuck.bridge = bridge_node return chuck @pytest.fixture def chuck_double_spend(net, alice, mike, chuck): """ Importing this fixture (along with `chuck` itself!) extends the chuck setup to transfer 100 coins to chuck, mine them to confirmation, then stop his bridge node to double-spend those funds. This consists of a blink tx of 95 (sent to alice) on the connected network and a conflicting regular tx (sent to himself) submitted to the mempool of his local hidden (and now disconnected) node. The fixture value is a tuple of the submitted tx details as returned by the rpc wallet, `(blinked_tx, hidden_tx)`. """ assert(chuck.balances() == (0, 0)) mike.transfer(chuck, coins(100)) net.mine() net.sync(extra_nodes=[chuck.bridge, chuck.hidden.node], extra_wallets=[chuck, chuck.hidden]) assert chuck.balances() == coins(100, 100) assert chuck.hidden.balances() == coins(100, 100) # Now we disconnect chuck's bridge node, which will isolate the hidden node. chuck.bridge.stop() tx_blink = chuck.transfer(alice, coins(95), priority=5) assert len(tx_blink['tx_hash_list']) == 1 blink_hash = tx_blink['tx_hash_list'][0] time.sleep(0.5) # allow blink to propagate # ... but it shouldn't have propagated here because this is disconnected, so we can submit a # conflicting tx: tx_hidden = chuck.hidden.transfer(chuck, coins(95), priority=1) assert len(tx_hidden['tx_hash_list']) == 1 hidden_hash = tx_hidden['tx_hash_list'][0] assert hidden_hash != blink_hash vprint("double-spend txs: blink: {}, hidden: {}".format(blink_hash, hidden_hash)) net.sync() alice.refresh() assert alice.balances() == coins(95, 0) mike_txpool = [x['id_hash'] for x in mike.node.rpc("/get_transaction_pool").json()['transactions']] assert mike_txpool == [blink_hash] hidden_txpool = [x['id_hash'] for x in chuck.hidden.node.rpc("/get_transaction_pool").json()['transactions']] assert hidden_txpool == [hidden_hash] return (tx_blink, tx_hidden)