oxen-core/tests/network_tests/service_node_network.py

412 lines
16 KiB
Python

# 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)