Files
archy/tests/lifecycle/fee-bump-regtest.py
T

187 lines
9.9 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""Exercise the production Bump RPC against disposable Bitcoin/LND regtest wallets."""
import http.cookiejar
import json
import os
from pathlib import Path
import secrets
import subprocess
import time
import urllib.error
import urllib.request
assert os.environ.get('ARCHY_FEE_REGTEST_ISOLATED') == '1'
assert os.getpid() == 1, 'A private PID namespace is required'
assert os.readlink('/proc/self/ns/net') != os.environ['ARCHY_HOST_NET_NS'], 'Host network refused'
BIN = Path('/opt/archy-regtest-bin')
ROOT = Path('/var/lib/archipelago')
RESULTS = Path('/opt/archy-regtest-results')
ROOT.mkdir(parents=True, exist_ok=True)
RESULTS.mkdir(parents=True, exist_ok=True)
procs = []
logs = []
def launch(name, args, env=None):
log = (RESULTS / (name + '.log')).open('ab'); logs.append(log)
proc = subprocess.Popen(args, stdout=log, stderr=subprocess.STDOUT, env=env)
procs.append(proc)
return proc
def wait(check, seconds=90):
until = time.monotonic() + seconds
last = None
while time.monotonic() < until:
try:
result = check()
if result: return result
except Exception as error: last = error
time.sleep(.5)
raise RuntimeError('Regtest readiness timeout: ' + str(last))
def cli(args):
proc = subprocess.run(args, capture_output=True, text=True, timeout=20)
if proc.returncode: raise RuntimeError(proc.stderr.strip()[:300])
try: return json.loads(proc.stdout)
except json.JSONDecodeError: return proc.stdout.strip()
bitcoin_dir = ROOT / 'regtest-bitcoin'
bitcoin_dir.mkdir()
password = secrets.token_hex(24)
conf = bitcoin_dir / 'bitcoin.conf'
conf.write_text('regtest=1\nserver=1\ndbcache=64\nlisten=0\ndiscover=0\ndnsseed=0\nfallbackfee=0.00002\n'
'[regtest]\nrpcbind=127.0.0.1\nrpcallowip=127.0.0.1\nrpcport=8332\n'
'rpcuser=archipelago\nrpcpassword=' + password + '\n'
'zmqpubrawblock=tcp://127.0.0.1:28332\nzmqpubrawtx=tcp://127.0.0.1:28333\n')
conf.chmod(0o600)
secrets_dir = ROOT / 'secrets'; secrets_dir.mkdir()
(secrets_dir / 'bitcoin-rpc-password').write_text(password)
(secrets_dir / 'bitcoin-rpc-password').chmod(0o600)
def btc(method, *args):
return cli([str(BIN/'bitcoin-cli'), '-datadir='+str(bitcoin_dir), method, *[str(a) for a in args]])
def ln(method, *args):
return cli([str(BIN/'lncli'), '--lnddir='+str(ROOT/'lnd'), '--network=regtest', method, *[str(a) for a in args]])
jar = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
def rpc(method, params=None):
headers = {'Content-Type':'application/json'}
csrf = next((c.value for c in jar if c.name == 'csrf_token'), None)
if csrf: headers['X-CSRF-Token'] = csrf
req = urllib.request.Request('http://127.0.0.1:5678/rpc/v1',
data=json.dumps({'jsonrpc':'2.0','id':1,'method':method,'params':params or {}}).encode(), headers=headers)
with opener.open(req, timeout=60) as response: data = json.load(response)
if data.get('error'): raise RuntimeError(data['error'].get('message', 'RPC error'))
return data['result']
try:
launch('bitcoin', [str(BIN/'bitcoind'), '-datadir='+str(bitcoin_dir)])
wait(lambda: btc('getblockchaininfo')['chain'] == 'regtest')
btc('createwallet', 'miner')
miner = btc('getnewaddress')
btc('generatetoaddress', 101, miner)
lnd_dir = ROOT/'lnd'; lnd_dir.mkdir()
lnd_conf = lnd_dir/'lnd.conf'
lnd_conf.write_text('[Application Options]\nnoseedbackup=1\nrestlisten=127.0.0.1:18080\nrpclisten=127.0.0.1:10009\nlisten=127.0.0.1:9735\n'
'[Bitcoin]\nbitcoin.active=1\nbitcoin.regtest=1\nbitcoin.node=bitcoind\n'
'[Bitcoind]\nbitcoind.rpchost=127.0.0.1:8332\nbitcoind.rpcuser=archipelago\nbitcoind.rpcpass='+password+'\n'
'bitcoind.zmqpubrawblock=tcp://127.0.0.1:28332\nbitcoind.zmqpubrawtx=tcp://127.0.0.1:28333\n')
lnd_conf.chmod(0o600)
launch('lnd', [str(BIN/'lnd'), '--lnddir='+str(lnd_dir)])
wait(lambda: ln('getinfo')['synced_to_chain'], 120)
# The production backend uses this canonical pathname. Only the namespace's
# disposable filesystem is changed; the host's real wallet is inaccessible.
(lnd_dir/'data/chain/bitcoin/mainnet').symlink_to('regtest')
address = ln('newaddress','p2wkh')['address']
btc('sendtoaddress', address, '0.01'); btc('generatetoaddress', 6, miner)
wait(lambda: int(ln('walletbalance')['confirmed_balance']) >= 1_000_000)
env = dict(os.environ, ARCHIPELAGO_DATA_DIR=str(ROOT), ARCHIPELAGO_BIND='127.0.0.1:5678',
ARCHIPELAGO_APPS_DIR=str(ROOT/'empty-apps'), ARCHIPELAGO_LOG_LEVEL='warn')
(ROOT/'empty-apps').mkdir()
backend = launch('backend', [str(BIN/'archipelago')], env)
wait(lambda: urllib.request.urlopen('http://127.0.0.1:5678/health',timeout=3).status == 200, 180)
account_password = secrets.token_urlsafe(24)
rpc('auth.setup', {'password':account_password})
rpc('auth.login', {'password':account_password})
assert rpc('system.get-hostname')
recipient = btc('getnewaddress')
sent = ln('sendcoins', '--addr='+recipient, '--amt=100000', '--sat_per_vbyte=1')
txid = sent['txid']
wait(lambda: btc('getmempoolentry', txid))
quote = rpc('lnd.bump-quote', {'txid':txid, 'sat_per_vbyte':5})
assert quote['method'] == 'cpfp' and quote['recipient_sats'] == 100000
assert 0 < quote['budget_sats'] < quote['input_sats']
result = rpc('lnd.bump-submit', {'txid':txid, 'quote_id':quote['quote_id']})
assert result['status'] in ['registered','mempool']
status = wait(lambda: (s if (s:=rpc('lnd.bump-status',{'txid':txid}))['status']=='mempool' else None),90)
child = status['bump_txid']
raw_parent=btc('getrawtransaction',txid,'true')
assert any(o['scriptPubKey'].get('address')==recipient and round(o['value']*100_000_000)==100000 for o in raw_parent['vout'])
fee = int(status['actual_sweep_fee_sats'])
assert 0 < fee <= quote['budget_sats']
duplicate=rpc('lnd.bump-submit',{'txid':txid,'quote_id':quote['quote_id']})
assert duplicate['bump_txid']==child
print('PASS real regtest CPFP: quote, explicit budget, broadcast, mempool, recipient preserved, duplicate submission', flush=True)
replacement_quote = wait(lambda: rpc('lnd.bump-quote', {'txid':child, 'sat_per_vbyte':10}), 30)
assert replacement_quote['method'] == 'rbf'
assert replacement_quote['recipient_sats'] == 100000
old_child = child
rpc('lnd.bump-submit', {'txid':old_child, 'quote_id':replacement_quote['quote_id']})
replaced = wait(lambda: (s if (s:=rpc('lnd.bump-status', {'txid':old_child}))['status']=='mempool' else None), 90)
child = replaced['bump_txid']
assert child != old_child and int(replaced['actual_sweep_fee_sats']) <= replacement_quote['budget_sats']
assert old_child not in btc('getrawmempool')
assert rpc('lnd.bump-status', {'txid':txid})['bump_txid'] == child
print('PASS real regtest RBF of CPFP child and original operation tracks replacement', flush=True)
def verify_history():
rows = rpc('lnd.gettransactions')['transactions']
parent = next(t for t in rows if t['tx_hash'] == txid)
assert parent['amount_sats'] - parent['total_fees'] == 100000
assert parent['fee_bump_txid'] == child
assert parent['bump_fee_sats'] == int(replaced['actual_sweep_fee_sats'])
assert not any(t['tx_hash'] in [child, old_child] for t in rows)
assert sum(h['status'] != 'replaced' for h in parent['fee_bump_history']) == 1
return True
wait(verify_history)
print('PASS one payment with verified fee history; replacement fee not double-counted', flush=True)
backend.terminate(); backend.wait(timeout=25)
backend=launch('backend',[str(BIN/'archipelago')],env)
wait(lambda: urllib.request.urlopen('http://127.0.0.1:5678/health',timeout=3).status == 200,180)
rpc('auth.login',{'password':account_password})
restored=rpc('lnd.bump-status',{'txid':txid})
assert restored['bump_txid']==child and restored['status']=='mempool'
wait(verify_history)
print('PASS durable operation and grouped history after actual backend restart',flush=True)
block=btc('generatetoaddress',1,miner)[0]
wait(lambda: rpc('lnd.bump-status',{'txid':txid})['status']=='confirmed')
btc('invalidateblock',block)
# A competing empty block announces the alternative chain to LND's ZMQ
# backend while leaving the payment package unconfirmed in Bitcoin.
btc('generateblock', miner, '[]')
try:
wait(lambda: rpc('lnd.bump-status',{'txid':txid})['status']=='mempool')
except RuntimeError:
diagnostic = {'bump':rpc('lnd.bump-status',{'txid':txid}), 'mempool':btc('getrawmempool'),
'lnd_history':ln('listchaintxns'), 'lnd_info':ln('getinfo')}
(RESULTS/'reorg.json').write_text(json.dumps(diagnostic,indent=2))
print('Reorg diagnostic:', diagnostic['bump']['status'], 'mempool size', len(diagnostic['mempool']),flush=True)
raise
wait(verify_history)
print('PASS confirmation and reorg return to mempool with grouped history',flush=True)
(RESULTS/'result.json').write_text(json.dumps({'passed':True,'network':'regtest','real_funds_used':False}))
except Exception:
# Disposable regtest data only; retain enough evidence to diagnose a failed
# relationship/chain-state assertion without weakening the acceptance test.
diagnostic = {}
for name, call in [('wallet_history', lambda: ln('listchaintxns')),
('normalized_history', lambda: rpc('lnd.gettransactions'))]:
try: diagnostic[name] = call()
except Exception as error: diagnostic[name] = str(error)
(RESULTS/'failure-history.json').write_text(json.dumps(diagnostic, indent=2))
raise
finally:
for proc in reversed(procs):
if proc.poll() is None:
proc.terminate()
try: proc.wait(timeout=20)
except subprocess.TimeoutExpired: proc.kill(); proc.wait()
for log in logs: log.close()