diff --git a/contrib/bridge-tests/README.md b/contrib/bridge-tests/README.md new file mode 100644 index 000000000000..330f1fe345a3 --- /dev/null +++ b/contrib/bridge-tests/README.md @@ -0,0 +1,37 @@ +# Bridge regtest proofs + +Each test runs a regtest pair: **A**, a stock Bitcoin Core 29 node (the Bitcoin side), and **B**, the eCash node under +test, which reaches A over `-bitcoinpeer` and feeds A's post-fork blocks into its own mempool. Transactions are mined on +A with `generateblock`, so A's relay policy never sees them — exactly how a Bitcoin block arrives. Every test states the +failure it reproduces, and exits 0 only if nothing is lost. + +``` +BRIDGE_VANILLA_BIN=/path/to/bitcoin-29.0/bin ./run_all.py path/to/ecash/bitcoind +``` + +Python 3 standard library only. Datadirs go under `$BRIDGE_REGTEST_ROOT` (default `/tmp/bridge-regtest`); each test has +its own ports, so `run_all.py` runs two at a time. Pass one or more test names to run just those. + +| test | what it proves | before | after | +|---|---|---|---| +| `test_cluster_leak` | a 40-tx chain behind a cluster limit of 10 arrives whole | 10/40 | 40/40 | +| `test_restart_persist` | what is held survives a restart | 10/40 | 40/40 | +| `test_package_cpfp` | a zero-fee parent goes in with the child that pays for it | 0/2 | 2/2 | +| `test_no_expiry` | a held transaction is never dropped for its age (31-day `setmocktime` jump) | 10/20 | 20/20 | +| `test_package_multi_parent` | a child paying for two zero-fee parents goes in with both | 0/3 | 3/3 | +| `test_ephemeral_sibling` | a sibling refused `missing-ephemeral-spends` waits for the dust parent to confirm | lost | in | +| `test_backpressure` | a full queue pauses the feed instead of refusing transactions; stuck ones cannot hold it paused | 63/100 | 100/100 | +| `test_fee_skip` | a transaction under the fee floor is not re-offered every pass | 200 offered/pass | 0 | +| `test_mempool_loss` | a fed transaction the node later loses (restart without `mempool.dat`) is fed again | lost | in | +| `test_known_relayed` | one that reached the mempool by another route is tracked too | lost | in | +| `test_stale_restart` | SIGKILL, restart a block behind, re-sync from a second node: children of lately-mined txs survive | lost | in | +| `test_mixed_dead_input` | a transaction with one live and one dead input is dropped at once, not held | — | dropped | +| `test_crash_feed` | SIGKILL mid-feed: blocks since the last write are re-fed, nothing lost or doubled | — | 100/100 | +| `test_reorg_retrack` | a fed transaction mined here and then reorganised away is tracked again | lost | in | +| `test_gate_release` | the catch-up gate releases when our chain will not get back to where it was | stalled | fed | +| `test_truc_sibling` | a v3 child refused only for its parent's one child slot waits, not dropped | lost | in | +| `test_unpaid_anchor` | a zero-fee anchor transaction whose fee payer can never go in is dropped with what waits behind it, at the feed and when the fee payer's other input dies later | 7 held | 0 | +| `test_retry_cost` | a pass costs what can progress, not the backlog (8,000 held) | 401 ms | ~7 ms | + +"Before" is the build just before the commit that fixes it; `test_backpressure` uses that build with its limit compiled +down to 6,000 WU. diff --git a/contrib/bridge-tests/bridgelib.py b/contrib/bridge-tests/bridgelib.py new file mode 100755 index 000000000000..c71b35050b25 --- /dev/null +++ b/contrib/bridge-tests/bridgelib.py @@ -0,0 +1,303 @@ +#!/usr/bin/env python3 +"""Shared regtest harness for the bridge tests. + +A = vanilla Bitcoin Core 29 (the Bitcoin side, reached by B over -bitcoinpeer). +B = the eCash node under test (-ecashheight=H), fed from A's post-fork blocks. + +Transactions are built on A with createrawtransaction/signrawtransactionwithwallet and mined with +generateblock, so A's own mempool policy never sees them -- exactly how a Bitcoin block arrives. +RPC goes over HTTP (batchable), not a bitcoin-cli process per call. + + pair = Pair("mytest", ecx=sys.argv[1], base=18600, b_args=["-limitclustercount=10"]).start() + try: ... + finally: pair.stop() +""" +import base64, hashlib, json, os, shutil, signal, subprocess, sys, time +import urllib.error, urllib.request + +# The Bitcoin side: a directory holding a stock Bitcoin Core 29 bitcoind. +VAN = os.environ.get("BRIDGE_VANILLA_BIN", "") +# The eCash node under test: given on the command line, else $BRIDGE_ECX_BIN, else this tree's build. +ECX_DEFAULT = os.environ.get("BRIDGE_ECX_BIN", os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "build", "bin", "bitcoind")) +REGTEST_ROOT = os.environ.get("BRIDGE_REGTEST_ROOT", "/tmp/bridge-regtest") +H = 150 # fork height +USER, PASS = "u", "p" +REGTEST_MAGIC = "fabfb5da" # A speaks plain regtest + + +class RPCError(Exception): + def __init__(self, method, err): + self.code, self.message = err.get("code"), err.get("message") + super().__init__(f"{method}: {self.code} {self.message}") + + +class Node: + def __init__(self, name, bitcoind, datadir, p2p, rpcport, args): + self.name, self.bitcoind, self.datadir = name, bitcoind, datadir + self.p2p, self.rpcport, self.args = p2p, rpcport, list(args) + self.proc = None + self._auth = "Basic " + base64.b64encode(f"{USER}:{PASS}".encode()).decode() + + # --- process ------------------------------------------------------------------------------ + def cmdline(self): + return [self.bitcoind, "-regtest", f"-datadir={self.datadir}", f"-port={self.p2p}", + f"-rpcport={self.rpcport}", f"-rpcuser={USER}", f"-rpcpassword={PASS}", + "-printtoconsole=0", "-dnsseed=0", "-listenonion=0", "-fallbackfee=0.0001"] + self.args + + def start(self, wait=120): + os.makedirs(self.datadir, exist_ok=True) + self.proc = subprocess.Popen(self.cmdline(), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + t0 = time.time() + while time.time() - t0 < wait: + try: + self.rpc("getblockchaininfo") + return self + except Exception: + if self.proc.poll() is not None: + raise RuntimeError(f"{self.name} exited with {self.proc.returncode}; see {self.logpath()}") + time.sleep(0.25) + raise RuntimeError(f"{self.name} did not come up") + + def stop(self): + if self.proc is None: + return + try: + self.rpc("stop") + except Exception: + pass + try: + self.proc.wait(timeout=90) + except subprocess.TimeoutExpired: + self.proc.kill() + self.proc.wait() + self.proc = None + + def kill(self): + """SIGKILL: no shutdown, no flush, no mempool.dat -- a crash, an OOM kill, or a container stop that timed out.""" + if self.proc is None: + return + self.proc.send_signal(signal.SIGKILL) + self.proc.wait() + self.proc = None + + def restart(self, args=None): + self.stop() + if args is not None: + self.args = list(args) + return self.start() + + # --- rpc ---------------------------------------------------------------------------------- + def _post(self, payload, path="/", timeout=600): + req = urllib.request.Request(f"http://127.0.0.1:{self.rpcport}{path}", json.dumps(payload).encode(), + {"Authorization": self._auth, "Content-Type": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=timeout) as r: + return json.loads(r.read()) + except urllib.error.HTTPError as e: # Core answers an RPC error with HTTP 500 + a JSON body + return json.loads(e.read()) + + def rpc(self, method, *params, wallet=None): + out = self._post({"jsonrpc": "1.0", "id": 0, "method": method, "params": list(params)}, + f"/wallet/{wallet}" if wallet else "/") + if out.get("error"): + raise RPCError(method, out["error"]) + return out["result"] + + def try_rpc(self, method, *params, wallet=None): + try: + return self.rpc(method, *params, wallet=wallet), None + except RPCError as e: + return None, e + + def batch(self, calls, wallet=None): + """[(method, [params])] -> [(result, error-dict-or-None)] in order.""" + if not calls: + return [] + body = [{"jsonrpc": "1.0", "id": i, "method": m, "params": p} for i, (m, p) in enumerate(calls)] + out = self._post(body, f"/wallet/{wallet}" if wallet else "/") + out.sort(key=lambda x: x["id"]) + return [(x["result"], x["error"]) for x in out] + + # --- helpers ------------------------------------------------------------------------------ + def logpath(self): + return os.path.join(self.datadir, "regtest", "debug.log") + + def log(self): + try: + with open(self.logpath(), errors="replace") as f: + return f.read() + except FileNotFoundError: + return "" + + def mempool(self): + return set(self.rpc("getrawmempool")) + + def has(self, txid): + """In the mempool, or confirmed on the active chain. (txindex also finds a transaction in a block that was + reorganised away; getrawtransaction reports 0 confirmations for that, so it does not count.)""" + r, _ = self.try_rpc("getrawtransaction", txid, True) + return r is not None and ("blockhash" not in r or r.get("confirmations", 0) > 0) + + +def wait_for(pred, what, timeout=120, poll=0.5): + end = time.time() + timeout + last = None + while time.time() < end: + try: + v = pred() + if v: + return v + except Exception as e: + last = e + time.sleep(poll) + raise AssertionError(f"timeout waiting for {what}" + (f" (last error: {last})" if last else "")) + + +def kill_stale(root): + """Kill any bitcoind still running on a datadir under root (by PID, never by pattern on our own command line).""" + for d in os.listdir("/proc"): + if not d.isdigit(): + continue + try: + with open(f"/proc/{d}/cmdline", "rb") as f: + cmd = f.read().replace(b"\0", b" ").decode(errors="replace") + except OSError: + continue + if "bitcoind" in cmd and f"-datadir={root}/" in cmd: + try: + os.kill(int(d), signal.SIGKILL) + except OSError: + pass + + +class Pair: + def __init__(self, tag, ecx=None, base=18600, b_args=(), a_args=(), debug=("mempool",)): + self.root = os.path.join(REGTEST_ROOT, tag) + self.ecx = ecx or ECX_DEFAULT + self.a_p2p, self.a_rpc, self.b_p2p, self.b_rpc = base + 1, base + 2, base + 3, base + 4 + a = ["-listen=1", "-bind=127.0.0.1", "-connect=0"] + list(a_args) + b = ["-listen=0", "-connect=0", f"-ecashheight={H}", f"-bitcoinpeer=127.0.0.1:{self.a_p2p}", + f"-bitcoinpeermagic={REGTEST_MAGIC}", "-txindex=1", "-walletbroadcast=0", "-logtimemicros=1"] + b += [f"-debug={c}" for c in debug] + list(b_args) + self.A = Node("A", os.path.join(VAN, "bitcoind"), os.path.join(self.root, "a"), self.a_p2p, self.a_rpc, a) + self.B = Node("B", self.ecx, os.path.join(self.root, "b"), self.b_p2p, self.b_rpc, b) + self.addr = None + self.coins = [] + + def start(self): + if not os.path.isfile(os.path.join(VAN, "bitcoind")): + raise SystemExit("set BRIDGE_VANILLA_BIN to a directory holding a stock Bitcoin Core 29 bitcoind") + if not os.path.isfile(self.ecx): + raise SystemExit(f"no eCash bitcoind at {self.ecx}; pass one as the first argument") + kill_stale(self.root) + shutil.rmtree(self.root, ignore_errors=True) + self.A.start() + self.A.rpc("createwallet", "w") + self.addr = self.A.rpc("getnewaddress", wallet="w") + self.A.rpc("generatetoaddress", H + 120, self.addr) + self.B.start() + wait_for(lambda: self.B.rpc("getblockcount") >= H - 1, "B pre-fork sync") + self.B.rpc("createwallet", "wb") + self.b_addr = self.B.rpc("getnewaddress", wallet="wb") + self.B.rpc("generatetoaddress", 1, self.b_addr) + # coins that exist on both chains and are mature on B: coinbases from the shared pre-fork blocks + height = self.A.rpc("getblockcount") + self.coins = sorted((u for u in self.A.rpc("listunspent", wallet="w") + if u["confirmations"] >= height - (H - 100) + 1), + key=lambda u: -u["amount"]) + # let the feed catch up with A's (empty) post-fork blocks + self.wait_fed(self.A.rpc("getbestblockhash")) + return self + + def stop(self): + for n in (self.B, self.A): + try: + n.stop() + except Exception: + pass + + # --- building Bitcoin transactions ---------------------------------------------------------- + def coin(self): + c = self.coins.pop(0) + return {"txid": c["txid"], "vout": c["vout"], "amount": float(c["amount"]), "spk": c["scriptPubKey"]} + + def spend(self, inputs, outputs, version=2, locktime=0, sequence=None): + """inputs: [{"txid","vout","amount","spk"}] (parents may be unconfirmed); outputs: [(addr, amount)]. + Returns {"txid","hex","vout":[{"n","amount","spk"}]}.""" + ins = [{"txid": i["txid"], "vout": i["vout"]} | ({"sequence": sequence} if sequence is not None else {}) + for i in inputs] + outs = [{a: round(v, 8)} for a, v in outputs] + raw = self.A.rpc("createrawtransaction", ins, outs, locktime, False) + if version != 2: + raw = struct_version(raw, version) + prev = [{"txid": i["txid"], "vout": i["vout"], "scriptPubKey": i["spk"], "amount": round(i["amount"], 8)} + for i in inputs] + signed = self.A.rpc("signrawtransactionwithwallet", raw, prev, wallet="w") + assert signed.get("complete"), signed + dec = self.A.rpc("decoderawtransaction", signed["hex"]) + return {"txid": dec["txid"], "hex": signed["hex"], + "vout": [{"n": o["n"], "amount": float(o["value"]), "spk": o["scriptPubKey"]["hex"]} for o in dec["vout"]]} + + def out(self, tx, n=0): + o = tx["vout"][n] + return {"txid": tx["txid"], "vout": n, "amount": o["amount"], "spk": o["spk"]} + + def chain(self, length, fee=0.0001, start=None): + """A chain of `length` 1-in-1-out transactions, each spending the previous one's output 0.""" + prev = start or self.coin() + txs = [] + for _ in range(length): + t = self.spend([prev], [(self.addr, prev["amount"] - fee)]) + txs.append(t) + prev = self.out(t) + return txs + + def chain_fast(self, length, fee=0.0001, start=None): + """Same as chain(), but batched: txids don't commit to witnesses, so the whole chain can be laid out + unsigned first and signed in one batch. For chains in the thousands.""" + prev = start or self.coin() + addr_spk = self.A.rpc("getaddressinfo", self.addr, wallet="w")["scriptPubKey"] + raws, prevs = [], [] + for _ in range(length): + raw = self.A.rpc("createrawtransaction", [{"txid": prev["txid"], "vout": prev["vout"]}], + [{self.addr: round(prev["amount"] - fee, 8)}], 0, False) + raws.append(raw) + prevs.append({"txid": prev["txid"], "vout": prev["vout"], "scriptPubKey": prev["spk"], + "amount": round(prev["amount"], 8)}) + # an unsigned segwit spend serializes without a witness, so its hash is already the txid + txid = hashlib.sha256(hashlib.sha256(bytes.fromhex(raw)).digest()).digest()[::-1].hex() + prev = {"txid": txid, "vout": 0, "amount": round(prev["amount"] - fee, 8), "spk": addr_spk} + out = [] + for i in range(0, length, 500): + res = self.A.batch([("signrawtransactionwithwallet", [raws[j], [prevs[j]]]) for j in range(i, min(i + 500, length))], + wallet="w") + for r, e in res: + assert e is None and r["complete"], (e, r) + out.append(r["hex"]) + return out + + # --- mining --------------------------------------------------------------------------------- + def mine_bitcoin(self, hexes): + """Mine the given raw transactions into one Bitcoin block on A (no mempool policy). Returns the block hash.""" + return self.A.rpc("generateblock", self.addr, list(hexes))["hash"] + + def mine_ecash(self, n=1): + return self.B.rpc("generatetoaddress", n, self.b_addr, wallet="wb") + + def wait_fed(self, block_hash, timeout=120): + """Until B has fed the given Bitcoin block (the bridge logs every block it offers).""" + return wait_for(lambda: f"bridge: Bitcoin block {block_hash}" in self.B.log(), f"B to feed {block_hash[:16]}", + timeout) + + def present(self, txids): + return sum(self.B.has(t) for t in txids) + + +def struct_version(raw_hex, version): + """Rewrite the 4-byte little-endian nVersion of an unsigned raw transaction.""" + return version.to_bytes(4, "little").hex() + raw_hex[8:] + + +def result(ok, msg): + print(("RESULT: PASS - " if ok else "RESULT: FAIL - ") + msg, flush=True) + sys.exit(0 if ok else 1) diff --git a/contrib/bridge-tests/run_all.py b/contrib/bridge-tests/run_all.py new file mode 100755 index 000000000000..a18b930aa99c --- /dev/null +++ b/contrib/bridge-tests/run_all.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +"""Run every bridge test against one eCash bitcoind, in two lanes (each test has its own ports and datadirs). + + BRIDGE_VANILLA_BIN=/path/to/bitcoin-29/bin ./run_all.py [] [--lanes N] [test ...] + +test_retry_cost.py is a measurement, not a pass/fail, and runs last on its own so the timing is not disturbed. +""" +import os, subprocess, sys, time +from concurrent.futures import ThreadPoolExecutor + +HERE = os.path.dirname(os.path.abspath(__file__)) +TESTS = ["test_cluster_leak", "test_restart_persist", "test_package_cpfp", "test_no_expiry", "test_package_multi_parent", + "test_ephemeral_sibling", "test_backpressure", "test_fee_skip", "test_mempool_loss", "test_known_relayed", + "test_stale_restart", "test_mixed_dead_input", "test_crash_feed", "test_reorg_retrack", "test_gate_release", + "test_truc_sibling", "test_unpaid_anchor"] + +args = [a for a in sys.argv[1:] if not a.startswith("--")] +lanes = int(sys.argv[sys.argv.index("--lanes") + 1]) if "--lanes" in sys.argv else 2 +if "--lanes" in sys.argv: + args.remove(str(lanes)) +ecx = [args.pop(0)] if args and os.path.isfile(args[0]) else [] +chosen = [t.removesuffix(".py") for t in args] or TESTS + +def run(name): + t0 = time.time() + p = subprocess.run([sys.executable, os.path.join(HERE, name + ".py")] + ecx, capture_output=True, text=True, timeout=1800) + line = next((l for l in reversed(p.stdout.splitlines()) if l.startswith("RESULT")), (p.stdout + p.stderr).strip().splitlines()[-1:] or ["(no output)"]) + return name, p.returncode, line if isinstance(line, str) else line[0], time.time() - t0 + +with ThreadPoolExecutor(lanes) as pool: + results = list(pool.map(run, chosen)) +if not args: + print("measuring test_retry_cost (alone) ...", flush=True) + p = subprocess.run([sys.executable, os.path.join(HERE, "test_retry_cost.py")] + (ecx or ["-"]) + ["8000"], + capture_output=True, text=True, timeout=1800) + cost = next((l for l in reversed(p.stdout.splitlines()) if l.startswith("RESULT")), "RESULT: (no measurement)") +print() +for name, rc, line, secs in results: + print(f"{'PASS' if rc == 0 else 'FAIL'} {name:28s} {secs:5.0f}s {line.removeprefix('RESULT: ')[:110]}") +if not args: + print(f"COST {'test_retry_cost':28s} {cost.removeprefix('RESULT: ')[:110]}") +failed = [n for n, rc, _, _ in results if rc != 0] +print(f"\n{len(results) - len(failed)}/{len(results)} passed" + (f"; failed: {', '.join(failed)}" if failed else "")) +sys.exit(1 if failed else 0) diff --git a/contrib/bridge-tests/test_backpressure.py b/contrib/bridge-tests/test_backpressure.py new file mode 100755 index 000000000000..0e48c563daf8 --- /dev/null +++ b/contrib/bridge-tests/test_backpressure.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Nothing held is turned away when the queue is full: the feed pauses, and resumes as it drains. And what can +never clear does not hold the feed paused for ever. + +Phase 1. The held-weight limit is 6,000 WU and B's cluster limit 10. Five Bitcoin blocks each carry a +20-transaction chain, so ten of each are held (~4,400 WU a block). A queue that turns newcomers away once full +refuses part of block 2's tail, and with it the rest of that chain; blocks 3-5 likewise. With back-pressure the +feed stops after block 2, resumes as B mines the held chains in, and all 100 arrive. + +Phase 2. Twenty zero-fee transactions that nothing will ever pay for push the held weight past the limit and +pause the feed, so a later Bitcoin block with an ordinary transaction X waits. After BRIDGE_HELD_YOUNG_BLOCKS of +B's blocks they stop counting toward the pause (they are kept, with a warning), the feed resumes, and X arrives. + +Usage: test_backpressure.py [] [--compiled-cap] + --compiled-cap: the binary has the 6,000 WU limit built in (the before case), so -bridgeheldmaxweight is not passed +exit 0 = nothing lost and the feed never stuck, 1 = otherwise +""" +import sys, time +from bridgelib import Pair, wait_for, result + +LIMIT, YOUNG = 6000, 144 +argv = [a for a in sys.argv[1:] if not a.startswith("--")] +compiled = "--compiled-cap" in sys.argv +b_args = ["-limitclustercount=10"] + ([] if compiled else [f"-bridgeheldmaxweight={LIMIT}"]) +pair = Pair("backpressure", ecx=(argv[0] if argv else None), base=18680, b_args=b_args) +try: + pair.start() + B = pair.B + # ---- phase 1 -------------------------------------------------------------------------------------------- + chains = [pair.chain(20) for _ in range(5)] + txids = [t["txid"] for c in chains for t in c] + for c in chains: + pair.mine_bitcoin([t["hex"] for t in c]) + print(f"phase 1: five Bitcoin blocks of 20-transaction chains; cluster limit 10, held limit {LIMIT} WU") + for i in range(40): + if pair.present(txids) == 100: + break + pair.mine_ecash(1) + time.sleep(2) + n1 = pair.present(txids) + log = B.log() + print(f" {n1}/100 arrived after {i} B blocks; feed paused {log.count('pausing the Bitcoin feed')}x, " + f"transactions turned away {log.count('is NOT fed')}") + ok1 = n1 == 100 + + # ---- phase 2 -------------------------------------------------------------------------------------------- + mark = len(B.log()) + stuck = [] + for _ in range(20): + c = pair.coin() + stuck.append(pair.spend([c], [(pair.addr, c["amount"])])) # zero fee, no child: never clears + pair.mine_bitcoin([z["hex"] for z in stuck]) + time.sleep(8) + c = pair.coin() + x = pair.spend([c], [(pair.addr, c["amount"] - 0.0001)]) + pair.mine_bitcoin([x["hex"]]) + time.sleep(8) + waited = not B.has(x["txid"]) + print(f"phase 2: 20 zero-fee transactions held; X {'waits behind the pause' if waited else 'arrived at once (no pause)'}") + pair.mine_ecash(YOUNG + 1) # they age past the young window + ok2 = bool(wait_for(lambda: B.has(x["txid"]), "X to arrive once the stuck ones age out", timeout=90)) + tail = B.log()[mark:] + print(f" after {YOUNG + 1} B blocks: X {'arrived' if ok2 else 'still waiting'}; " + f"resumed {tail.count('resuming the Bitcoin feed')}x; parked warning {tail.count('without clearing')}x") + result(ok1 and ok2, f"phase 1 {n1}/100, phase 2 X {'arrived' if ok2 else 'stuck'}") +finally: + pair.stop() diff --git a/contrib/bridge-tests/test_cluster_leak.py b/contrib/bridge-tests/test_cluster_leak.py new file mode 100755 index 000000000000..ba8752fe9efa --- /dev/null +++ b/contrib/bridge-tests/test_cluster_leak.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +"""A chain longer than B's cluster limit arrives whole. + +A 40-transaction chain is mined into one Bitcoin block; B's cluster limit is 10. The mempool refuses the tail +"too-large-cluster". The shipped bridge held "too-long-mempool-chain" -- the pre-cluster-mempool string, never +emitted by this Core -- so the tail fell through to OTHER and was dropped for good, with everything later spending +it. Held and re-offered as B mines, all 40 arrive. + +Usage: test_cluster_leak.py [] exit 0 = 40/40, 1 = tail lost +""" +import sys, time +from bridgelib import Pair, wait_for, result + +pair = Pair("cluster-leak", ecx=(sys.argv[1] if len(sys.argv) > 1 else None), base=18740, + b_args=["-limitclustercount=10"]) +try: + pair.start() + chain = pair.chain(40) + txids = [t["txid"] for t in chain] + pair.wait_fed(pair.mine_bitcoin([t["hex"] for t in chain])) + wait_for(lambda: pair.present(txids) >= 10, "the first ten") + print(f"fed: {pair.present(txids)}/40 accepted, the rest refused too-large-cluster") + for i in range(6): + pair.mine_ecash(1) + time.sleep(3) + n = pair.present(txids) + print(f" after B block {i + 1}: {n}/40") + if n == 40: + break + n = pair.present(txids) + result(n == 40, f"{n}/40 -- " + ("the whole chain arrived" if n == 40 else "the tail was dropped")) +finally: + pair.stop() diff --git a/contrib/bridge-tests/test_crash_feed.py b/contrib/bridge-tests/test_crash_feed.py new file mode 100755 index 000000000000..c273a1d5e86c --- /dev/null +++ b/contrib/bridge-tests/test_crash_feed.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""A hard kill in the middle of a feed loses nothing and doubles nothing. + +Five Bitcoin blocks of 20-transaction chains (ten of each held behind a cluster limit of 10) are fed back to back, +faster than the queue is written (at most once a minute), and B is killed (SIGKILL) the moment the last is fed: no +shutdown, no save, no mempool.dat. On disk the anchor is never ahead of the queue, so B re-feeds the blocks since the +last write; what it already tracks is not fed twice. All 100 arrive, and nothing is left held once they have. + +Usage: test_crash_feed.py [] exit 0 = 100/100 and nothing left over, 1 = otherwise +""" +import re, sys, time +from bridgelib import Pair, wait_for, result + +pair = Pair("crash-feed", ecx=(sys.argv[1] if len(sys.argv) > 1 else None), base=18730, + b_args=["-limitclustercount=10"]) +try: + pair.start() + B = pair.B + B.rpc("gettxoutsetinfo", "none") # flush, so the kill rolls B back only to here + chains = [pair.chain(20) for _ in range(5)] + txids = [t["txid"] for c in chains for t in c] + hashes = [pair.mine_bitcoin([t["hex"] for t in c]) for c in chains] + pair.wait_fed(hashes[-1]) + B.kill() + print(f"fed 5 blocks, then SIGKILL; {sum(1 for h in hashes if f'bridge: Bitcoin block {h}' in B.log())}/5 had been fed") + B.start() + for i in range(40): + if pair.present(txids) == 100: + break + pair.mine_ecash(1) + time.sleep(2) + n = pair.present(txids) + pair.mine_ecash(2) + time.sleep(4) + last = [l for l in B.log().splitlines() if "still held" in l][-1].split("bridge: ")[-1] + m = re.search(r"(\d+) still held, (\d+) fed", last) + left = (int(m.group(1)), int(m.group(2))) if m else (-1, -1) + refed = sum(1 for h in hashes if B.log().count(f"bridge: Bitcoin block {h}") > 1) + print(f"after restart: {n}/100 arrived; {refed} block(s) fed again; last pass: {last[:140]}") + result(n == 100 and left == (0, 0), f"{n}/100, {left[0]} held and {left[1]} fed left over") +finally: + pair.stop() diff --git a/contrib/bridge-tests/test_ephemeral_sibling.py b/contrib/bridge-tests/test_ephemeral_sibling.py new file mode 100755 index 000000000000..713bcebd911f --- /dev/null +++ b/contrib/bridge-tests/test_ephemeral_sibling.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""A sibling of an ephemeral-dust spender must wait for the parent to confirm, not be dropped. + +P pays nothing and carries a zero-value pay-to-anchor output (ephemeral dust) next to two ordinary +outputs. C1 spends the anchor and output 0 and pays for both; C2 spends only output 1. All three are +in one Bitcoin block. On eCash P is below the fee floor, so C1 carries it in as a package. C2 is then +offered with P in the mempool, and Core refuses it with "missing-ephemeral-spends": while P is +unconfirmed, every child must spend P's dust, and C1 already did. That clears the moment P confirms -- +but a bridge that treats the refusal as permanent drops C2, and everything spending it, for good. + +Usage: test_ephemeral_sibling.py [] exit 0 = C2 arrived after P confirmed, 1 = lost +""" +import sys, time +from bridgelib import Pair, result + +P2A = "bcrt1pfeesnyr2tx" +pair = Pair("ephemeral-sibling", ecx=(sys.argv[1] if len(sys.argv) > 1 else None), base=18670) +try: + pair.start() + A, B = pair.A, pair.B + c = pair.coin() + addr2 = A.rpc("getnewaddress", wallet="w") + raw = A.rpc("createrawtransaction", [{"txid": c["txid"], "vout": c["vout"]}], + [{pair.addr: 25}, {addr2: round(c["amount"] - 25, 8)}, {P2A: 0}]) + p_hex = A.rpc("signrawtransactionwithwallet", raw, [], wallet="w")["hex"] + pd = A.rpc("decoderawtransaction", p_hex) + prev = lambda n: {"txid": pd["txid"], "vout": n, "scriptPubKey": pd["vout"][n]["scriptPubKey"]["hex"], + "amount": pd["vout"][n]["value"]} + c1_raw = A.rpc("createrawtransaction", [{"txid": pd["txid"], "vout": 0}, {"txid": pd["txid"], "vout": 2}], [{pair.addr: 24.998}]) + c1 = A.rpc("signrawtransactionwithwallet", c1_raw, [prev(0), prev(2)], wallet="w") + c2_raw = A.rpc("createrawtransaction", [{"txid": pd["txid"], "vout": 1}], [{pair.addr: 24.999}]) + c2 = A.rpc("signrawtransactionwithwallet", c2_raw, [prev(1)], wallet="w") + assert c1["complete"] and c2["complete"] + ids = {"P": pd["txid"], "C1": A.rpc("decoderawtransaction", c1["hex"])["txid"], "C2": A.rpc("decoderawtransaction", c2["hex"])["txid"]} + pair.wait_fed(pair.mine_bitcoin([p_hex, c1["hex"], c2["hex"]])) + time.sleep(6) + show = lambda: ", ".join(f"{k} {'in' if B.has(v) else 'MISSING'}" for k, v in ids.items()) + print("after the feed:", show()) + for i in range(4): + pair.mine_ecash(1) # confirms P and C1; the pass that follows re-offers C2 + time.sleep(5) + print(f" after B block {i + 1}: {show()}") + if all(B.has(v) for v in ids.values()): + break + refused = [l.split("bridge: ")[-1][:140] for l in B.log().splitlines() if "missing-ephemeral-spends" in l] + print(f" missing-ephemeral-spends seen {len(refused)}x") + ok = all(B.has(v) for v in ids.values()) + result(ok, "C2 waited for P to confirm, then went in" if ok else "C2 was dropped although its refusal clears once P confirms") +finally: + pair.stop() diff --git a/contrib/bridge-tests/test_fee_skip.py b/contrib/bridge-tests/test_fee_skip.py new file mode 100755 index 000000000000..643b7703a501 --- /dev/null +++ b/contrib/bridge-tests/test_fee_skip.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +"""A transaction refused for its fee is not offered again while its feerate is still under the floor. + +A fan-out F (pays a fee, 200 outputs) and 200 zero-fee spends of it, one per output, all in one Bitcoin block. +F goes in; each spend is held below the relay floor, which never moves, and nothing will ever carry them. +Re-offering all 200 on every pass is the retry-cost problem again, driven by fees instead of chains -- and on a +congested mempool the whole frontier looks like this. The check compares the transaction's own feerate with the +floor and nothing else, so the answer is known without asking. Measured as the 'offered' count per pass. + +Usage: test_fee_skip.py [] exit 0 = none re-offered after the first sight, 1 = re-offered +""" +import re, sys, time +from bridgelib import Pair, result + +N = 200 +pair = Pair("fee-skip", ecx=(sys.argv[1] if len(sys.argv) > 1 else None), base=18690) +try: + pair.start() + A, B = pair.A, pair.B + addrs = [r for r, _ in A.batch([("getnewaddress", []) for _ in range(N + 1)], wallet="w")] + c = pair.coin() + raw = A.rpc("createrawtransaction", [{"txid": c["txid"], "vout": c["vout"]}], + [{a: 0.2} for a in addrs[:N]] + [{addrs[N]: round(c["amount"] - 0.2 * N - 0.01, 8)}]) + f = A.rpc("signrawtransactionwithwallet", raw, [], wallet="w") + fd = A.rpc("decoderawtransaction", f["hex"]) + raws = A.batch([("createrawtransaction", [[{"txid": fd["txid"], "vout": i}], [{pair.addr: 0.2}]]) for i in range(N)]) + prevs = [[{"txid": fd["txid"], "vout": i, "scriptPubKey": fd["vout"][i]["scriptPubKey"]["hex"], "amount": 0.2}] for i in range(N)] + zs = A.batch([("signrawtransactionwithwallet", [r, p]) for (r, _), p in zip(raws, prevs)], wallet="w") + assert all(z["complete"] for z, _ in zs) + pair.wait_fed(pair.mine_bitcoin([f["hex"]] + [z["hex"] for z, _ in zs])) + time.sleep(4) + print(f"fed: F and {N} zero-fee spends of it; F {'in' if B.has(fd['txid']) else 'MISSING'}") + offered = [] + for i in range(3): + mark = len(B.log()) + pair.mine_ecash(1) + time.sleep(4) + line = next((l for l in B.log()[mark:].splitlines() if "retry pass" in l or "re-offered held" in l), "") + m = re.search(r"\((\d+) offered", line) + offered.append(int(m.group(1)) if m else -1) + print(f" pass after B block {i + 1}: {line.split('bridge: ')[-1][:120]}") + result(max(offered) == 0, f"offered per pass {offered} for {N} held under the fee floor") +finally: + pair.stop() diff --git a/contrib/bridge-tests/test_gate_release.py b/contrib/bridge-tests/test_gate_release.py new file mode 100755 index 000000000000..809ff2df4fe0 --- /dev/null +++ b/contrib/bridge-tests/test_gate_release.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""The catch-up gate does not stall the bridge when our chain will not get back to where it was. + +Nothing is judged while our chain is below the height the bridge last read at (after a crash it re-syncs up to it). +Here B invalidates one of its own recent blocks: its tip drops below that height, and no header of a valid chain +reaches it any more. A gate that waits for the height for ever feeds nothing again -- here, a new Bitcoin transaction +X. Released once no known header gets back there (after a short grace for headers to arrive), it feeds X. + +Usage: test_gate_release.py [] exit 0 = X arrived, 1 = the bridge stalled +""" +import sys, time +from bridgelib import Pair, wait_for, result + +pair = Pair("gate-release", ecx=(sys.argv[1] if len(sys.argv) > 1 else None), base=18780) +try: + pair.start() + B = pair.B + hashes = pair.mine_ecash(6) + time.sleep(4) # passes read up to the tip + B.rpc("invalidateblock", hashes[2]) # our tip drops 4 blocks, onto a branch that ends there + print(f"B invalidated its own block; tip now {B.rpc('getblockcount')}, 4 below where the bridge last read") + c = pair.coin() + x = pair.spend([c], [(pair.addr, c["amount"] - 0.0001)]) + pair.mine_bitcoin([x["hex"]]) + try: + wait_for(lambda: B.has(x["txid"]), "X to be fed", timeout=200) + ok = True + except AssertionError: + ok = False + log = B.log() + result(ok, ("X fed" if ok else "X never fed: the bridge is waiting for a height that will not come back") + + (" (gate released)" if "reading from here" in log else "")) +finally: + pair.stop() diff --git a/contrib/bridge-tests/test_known_relayed.py b/contrib/bridge-tests/test_known_relayed.py new file mode 100755 index 000000000000..f0359b99fd65 --- /dev/null +++ b/contrib/bridge-tests/test_known_relayed.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""A Bitcoin transaction that reached our mempool by another route is watched like our own. + +X is sent straight to B (sendrawtransaction) before Bitcoin mines it -- as another bridge, or ordinary relay, would +put it there. When Bitcoin's block arrives the bridge finds X already in B's mempool. Untracked, X is lost with the +mempool on a restart without mempool.dat, and Y, which spends it, then has missing inputs and is dropped. Tracked as +fed, X is fed again and Y follows. + +Usage: test_known_relayed.py [] exit 0 = both arrived, 1 = lost +""" +import re, sys, time +from bridgelib import Pair, wait_for, result + +pair = Pair("known-relayed", ecx=(sys.argv[1] if len(sys.argv) > 1 else None), base=18710, + b_args=["-persistmempool=0"]) +try: + pair.start() + B = pair.B + c = pair.coin() + x = pair.spend([c], [(pair.addr, c["amount"] - 0.0001)]) + B.rpc("sendrawtransaction", x["hex"]) # another route got there first + pair.wait_fed(pair.mine_bitcoin([x["hex"]])) + print("X relayed to B first; Bitcoin's block with X fed after it") + logged = len(B.log()) + B.restart() + if B.has(x["txid"]): + # The bridge's first pass after startup can feed X again before we look. Then the log since the restart says + # so; if it doesn't, the mempool survived and the test would prove nothing. + refed = [int(n) for n in re.findall(r"(\d+) fed again after being lost", B.log()[logged:])] + assert refed and max(refed) >= 1, "the mempool survived the restart; the test proves nothing" + print("B restarted without its mempool; the bridge has already fed X again") + else: + print("B restarted without its mempool: X is gone") + y = pair.spend([pair.out(x)], [(pair.addr, x["vout"][0]["amount"] - 0.0001)]) + pair.wait_fed(pair.mine_bitcoin([y["hex"]])) + for i in range(5): + time.sleep(5) + if B.has(x["txid"]) and B.has(y["txid"]): + break + pair.mine_ecash(1) + ok = B.has(x["txid"]) and B.has(y["txid"]) + result(ok, "X was fed again and Y followed it" if ok else + f"X {'back' if B.has(x['txid']) else 'lost'}, Y {'in' if B.has(y['txid']) else 'dropped'}") +finally: + pair.stop() diff --git a/contrib/bridge-tests/test_mempool_loss.py b/contrib/bridge-tests/test_mempool_loss.py new file mode 100755 index 000000000000..50f5bd0289c1 --- /dev/null +++ b/contrib/bridge-tests/test_mempool_loss.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""A fed transaction that the node later loses must be fed again. + +`mempool.dat` is written only on a clean shutdown, so a crash, an OOM kill or a container stop that +outruns the shutdown leaves the node with an empty mempool -- here simulated exactly with +-persistmempool=0. X is fed and accepted; B restarts and X is gone; the next Bitcoin block carries Y, +which spends X. The anchor has moved past X's block, so it is never re-fed: X is lost, Y then has +missing inputs and is dropped, and so is everything that later spends either. Fixed: fed +transactions are tracked until they confirm, and one that vanishes is offered again. + +Usage: test_mempool_loss.py [] exit 0 = both arrived, 1 = lost +""" +import re, sys, time +from bridgelib import Pair, wait_for, result + +pair = Pair("mempool-loss", ecx=(sys.argv[1] if len(sys.argv) > 1 else None), base=18650, + b_args=["-persistmempool=0"]) +try: + pair.start() + B = pair.B + c = pair.coin() + x = pair.spend([c], [(pair.addr, c["amount"] - 0.0001)]) + pair.wait_fed(pair.mine_bitcoin([x["hex"]])) + wait_for(lambda: B.has(x["txid"]), "X accepted into B's mempool") + print("X fed and in B's mempool") + + logged = len(B.log()) + B.restart() # -persistmempool=0: it comes back with nothing + if B.has(x["txid"]): + # The bridge's first pass after startup can feed X again before we look. Then the log since the restart says + # so; if it doesn't, the mempool survived and the test would prove nothing. + refed = [int(n) for n in re.findall(r"(\d+) fed again after being lost", B.log()[logged:])] + assert refed and max(refed) >= 1, "the mempool survived the restart; the test proves nothing" + print("B restarted without its mempool; the bridge has already fed X again") + else: + print("B restarted without its mempool: X is gone") + + y = pair.spend([pair.out(x)], [(pair.addr, x["vout"][0]["amount"] - 0.0001)]) + pair.wait_fed(pair.mine_bitcoin([y["hex"]])) + print("Bitcoin mined Y, which spends X") + for i in range(5): + time.sleep(5) + have = (B.has(x["txid"]), B.has(y["txid"])) + print(f" attempt {i + 1}: X {'present' if have[0] else 'MISSING'}, Y {'present' if have[1] else 'MISSING'}") + if all(have): + break + pair.mine_ecash(1) + ok = B.has(x["txid"]) and B.has(y["txid"]) + result(ok, "X was fed again and Y followed it" if ok else "X was lost with the mempool, and Y was dropped as an orphan") +finally: + pair.stop() diff --git a/contrib/bridge-tests/test_mixed_dead_input.py b/contrib/bridge-tests/test_mixed_dead_input.py new file mode 100755 index 000000000000..9cf453fc5e1a --- /dev/null +++ b/contrib/bridge-tests/test_mixed_dead_input.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""A transaction with one input we expect and one that is dead can never go in: drop it, don't hold it. + +P is fed and sits in B's mempool. T spends P's output and a post-fork Bitcoin coinbase output, which never exists on +eCash (by fork+4 days a third of Bitcoin's transactions descend from one); U spends T. B reports T's inputs missing. A +rule that holds a transaction whenever one of its parents is tracked keeps T -- P is -- and re-offers it every pass +until P is mined, with U and the rest of the chain held behind it, counting toward the pause. Checked input by input, +the coinbase input is missing and untracked: T is dead, and so is U. + +Usage: test_mixed_dead_input.py [] exit 0 = T and U dropped at once, 1 = held +""" +import re, sys, time +from bridgelib import H, Pair, wait_for, result + +pair = Pair("mixed-dead", ecx=(sys.argv[1] if len(sys.argv) > 1 else None), base=18720) +try: + pair.start() + A, B = pair.A, pair.B + c = pair.coin() + p = pair.spend([c], [(pair.addr, c["amount"] - 0.0001)]) + pair.wait_fed(pair.mine_bitcoin([p["hex"]])) + wait_for(lambda: p["txid"] in B.mempool(), "P fed") + tip = A.rpc("getblockcount") + dead = next(u for u in A.rpc("listunspent", wallet="w") # a mature post-fork coinbase output + if 100 <= u["confirmations"] <= tip - H + 1) + d = {"txid": dead["txid"], "vout": dead["vout"], "amount": float(dead["amount"]), "spk": dead["scriptPubKey"]} + t = pair.spend([pair.out(p), d], [(pair.addr, p["vout"][0]["amount"] + d["amount"] - 0.0002)]) + u = pair.spend([pair.out(t)], [(pair.addr, t["vout"][0]["amount"] - 0.0001)]) + h = pair.mine_bitcoin([t["hex"], u["hex"]]) + pair.wait_fed(h) + line = next(l for l in B.log().splitlines() if f"bridge: Bitcoin block {h}" in l) + print("feed:", line.split("bridge: ")[-1][:160]) + time.sleep(4) + pair.mine_ecash(1) # a pass: anything held would still be held after it + time.sleep(4) + held_now = [l for l in B.log().splitlines() if "still held" in l] + last = held_now[-1].split("bridge: ")[-1] if held_now else "(no pass reported)" + m = re.search(r"(\d+) still held", last) + held_count = int(m.group(1)) if m else 0 + missing = int(re.search(r"(\d+) missing inputs", line).group(1)) + print("after a pass:", last[:160]) + result(missing == 2 and held_count == 0 and not B.has(t["txid"]) and not B.has(u["txid"]), + f"T and U: {missing} dropped as dead at the feed, {held_count} held afterwards") +finally: + pair.stop() diff --git a/contrib/bridge-tests/test_no_expiry.py b/contrib/bridge-tests/test_no_expiry.py new file mode 100755 index 000000000000..cfe260c0de42 --- /dev/null +++ b/contrib/bridge-tests/test_no_expiry.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""A held transaction must never expire while it can still be accepted. + +A 20-transaction chain is mined into one Bitcoin block and B's cluster limit (10) holds the tail. +B's clock then jumps 31 days (setmocktime) and a retry pass runs while the tail is still blocked. +A queue that gives up on anything held past 30 days drops the tail -- and the Bitcoin block is never +re-fed, so it is gone for good, although every one of those transactions was still valid. Fixed: +the tail stays held and arrives as soon as B mines. + +Usage: test_no_expiry.py [] exit 0 = tail survived, 1 = lost +""" +import sys, time +from bridgelib import Pair, wait_for, result + +pair = Pair("no-expiry", ecx=(sys.argv[1] if len(sys.argv) > 1 else None), base=18610, + b_args=["-limitclustercount=10"]) +try: + pair.start() + B = pair.B + chain = pair.chain(20) + txids = [t["txid"] for t in chain] + pair.wait_fed(pair.mine_bitcoin([t["hex"] for t in chain])) + wait_for(lambda: pair.present(txids) == 10, "10 accepted with the tail held") + print("fed: 10/20 in B's mempool, 10 held behind the cluster limit") + + # 31 days pass on B while the tail is still cluster-blocked; a retry pass must run before B mines, + # or the tail would simply be accepted and the test would prove nothing. + mark = len(B.log()) + B.rpc("setmocktime", int(time.time()) + 31 * 86400) + ran = wait_for(lambda: ("re-offered held Bitcoin transactions" in B.log()[mark:] + or "bridge: retry pass" in B.log()[mark:]), "a retry pass after the clock jump", timeout=150) + tail = B.log()[mark:] + dropped_line = next((l for l in tail.splitlines() if "re-offered held" in l), "") + print("retry pass after the jump:", (dropped_line.split("bridge: ")[-1] if dropped_line else "(nothing changed)")) + + for i in range(4): + pair.mine_ecash(1) + time.sleep(4) + print(f" after B block {i + 1}: {pair.present(txids)}/20") + if pair.present(txids) == 20: + break + n = pair.present(txids) + result(n == 20, f"{n}/20 -- the tail " + ("survived the 31-day wait" if n == 20 else f"was dropped as expired ({20 - n} lost)")) +finally: + pair.stop() diff --git a/contrib/bridge-tests/test_package_cpfp.py b/contrib/bridge-tests/test_package_cpfp.py new file mode 100755 index 000000000000..9c043de33bd7 --- /dev/null +++ b/contrib/bridge-tests/test_package_cpfp.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +"""A parent below the fee floor goes in with the child that pays for it. + +A zero-fee parent and its CPFP child are mined into one Bitcoin block (generateblock: no relay policy on the +Bitcoin side, as with a real package). Alone, the parent is refused "min relay fee not met" and the child then lacks +its input; both used to be lost. Offered together to ProcessNewPackage, both go in. + +Usage: test_package_cpfp.py [] exit 0 = both in, 1 = lost +""" +import sys, time +from bridgelib import Pair, result + +pair = Pair("package-cpfp", ecx=(sys.argv[1] if len(sys.argv) > 1 else None), base=18760) +try: + pair.start() + c = pair.coin() + parent = pair.spend([c], [(pair.addr, c["amount"])]) # zero fee + child = pair.spend([pair.out(parent)], [(pair.addr, c["amount"] - 0.001)]) # pays for both + pair.wait_fed(pair.mine_bitcoin([parent["hex"], child["hex"]])) + for i in range(4): + time.sleep(4) + if pair.present([parent["txid"], child["txid"]]) == 2: + break + pair.mine_ecash(1) + n = pair.present([parent["txid"], child["txid"]]) + result(n == 2, "the zero-fee parent and its child went in together as a package" if n == 2 else f"{n}/2 arrived") +finally: + pair.stop() diff --git a/contrib/bridge-tests/test_package_multi_parent.py b/contrib/bridge-tests/test_package_multi_parent.py new file mode 100755 index 000000000000..62dfc102126a --- /dev/null +++ b/contrib/bridge-tests/test_package_multi_parent.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +"""A child that pays for two parents below the fee floor must go in with both. + +Two zero-fee parents and one child spending an output of each -- the child's fee pays for all three +-- are mined into one Bitcoin block. Neither parent is acceptable alone, and a one-parent-one-child +package fails too: the child still lacks its other parent. Only [parent, parent, child] works, which +Core's ProcessNewPackage accepts (a child with up to 24 parents). Fixed: all three arrive. + +Usage: test_package_multi_parent.py [] exit 0 = all three arrived, 1 = lost +""" +import sys, time +from bridgelib import Pair, result + +pair = Pair("package-multi", ecx=(sys.argv[1] if len(sys.argv) > 1 else None), base=18620) +try: + pair.start() + c1, c2 = pair.coin(), pair.coin() + p1 = pair.spend([c1], [(pair.addr, c1["amount"])]) # zero fee + p2 = pair.spend([c2], [(pair.addr, c2["amount"])]) # zero fee + child = pair.spend([pair.out(p1), pair.out(p2)], [(pair.addr, c1["amount"] + c2["amount"] - 0.002)]) + txids = [p1["txid"], p2["txid"], child["txid"]] + pair.wait_fed(pair.mine_bitcoin([p1["hex"], p2["hex"], child["hex"]])) + print(f"mined two zero-fee parents and their child: {pair.present(txids)}/3 on B after the feed") + for i in range(5): + time.sleep(5) + n = pair.present(txids) + print(f" attempt {i + 1}: {n}/3") + if n == 3: + break + pair.mine_ecash(1) # a tip change re-triggers the retry pass + n = pair.present(txids) + pkg = [l.split("bridge: ")[-1] for l in pair.B.log().splitlines() if "in packages" in l and " 0 in packages" not in l] + for l in pkg[:2]: + print(" ", l) + result(n == 3, f"{n}/3 -- " + ("both parents went in with their child" if n == 3 else "lost: no one-parent package can carry them")) +finally: + pair.stop() diff --git a/contrib/bridge-tests/test_reorg_retrack.py b/contrib/bridge-tests/test_reorg_retrack.py new file mode 100755 index 000000000000..5226713e8c09 --- /dev/null +++ b/contrib/bridge-tests/test_reorg_retrack.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""A fed transaction mined here and then reorganised away is tracked again. + +X is fed, then mined into a B block, so the bridge stops tracking its body. B's chain then reorganises (the block is +invalidated and replaced by an empty one): X is back in the mempool but no longer tracked. B then restarts without +mempool.dat, losing X, and Bitcoin mines Y, which spends X. Untracked, X is never fed again and Y is dropped as an +orphan. Taken back from the reorganised-away block and tracked as fed, X is fed again and Y follows. + +Usage: test_reorg_retrack.py [] exit 0 = both arrived, 1 = lost +""" +import sys, time +from bridgelib import Pair, wait_for, result + +pair = Pair("reorg-retrack", ecx=(sys.argv[1] if len(sys.argv) > 1 else None), base=18770, + b_args=["-persistmempool=0"]) +try: + pair.start() + B = pair.B + c = pair.coin() + x = pair.spend([c], [(pair.addr, c["amount"] - 0.0001)]) + pair.wait_fed(pair.mine_bitcoin([x["hex"]])) + wait_for(lambda: x["txid"] in B.mempool(), "X fed") + mined = pair.mine_ecash(1)[0] + time.sleep(4) # a pass reads the block and retires X as mined + B.rpc("invalidateblock", mined) + B.rpc("generateblock", pair.b_addr, []) # an empty block in its place: X is not mined any more + time.sleep(5) # a pass sees the reorg + print(f"X mined, then reorganised away; X {'in' if x['txid'] in B.mempool() else 'NOT in'} B's mempool") + B.restart() # -persistmempool=0: X is lost (the bridge may re-feed it at once) + y = pair.spend([pair.out(x)], [(pair.addr, x["vout"][0]["amount"] - 0.0001)]) + pair.wait_fed(pair.mine_bitcoin([y["hex"]])) + for i in range(5): + time.sleep(4) + if B.has(x["txid"]) and B.has(y["txid"]): + break + pair.mine_ecash(1) + retracked = "tracked again" in B.log() + ok = B.has(x["txid"]) and B.has(y["txid"]) + result(ok, f"X {'back' if B.has(x['txid']) else 'lost'}, Y {'in' if B.has(y['txid']) else 'dropped'}" + + (" (re-tracked after the reorg)" if retracked else "")) +finally: + pair.stop() diff --git a/contrib/bridge-tests/test_restart_persist.py b/contrib/bridge-tests/test_restart_persist.py new file mode 100755 index 000000000000..11d297833123 --- /dev/null +++ b/contrib/bridge-tests/test_restart_persist.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +"""What is held survives a restart. + +A 40-transaction chain behind a cluster limit of 10: 10 accepted, 30 held. B is stopped and restarted before any +of the 30 can go in. A queue kept only in memory loses them, and the Bitcoin block is not fed again. Kept in +bridgeheld.dat, they are loaded back and arrive as B mines. + +Usage: test_restart_persist.py [] exit 0 = 40/40, 1 = lost +""" +import os, sys, time +from bridgelib import Pair, wait_for, result + +pair = Pair("restart-persist", ecx=(sys.argv[1] if len(sys.argv) > 1 else None), base=18750, + b_args=["-limitclustercount=10"]) +try: + pair.start() + B = pair.B + chain = pair.chain(40) + txids = [t["txid"] for t in chain] + pair.wait_fed(pair.mine_bitcoin([t["hex"] for t in chain])) + wait_for(lambda: pair.present(txids) >= 10, "the first ten") + B.restart() # a clean stop writes the queue + held_file = os.path.join(B.datadir, "regtest", "bridgeheld.dat") + print(f"fed {pair.present(txids)}/40, restarted; bridgeheld.dat {'present' if os.path.exists(held_file) else 'MISSING'}") + for i in range(6): + pair.mine_ecash(1) + time.sleep(3) + n = pair.present(txids) + print(f" after B block {i + 1}: {n}/40") + if n == 40: + break + n = pair.present(txids) + result(n == 40, f"{n}/40 -- " + ("the queue survived the restart" if n == 40 else "the held tail was lost")) +finally: + pair.stop() diff --git a/contrib/bridge-tests/test_retry_cost.py b/contrib/bridge-tests/test_retry_cost.py new file mode 100755 index 000000000000..95dda831fd09 --- /dev/null +++ b/contrib/bridge-tests/test_retry_cost.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""The retry pass must cost what can make progress, not the size of the backlog. + +A 3,000-transaction chain is mined into one Bitcoin block; B's cluster limit (10) admits 10 and +holds the rest. Each B block then frees room for the next ten. A pass that re-validates every held +transaction does ~3,000 mempool acceptance attempts per block to admit ten -- each one failing on a +missing input whose parent is plainly still held. Scaled to betanet's backlog of ~170,000 that is +seconds of the message-handler thread per block. Measured here as the gap between B connecting a +block (UpdateTip) and the pass that follows it finishing ("re-offered held"). + +Usage: test_retry_cost.py [] [chain-length] prints per-pass cost; exit 0 always +(the verdict is the number, compared across builds) +""" +import re, statistics, sys, time +from datetime import datetime +from bridgelib import Pair, wait_for + +N = int(sys.argv[2]) if len(sys.argv) > 2 else 3000 +pair = Pair("retry-cost", ecx=(sys.argv[1] if len(sys.argv) > 1 and sys.argv[1] != "-" else None), base=18640, + b_args=["-limitclustercount=10"], debug=()) +TS = re.compile(r"^(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d+)Z") + +def ts(line): + m = TS.match(line) + return datetime.strptime(m.group(1), "%Y-%m-%dT%H:%M:%S.%f").timestamp() if m else None + +try: + pair.start() + t0 = time.time() + hexes = pair.chain_fast(N) + print(f"built a {N}-transaction chain in {time.time() - t0:.0f}s") + h = pair.mine_bitcoin(hexes) + pair.wait_fed(h, timeout=600) + feed = next(l for l in pair.B.log().splitlines() if f"bridge: Bitcoin block {h}" in l) + print("feed:", feed.split("bridge: ")[-1][:150]) + + costs = [] + for i in range(4): + mark = len(pair.B.log()) + pair.mine_ecash(1) + wait_for(lambda: "re-offered held Bitcoin transactions" in pair.B.log()[mark:], "the retry pass after the block", timeout=300) + tail = pair.B.log()[mark:].splitlines() + tip = next(ts(l) for l in tail if "UpdateTip" in l) + done_line = next(l for l in tail if "re-offered held Bitcoin transactions" in l) + gap = (ts(done_line) - tip) * 1000 + own = re.search(r"([0-9.]+) ms\)", done_line) # builds that time the pass report it themselves + cost = float(own.group(1)) if own else gap + costs.append(cost) + print(f" block {i + 1}: pass {cost:7.1f} ms ({'self-reported' if own else 'block-to-pass gap'}; gap {gap:.1f} ms)" + f" | {done_line.split('bridge: ')[-1][:95]}") + med = statistics.median(costs) + print(f"RESULT: median retry pass {med:.1f} ms for ~{N} held " + f"(~{med / N * 170000:.0f} ms of the message thread per block at betanet's ~170,000, if linear)") +finally: + pair.stop() diff --git a/contrib/bridge-tests/test_stale_restart.py b/contrib/bridge-tests/test_stale_restart.py new file mode 100755 index 000000000000..3716ff6182fc --- /dev/null +++ b/contrib/bridge-tests/test_stale_restart.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""A crash that rolls our chain back must not turn the children of lately mined transactions into orphans. + +Core flushes the chainstate every 50-70 minutes, so a node killed hard restarts where it last flushed -- blocks +behind -- and with an empty mempool. Here B's chainstate is flushed; P (fed earlier) is then mined into a B block; +C, which spends P, is fed and sits in B's mempool. B is killed (SIGKILL) and restarts one block back, where P is +neither mined nor in its mempool, and C is gone. A second eCash node, B2, still has the lost block, as peers do. +Untracked, C is simply lost. Tracked, but judged before B has caught up, C's input looks missing and C is dropped for +good. Fixed: nothing is judged until B is back at the height it had reached; then C is fed again and goes in. + +Usage: test_stale_restart.py [] exit 0 = C came back, 1 = lost +""" +import os, sys, time +from bridgelib import H, Node, Pair, wait_for, result + +pair = Pair("stale-restart", ecx=(sys.argv[1] if len(sys.argv) > 1 else None), base=18700, + b_args=["-listen=1", "-bind=127.0.0.1"]) +b2 = Node("B2", pair.ecx, os.path.join(pair.root, "b2"), 18705, 18706, + ["-listen=0", f"-connect=127.0.0.1:{pair.b_p2p}", f"-ecashheight={H}", "-walletbroadcast=0"]) +try: + pair.start() + b2.start() + B = pair.B + wait_for(lambda: b2.rpc("getbestblockhash") == B.rpc("getbestblockhash"), "B2 to follow B") + + c = pair.coin() + p = pair.spend([c], [(pair.addr, c["amount"] - 0.0001)]) + pair.wait_fed(pair.mine_bitcoin([p["hex"]])) + wait_for(lambda: p["txid"] in B.mempool(), "P fed") + B.rpc("gettxoutsetinfo", "none") # flush: this is where a crash will roll B back to + h0 = B.rpc("getblockcount") + pair.mine_ecash(1) # P is mined at h0+1 + wait_for(lambda: b2.rpc("getblockcount") == h0 + 1, "B2 to have the block that will be lost") + time.sleep(3) # a pass sees P mined + + ch = pair.spend([pair.out(p)], [(pair.addr, p["vout"][0]["amount"] - 0.0001)]) + pair.wait_fed(pair.mine_bitcoin([ch["hex"]])) + wait_for(lambda: ch["txid"] in B.mempool(), "C fed") + print(f"P mined at {h0 + 1}, C (spends P) fed and in B's mempool; B last flushed at {h0}") + + B.kill() + B.start() + back = B.rpc("getblockcount") + print(f"B killed and restarted at height {back}" + (" -- one block back, P unmined, mempool empty" if back == h0 else " (not rolled back; inconclusive)")) + wait_for(lambda: B.rpc("getblockcount") >= h0 + 1, "B to recover the lost block from B2", timeout=120) + ok = bool(wait_for(lambda: B.has(ch["txid"]), "C to be fed again", timeout=60)) + refed = sum(1 for l in B.log().splitlines() if "fed again after being lost" in l and " 0 fed again" not in l) + result(back == h0 and ok, f"C {'is back' if ok else 'was lost'} after a restart {h0 + 1 - back} block(s) behind " + f"({refed} pass(es) fed something again)") +except AssertionError as e: + result(False, str(e)) +finally: + b2.stop() + pair.stop() diff --git a/contrib/bridge-tests/test_truc_sibling.py b/contrib/bridge-tests/test_truc_sibling.py new file mode 100755 index 000000000000..4fb039a165b5 --- /dev/null +++ b/contrib/bridge-tests/test_truc_sibling.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +"""A version 3 child refused only because its parent's one child slot is taken waits; it is not dropped. + +P (v3) and its child C1 are mined in one Bitcoin block; C2 (v3), spending another output of P, in the next. On +Bitcoin that is fine: P was mined. On eCash P and C1 may still be unconfirmed when C2 arrives, and TRUC allows a v3 +transaction one unconfirmed child, so C2 could only get in by evicting its sibling C1, and pays too little to. Core +refuses it "insufficient fee (including sibling eviction)". Nothing conflicts with C2 -- it clears the moment P is +mined here -- but read as a lost conflict it is dropped for good. Held, it goes in once B mines P. + +Usage: test_truc_sibling.py [] exit 0 = C2 arrived, 1 = dropped +""" +import sys, time +from bridgelib import Pair, wait_for, result + +pair = Pair("truc-sibling", ecx=(sys.argv[1] if len(sys.argv) > 1 else None), base=18790) +try: + pair.start() + A, B = pair.A, pair.B + c = pair.coin() + a2 = A.rpc("getnewaddress", wallet="w") + p = pair.spend([c], [(pair.addr, 25), (a2, round(c["amount"] - 25 - 0.001, 8))], version=3) + c1 = pair.spend([pair.out(p, 0)], [(pair.addr, 25 - 0.002)], version=3) # the child that pays well + c2 = pair.spend([pair.out(p, 1)], [(pair.addr, p["vout"][1]["amount"] - 0.0001)], version=3) # a sibling paying less + pair.wait_fed(pair.mine_bitcoin([p["hex"], c1["hex"]])) + wait_for(lambda: c1["txid"] in B.mempool(), "P and C1 fed") + h = pair.mine_bitcoin([c2["hex"]]) + pair.wait_fed(h) + line = next(l for l in B.log().splitlines() if f"bridge: Bitcoin block {h}" in l) + print("C2's block:", line.split("bridge: ")[-1][:150]) + for i in range(4): + pair.mine_ecash(1) # mines P and C1; the next pass offers C2 again + time.sleep(4) + if B.has(c2["txid"]): + break + ok = B.has(c2["txid"]) + result(ok, "C2 waited for P to be mined, then went in" if ok else "C2 was dropped as if it had lost a conflict") +finally: + pair.stop() diff --git a/contrib/bridge-tests/test_unpaid_anchor.py b/contrib/bridge-tests/test_unpaid_anchor.py new file mode 100755 index 000000000000..6b072eab0997 --- /dev/null +++ b/contrib/bridge-tests/test_unpaid_anchor.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""A zero-fee transaction whose fee payer can never go in is unpaid: drop it and what waits behind it, don't hold it. + +T pays nothing and carries a zero-value pay-to-anchor output next to two ordinary ones -- the shape of a pre-signed +transaction whose fee is settled at broadcast. On Bitcoin, H spent the anchor and paid for both, adding a post-fork +coinbase output to cover the fee; C and D spend T's other outputs. All four are in one Bitcoin block. On eCash H can +never go in (that coinbase output never exists here), and nothing else can carry T: policy admits a transaction with +dust only at zero fee, prioritisetransaction or not, and only together with a child that spends the dust. A bridge +that holds T for its fee keeps T, C and D forever: re-offered, saved across restarts, counted in the queue. (On +betanet, 2026-10-07: 35 such transactions with 1,870 waiting behind them.) + +Second, later: the fee payer H2 is held behind T2 because its other input, from Y, was still expected; then Y loses +to a conflicting eCash spend Y' and is dropped. H2 now fails its package with T2 for good, so it goes, and T2 with it. + +Usage: test_unpaid_anchor.py [] exit 0 = both unpaid trees dropped, 1 = held +""" +import re, sys, time +from bridgelib import H, Pair, wait_for, result + +P2A = "bcrt1pfeesnyr2tx" +pair = Pair("unpaid-anchor", ecx=(sys.argv[1] if len(sys.argv) > 1 else None), base=18800) +try: + pair.start() + A, B = pair.A, pair.B + c = pair.coin() + tip = A.rpc("getblockcount") + dead = next(u for u in A.rpc("listunspent", wallet="w") # a mature post-fork coinbase output + if 100 <= u["confirmations"] <= tip - H + 1) + d = {"txid": dead["txid"], "vout": dead["vout"], "amount": float(dead["amount"]), "spk": dead["scriptPubKey"]} + half = round(c["amount"] / 2, 8) + t = pair.spend([c], [(pair.addr, half), (A.rpc("getnewaddress", wallet="w"), round(c["amount"] - half, 8)), (P2A, 0)], + version=3) + assert t["vout"][2]["spk"] == "51024e73" + ch = pair.spend([pair.out(t, 0)], [(pair.addr, half), (P2A, 0)], version=3) # one level down the tree + dd = pair.spend([pair.out(t, 1)], [(pair.addr, t["vout"][1]["amount"] - 0.0001)]) # pays its own fee + h = pair.spend([pair.out(t, 2), d], [(pair.addr, d["amount"] - 0.0001)], version=3) # the fee payer + ids = {"T": t["txid"], "C": ch["txid"], "D": dd["txid"], "H": h["txid"]} + blk = pair.mine_bitcoin([t["hex"], ch["hex"], dd["hex"], h["hex"]]) + pair.wait_fed(blk) + line = next(l for l in B.log().splitlines() if f"bridge: Bitcoin block {blk}" in l) + print("feed:", line.split("bridge: ")[-1][:170]) + time.sleep(4) + pair.mine_ecash(1) # a pass: anything held would still be held after it + time.sleep(4) + held_now = [l for l in B.log().splitlines() if "still held" in l] + last = held_now[-1].split("bridge: ")[-1] if held_now else "(no pass reported)" + m = re.search(r"(\d+) still held", last) + held_count = int(m.group(1)) if m else 0 + unpaid = [l.split("bridge: ")[-1] for l in B.log().splitlines() if "fee payer can never go in" in l] + print("after a pass:", last[:160]) + print("unpaid:", unpaid[-1][:160] if unpaid else "(none reported)") + present = [k for k, v in ids.items() if B.has(v)] + first = held_count == 0 and not present and len(unpaid) == 1 + print("first:", "T dropped as unpaid with C and D" if first else f"{held_count} held, in B: {present}") + + # Second: Y is fed, then replaced in B's mempool by Y' (a conflicting eCash spend of the same coin, paying more). + k = pair.coin() + y = pair.spend([k], [(pair.addr, k["amount"] - 0.0001)]) + pair.wait_fed(pair.mine_bitcoin([y["hex"]])) + wait_for(lambda: B.has(y["txid"]), "Y fed") + y2 = pair.spend([k], [(A.rpc("getnewaddress", wallet="w"), k["amount"] - 0.001)]) + B.rpc("sendrawtransaction", y2["hex"]) # eCash spent the coin differently: Y can never go in + assert B.has(y2["txid"]) and not B.has(y["txid"]) + c2 = pair.coin() + t2 = pair.spend([c2], [(pair.addr, half), (A.rpc("getnewaddress", wallet="w"), round(c2["amount"] - half, 8)), (P2A, 0)], + version=3) + ch2 = pair.spend([pair.out(t2, 0)], [(pair.addr, half), (P2A, 0)], version=3) + dd2 = pair.spend([pair.out(t2, 1)], [(pair.addr, t2["vout"][1]["amount"] - 0.0001)]) + h2 = pair.spend([pair.out(t2, 2), pair.out(y)], [(pair.addr, y["vout"][0]["amount"] - 0.0001)], version=3) + ids2 = {"T2": t2["txid"], "C2": ch2["txid"], "D2": dd2["txid"], "H2": h2["txid"]} + blk2 = pair.mine_bitcoin([t2["hex"], ch2["hex"], dd2["hex"], h2["hex"]]) + pair.wait_fed(blk2) + line2 = next(l for l in B.log().splitlines() if f"bridge: Bitcoin block {blk2}" in l) + print("feed:", line2.split("bridge: ")[-1][:170]) + for _ in range(60): # the pass after the feed; held, it never comes + unpaid = [l.split("bridge: ")[-1] for l in B.log().splitlines() if "fee payer can never go in" in l] + if len(unpaid) >= 2: + break + time.sleep(1) + print("unpaid:", unpaid[-1][:160] if len(unpaid) >= 2 else "(not reported)") + pair.mine_ecash(1) + time.sleep(4) + present2 = [k for k, v in ids2.items() if B.has(v)] + held_now = [l for l in B.log().splitlines() if "still held" in l] + m = re.search(r"(\d+) still held", held_now[-1]) if held_now else None + held2 = int(m.group(1)) if m else 0 + second = held2 == 0 and not present2 and len(unpaid) >= 2 and "and 2 waiting" in unpaid[-1] + result(first and second, ("T, C and D dropped as unpaid at the feed; " if first else "first tree held; ") + + ("T2, C2 and D2 dropped once H2's other input died" if second else f"second: {held2} held, in B: {present2}")) +finally: + pair.stop() diff --git a/src/chainparams.cpp b/src/chainparams.cpp index 32c3bff73482..68797625c3bf 100644 --- a/src/chainparams.cpp +++ b/src/chainparams.cpp @@ -45,6 +45,7 @@ void ReadRegTestArgs(const ArgsManager& args, CChainParams::RegTestOptions& opti { if (auto value = args.GetBoolArg("-fastprune")) options.fastprune = *value; if (HasTestOption(args, "bip94")) options.enforce_bip94 = true; + options.ecash_height = args.GetIntArg("-ecashheight", 0); for (const std::string& arg : args.GetArgs("-testactivationheight")) { const auto found{arg.find('@')}; diff --git a/src/init.cpp b/src/init.cpp index 8ced906a5417..cb89f8ac34f9 100644 --- a/src/init.cpp +++ b/src/init.cpp @@ -320,6 +320,7 @@ void Shutdown(NodeContext& node) // using the other before destroying them. if (node.peerman && node.validation_signals) node.validation_signals->UnregisterValidationInterface(node.peerman.get()); if (node.connman) node.connman->Stop(); + if (node.peerman) node.peerman->FlushBridge(); StopTorControl(); @@ -540,6 +541,13 @@ void SetupServerArgs(ArgsManager& argsman, bool can_listen_ipc) argsman.AddArg("-addnode=", strprintf("Add a node to connect to and attempt to keep the connection open (see the addnode RPC help for more info). This option can be specified multiple times to add multiple nodes; connections are limited to %u at a time and are counted separately from the -maxconnections limit.", MAX_ADDNODE_CONNECTIONS), ArgsManager::ALLOW_ANY | ArgsManager::NETWORK_ONLY, OptionsCategory::CONNECTION); argsman.AddArg("-bitcoinpeer=", "Connect to an operator-controlled, unpruned Bitcoin full node using Bitcoin's network magic, to fetch the pre-fork chain and, past the fork, feed our mempool from Bitcoin blocks. The connection should use loopback or a trusted private network because Bitcoin P2P is not authenticated. Up to four peers may be given when the block feed is active: one fetches blocks at a time, the others follow headers and take over if it disconnects or stalls. Each peer must advertise NODE_NETWORK and NODE_WITNESS. Always v1; never relays addresses or transaction gossip. Seed-node use only.", ArgsManager::ALLOW_ANY | ArgsManager::NETWORK_ONLY, OptionsCategory::CONNECTION); + argsman.AddArg("-bitcoinpeermagic=", "Speak this 4-byte network magic to -bitcoinpeer peers instead of Bitcoin mainnet's (regtest only, for tests).", + ArgsManager::ALLOW_ANY | ArgsManager::DEBUG_ONLY, OptionsCategory::CONNECTION); + argsman.AddArg("-bridgeheldmaxweight=", strprintf("Pause fetching Bitcoin blocks while the transactions held from our last %d blocks weigh at least weight units; nothing held is dropped (default: %u)", + BRIDGE_HELD_YOUNG_BLOCKS, DEFAULT_BRIDGE_HELD_MAX_WEIGHT), + ArgsManager::ALLOW_ANY | ArgsManager::DEBUG_ONLY, OptionsCategory::CONNECTION); + argsman.AddArg("-ecashheight=", "Fork height at which eCash rules and the Bitcoin block feed activate (regtest only, for tests).", + ArgsManager::ALLOW_ANY | ArgsManager::DEBUG_ONLY, OptionsCategory::CHAINPARAMS); argsman.AddArg("-asmap=", strprintf("Specify asn mapping used for bucketing of the peers. Relative paths will be prefixed by the net-specific datadir location.%s", #ifdef ENABLE_EMBEDDED_ASMAP " If a bool arg is given (-asmap or -asmap=1), the embedded mapping data in the binary will be used." diff --git a/src/kernel/chainparams.cpp b/src/kernel/chainparams.cpp index 0f5326174623..cd58d80609e4 100644 --- a/src/kernel/chainparams.cpp +++ b/src/kernel/chainparams.cpp @@ -559,6 +559,8 @@ class CRegTestParams : public CChainParams explicit CRegTestParams(const RegTestOptions& opts) { m_chain_type = ChainType::REGTEST; + consensus.EcashHeight = opts.ecash_height; // regtest only (tests); 0 = plain Bitcoin regtest + consensus.EcashForkBits = 0x207fffff; // regtest powLimit: the fork block keeps regtest difficulty consensus.signet_blocks = false; consensus.signet_challenge.clear(); consensus.nSubsidyHalvingInterval = 150; diff --git a/src/kernel/chainparams.h b/src/kernel/chainparams.h index f7209bee1b06..aacd5fa4ac95 100644 --- a/src/kernel/chainparams.h +++ b/src/kernel/chainparams.h @@ -152,6 +152,7 @@ class CChainParams std::unordered_map activation_heights{}; bool fastprune{false}; bool enforce_bip94{false}; + int ecash_height{0}; //!< regtest only: fork height for eCash rules + the Bitcoin block feed (tests) }; static std::unique_ptr RegTest(const RegTestOptions& options); diff --git a/src/net.cpp b/src/net.cpp index 2da4b4b90733..de583283f4df 100644 --- a/src/net.cpp +++ b/src/net.cpp @@ -61,6 +61,19 @@ TRACEPOINT_SEMAPHORE(net, outbound_message); * under our own magic. */ static constexpr MessageStartChars BITCOIN_MAINNET_MAGIC{0xf9, 0xbe, 0xb4, 0xd9}; +/** Magic spoken to -bitcoinpeer peers: Bitcoin mainnet's, or -bitcoinpeermagic on regtest (tests). */ +static MessageStartChars BitcoinPeerMagic() +{ + if (Params().GetChainType() == ChainType::REGTEST) { + if (const auto bytes{TryParseHex(gArgs.GetArg("-bitcoinpeermagic", ""))}; bytes && bytes->size() == 4) { + MessageStartChars magic; + std::copy(bytes->begin(), bytes->end(), magic.begin()); + return magic; + } + } + return BITCOIN_MAINNET_MAGIC; +} + /** Maximum number of block-relay-only anchor connections */ static constexpr size_t MAX_BLOCK_RELAY_ONLY_ANCHORS = 2; static_assert (MAX_BLOCK_RELAY_ONLY_ANCHORS <= static_cast(MAX_BLOCK_RELAY_ONLY_CONNECTIONS), "MAX_BLOCK_RELAY_ONLY_ANCHORS must not exceed MAX_BLOCK_RELAY_ONLY_CONNECTIONS."); @@ -554,7 +567,7 @@ CNode* CConnman::ConnectNode(CAddress addrConnect, .i2p_sam_session = std::move(i2p_transient_session), .recv_flood_size = nReceiveFloodSize, .use_v2transport = use_v2transport, - .magic = bitcoin_magic ? std::optional{BITCOIN_MAINNET_MAGIC} : std::nullopt, + .magic = bitcoin_magic ? std::optional{BitcoinPeerMagic()} : std::nullopt, }); pnode->AddRef(); diff --git a/src/net_processing.cpp b/src/net_processing.cpp index 430354d679b8..df2cfdaf4fe8 100644 --- a/src/net_processing.cpp +++ b/src/net_processing.cpp @@ -41,6 +41,7 @@ #include #include #include +#include #include #include #include @@ -141,10 +142,31 @@ static constexpr int BRIDGE_FOREIGN_KEEP_HEIGHTS{2000}; static constexpr size_t BRIDGE_MAX_FOREIGN_HEADERS{60000}; /** Bridge: file (in the network datadir) holding the last fed " ". */ static constexpr const char* BRIDGE_ANCHOR_FILE{"bridgefeed.dat"}; -/** Bridge: transactions rejected for a reason that may clear later are held this long and re-offered. */ -static constexpr size_t BRIDGE_HELD_TXS_MAX{2000}; -static constexpr auto BRIDGE_HELD_TXS_MAX_AGE{24h}; +/** Bridge: refused only on fee, which a child paying for the transaction can cure as a package (Core's + * package-reconsiderable fee failures; the RBF ones are excluded, package RBF being one-parent-one-child). */ +static bool BridgeBelowFloor(const std::string& reason) +{ + return reason == "min relay fee not met" || reason == "mempool min fee not met" || reason == "mempool full"; +} +/** Bridge: file (in the network datadir) holding the queue across a restart. */ +static constexpr const char* BRIDGE_HELD_FILE{"bridgeheld.dat"}; static constexpr auto BRIDGE_HELD_TXS_RETRY_INTERVAL{10min}; +/** Bridge: a tracked transaction mined here is remembered for this many of our blocks, so that one spending it is not + * taken for an orphan when our chain reorganises, or restarts behind where it was. */ +static constexpr int BRIDGE_CONFIRMED_KEEP_BLOCKS{12}; +/** Bridge: at most this many of our new blocks are read for confirmations in one pass. Past a longer gap the reading + * restarts at the tip, and what was mined in the gap is offered once more and found already here. */ +static constexpr int BRIDGE_SCAN_MAX_BLOCKS{144}; +/** Bridge: after startup, how long the catch-up gate waits for a header that reaches the height it last read at. */ +static constexpr auto BRIDGE_CATCHUP_GRACE{2min}; +/** Bridge: how often an entry skipped for its feerate is offered anyway (prioritisetransaction; or it went dead). */ +static constexpr auto BRIDGE_FEE_RECHECK_INTERVAL{1h}; +/** Bridge: bridgeheld.dat, version 2. The magic is larger than any count a version 1 file starts with. */ +static constexpr uint64_t BRIDGE_HELD_MAGIC{0x3244'4c45'4847'4442}; +static constexpr uint32_t BRIDGE_HELD_VERSION{2}; +/** Bridge: the queue and the anchor are written at most this often (and at shutdown). A crash re-feeds the Bitcoin + * blocks since the last write, which is harmless: nothing tracked is fed twice. */ +static constexpr auto BRIDGE_SAVE_INTERVAL{60s}; /** Default time during which a peer must stall block download progress before being disconnected. * the actual timeout is increased temporarily if peers are disconnected for hitting the timeout */ static constexpr auto BLOCK_STALLING_TIMEOUT_DEFAULT{2s}; @@ -562,6 +584,7 @@ class PeerManagerImpl final : public PeerManager PeerManagerImpl(CConnman& connman, AddrMan& addrman, BanMan* banman, ChainstateManager& chainman, CTxMemPool& pool, node::Warnings& warnings, Options opts); + void FlushBridge() override; /** Overridden from CValidationInterface. */ void ActiveTipChange(const CBlockIndex& new_tip, bool) override @@ -721,7 +744,14 @@ class PeerManagerImpl final : public PeerManager /** Bridge: create the peer's ForeignChain, seeded with the persisted anchor and its ancestry. */ void BridgeInitPeer(NodeId nodeid) EXCLUSIVE_LOCKS_REQUIRED(cs_main); /** Bridge: persist the last fed block. */ - void BridgeSaveAnchor() EXCLUSIVE_LOCKS_REQUIRED(cs_main); + bool BridgeWriteAnchor(const std::pair& anchor) const; + fs::path BridgeHeldPath() const; + /** Bridge: write the held queue, so a restart does not silently drop what it is holding. */ + bool BridgeSaveHeld() EXCLUSIVE_LOCKS_REQUIRED(NetEventsInterface::g_msgproc_mutex); + /** Bridge: write the queue, then the anchor, if either changed and BRIDGE_SAVE_INTERVAL has passed (or `force`). */ + void BridgeMaybeSave(bool force) EXCLUSIVE_LOCKS_REQUIRED(NetEventsInterface::g_msgproc_mutex) LOCKS_EXCLUDED(::cs_main); + /** Bridge: read the held queue back at the first offer after startup. */ + void BridgeLoadHeld() EXCLUSIVE_LOCKS_REQUIRED(NetEventsInterface::g_msgproc_mutex); /** Bridge: locator from the foreign tip down through the anchor and the fork parent into our chain. */ CBlockLocator BridgeLocator(NodeId nodeid) EXCLUSIVE_LOCKS_REQUIRED(cs_main); bool BridgeKnowsBlock(NodeId nodeid, const uint256& hash) EXCLUSIVE_LOCKS_REQUIRED(cs_main); @@ -735,11 +765,41 @@ class PeerManagerImpl final : public PeerManager bool BridgeProcessBlock(CNode& pfrom, Peer& peer, const std::shared_ptr& block) EXCLUSIVE_LOCKS_REQUIRED(g_msgproc_mutex, !m_peer_mutex, !m_tx_download_mutex); enum class BridgeTxResult { ACCEPTED, KNOWN, MISSING, HELD, OTHER }; + /** Bridge: WAITING was refused for a reason that may clear and is offered again; FED is in our mempool and is + * watched until it is mined here, so that a mempool we lose (an unclean stop leaves no mempool.dat) is fed again. */ + enum class BridgeState : uint8_t { WAITING, FED }; + /** Bridge: the inputs of a transaction here. AVAILABLE: all present (chain or mempool). WAITS: some missing, each + * from a transaction we track and expect. DEAD: one missing from a transaction we don't -- spent, or never here. */ + enum class BridgeInputs { AVAILABLE, WAITS, DEAD }; /** Bridge: offer one Bitcoin transaction to the mempool as sendrawtransaction would; `reason` is set unless accepted or known. */ - BridgeTxResult BridgeOfferTx(const CTransactionRef& tx, std::string& reason) + BridgeTxResult BridgeOfferTx(const CTransactionRef& tx, std::string& reason, std::optional* feerate = nullptr) EXCLUSIVE_LOCKS_REQUIRED(g_msgproc_mutex, !m_peer_mutex, !m_tx_download_mutex); - /** Bridge: keep a transaction for BridgeRetryHeldTxs (oldest evicted beyond BRIDGE_HELD_TXS_MAX). */ - void BridgeHoldTx(const CTransactionRef& tx, std::chrono::microseconds now) EXCLUSIVE_LOCKS_REQUIRED(g_msgproc_mutex); + /** Bridge: offer held parents together with the held child that pays for them (sorted, child last). + * Returns the transactions that are in the mempool afterwards. */ + std::set BridgeOfferPackage(const Package& package) + EXCLUSIVE_LOCKS_REQUIRED(NetEventsInterface::g_msgproc_mutex, !m_peer_mutex, !m_tx_download_mutex); + /** Bridge: whether any input of `tx` spends a held transaction. */ + bool BridgeHasHeldParent(const CTransaction& tx) const + EXCLUSIVE_LOCKS_REQUIRED(NetEventsInterface::g_msgproc_mutex); + /** Bridge: the held parents of `tx` (in input order, each once). */ + std::vector BridgeHeldParents(const CTransaction& tx) const + EXCLUSIVE_LOCKS_REQUIRED(NetEventsInterface::g_msgproc_mutex); + /** Bridge: start tracking a transaction, WAITING or FED. Never refuses: when the queue is full the feed pauses + * instead (BridgeRequestBlocks). `feerate` is set when it was refused on fee. */ + void BridgeTrackTx(const CTransactionRef& tx, BridgeState state, std::chrono::microseconds now, int height, std::optional feerate) + EXCLUSIVE_LOCKS_REQUIRED(g_msgproc_mutex); + /** Bridge: whether we track `txid`: waiting, fed, or mined here lately. */ + bool BridgeTracked(const Txid& txid) const EXCLUSIVE_LOCKS_REQUIRED(NetEventsInterface::g_msgproc_mutex); + /** Bridge: `tx` can never go in; note the outputs of waiting transactions it spends. */ + void BridgeNoteDeadSpends(const CTransaction& tx) EXCLUSIVE_LOCKS_REQUIRED(NetEventsInterface::g_msgproc_mutex); + /** Bridge: drop each waiting transaction whose dust (an anchor) was spent by one that can never go in -- its fee + * payer -- and everything waiting behind it. */ + void BridgeDropUnpaid(int tip_height) EXCLUSIVE_LOCKS_REQUIRED(NetEventsInterface::g_msgproc_mutex); + BridgeInputs BridgeClassifyInputs(const CTransaction& tx) EXCLUSIVE_LOCKS_REQUIRED(NetEventsInterface::g_msgproc_mutex, cs_main); + /** Bridge: whether the node is caught up enough to judge Bitcoin transactions at all. */ + bool BridgeReady(int tip_height, int best_header_height) EXCLUSIVE_LOCKS_REQUIRED(NetEventsInterface::g_msgproc_mutex); + /** Bridge: read our blocks since the last pass; returns the tracked transactions mined in them. */ + std::unordered_set BridgeScanMined() EXCLUSIVE_LOCKS_REQUIRED(NetEventsInterface::g_msgproc_mutex) LOCKS_EXCLUDED(::cs_main); /** Bridge: re-offer held transactions once our tip has moved or enough time has passed. */ void BridgeRetryHeldTxs(std::chrono::microseconds now) EXCLUSIVE_LOCKS_REQUIRED(g_msgproc_mutex, !m_peer_mutex, !m_tx_download_mutex); @@ -904,11 +964,42 @@ class PeerManagerImpl final : public PeerManager std::pair m_bridge_anchor GUARDED_BY(cs_main){uint256{}, -1}; bool m_bridge_anchor_loaded GUARDED_BY(cs_main){false}; bool m_bridge_anchor_dirty GUARDED_BY(cs_main){false}; - /** Bridge: transactions rejected for a reason that may clear later, with when they were first offered. */ - std::deque> m_bridge_held_txs GUARDED_BY(NetEventsInterface::g_msgproc_mutex); - std::set m_bridge_held_txids GUARDED_BY(NetEventsInterface::g_msgproc_mutex); + /** Bridge: a Bitcoin transaction we keep track of. */ + struct BridgeHeld { + CTransactionRef tx; + std::chrono::microseconds since; //!< when it was first fed + int since_height; //!< our tip when it last began waiting: its age is counted in our blocks + int64_t weight; //!< its weight, kept rather than recomputed on every pass + std::optional feerate; //!< while refused on fee: offered again only once the floor falls to it + BridgeState state{BridgeState::WAITING}; + }; + /** Bridge: tracked transactions, WAITING and FED, in feed order (parents before children). */ + std::deque m_bridge_held_txs GUARDED_BY(NetEventsInterface::g_msgproc_mutex); + /** Bridge: txids of the FED entries (m_bridge_held_txids has the WAITING ones). */ + std::unordered_set m_bridge_fed_txids GUARDED_BY(NetEventsInterface::g_msgproc_mutex); + /** Bridge: tracked transactions mined here lately, with the height, oldest first (BRIDGE_CONFIRMED_KEEP_BLOCKS). */ + std::deque> m_bridge_recent GUARDED_BY(NetEventsInterface::g_msgproc_mutex); + std::unordered_set m_bridge_recent_txids GUARDED_BY(NetEventsInterface::g_msgproc_mutex); + /** Bridge: the last of our blocks read for confirmations. Nothing is done until our chain is back at its height. */ + std::pair m_bridge_scanned GUARDED_BY(NetEventsInterface::g_msgproc_mutex){uint256{}, -1}; + std::unordered_set m_bridge_held_txids GUARDED_BY(NetEventsInterface::g_msgproc_mutex); uint256 m_bridge_held_tip GUARDED_BY(NetEventsInterface::g_msgproc_mutex); std::chrono::microseconds m_bridge_held_retried_at GUARDED_BY(NetEventsInterface::g_msgproc_mutex){0us}; + uint64_t m_bridge_held_weight GUARDED_BY(NetEventsInterface::g_msgproc_mutex){0}; + /** Bridge: the part of that held from our last BRIDGE_HELD_YOUNG_BLOCKS blocks; the feed pauses at the limit. */ + uint64_t m_bridge_young_weight GUARDED_BY(NetEventsInterface::g_msgproc_mutex){0}; + bool m_bridge_feed_paused GUARDED_BY(NetEventsInterface::g_msgproc_mutex){false}; + bool m_bridge_parked_warned GUARDED_BY(NetEventsInterface::g_msgproc_mutex){false}; + bool m_bridge_held_dirty GUARDED_BY(NetEventsInterface::g_msgproc_mutex){false}; + bool m_bridge_held_loaded GUARDED_BY(NetEventsInterface::g_msgproc_mutex){false}; + SteadyClock::time_point m_bridge_saved_at GUARDED_BY(NetEventsInterface::g_msgproc_mutex){}; + SteadyClock::time_point m_bridge_loaded_at GUARDED_BY(NetEventsInterface::g_msgproc_mutex){}; + bool m_bridge_catchup_logged GUARDED_BY(NetEventsInterface::g_msgproc_mutex){false}; + std::chrono::microseconds m_bridge_fee_rechecked_at GUARDED_BY(NetEventsInterface::g_msgproc_mutex){0us}; + /** Bridge: a fed block left a transaction below the fee floor; retry at once so a child can carry it. */ + bool m_bridge_retry_now GUARDED_BY(NetEventsInterface::g_msgproc_mutex){false}; + /** Bridge: outputs of WAITING transactions that a transaction which can never go in spends, for BridgeDropUnpaid. */ + std::map> m_bridge_dead_spends GUARDED_BY(NetEventsInterface::g_msgproc_mutex); /** Bridge: the Bitcoin peer fetching blocks. Others only follow headers until it leaves, stalls or idles. */ NodeId m_bridge_feed_peer GUARDED_BY(cs_main){-1}; /** Bridge: recently fed block hashes, so a peer that learns of one later does not queue it again. */ @@ -2106,6 +2197,15 @@ std::unique_ptr PeerManager::make(CConnman& connman, AddrMan& addrm return std::make_unique(connman, addrman, banman, chainman, pool, warnings, opts); } +void PeerManagerImpl::FlushBridge() +{ + // The last word on what the bridge tracks: Shutdown calls this after stopping connman (so the message handler no + // longer holds g_msgproc_mutex) and before the mempool and chainstate go. + AssertLockNotHeld(NetEventsInterface::g_msgproc_mutex); + LOCK(NetEventsInterface::g_msgproc_mutex); + if (m_bridge_held_loaded) BridgeMaybeSave(/*force=*/true); +} + PeerManagerImpl::PeerManagerImpl(CConnman& connman, AddrMan& addrman, BanMan* banman, ChainstateManager& chainman, CTxMemPool& pool, node::Warnings& warnings, Options opts) @@ -2770,21 +2870,169 @@ void PeerManagerImpl::BridgeInitPeer(NodeId nodeid) fc.tip_height = m_bridge_anchor.second; } -void PeerManagerImpl::BridgeSaveAnchor() +bool PeerManagerImpl::BridgeWriteAnchor(const std::pair& anchor) const { - AssertLockHeld(cs_main); - m_bridge_anchor_dirty = false; const fs::path path{BridgeAnchorPath()}; const fs::path tmp{path + ".tmp"}; + FILE* file{fsbridge::fopen(tmp, "wb")}; + if (!file) { + LogWarning("bridge: cannot write %s\n", fs::PathToString(tmp)); + return false; + } + const std::string line{strprintf("%s %d\n", anchor.first.ToString(), anchor.second)}; + const bool written{std::fwrite(line.data(), 1, line.size(), file) == line.size() && FileCommit(file)}; + if (std::fclose(file) != 0 || !written) { + LogWarning("bridge: cannot write %s\n", fs::PathToString(tmp)); + return false; + } + if (!RenameOver(tmp, path)) { + LogWarning("bridge: cannot rename %s\n", fs::PathToString(tmp)); + return false; + } + return true; +} + +fs::path PeerManagerImpl::BridgeHeldPath() const +{ + return m_chainman.m_options.datadir / BRIDGE_HELD_FILE; +} + +bool PeerManagerImpl::BridgeSaveHeld() +{ + AssertLockHeld(g_msgproc_mutex); + const fs::path path{BridgeHeldPath()}; + if (m_bridge_held_txs.empty() && m_bridge_recent.empty()) { + fs::remove(path); + DirectoryCommit(path.parent_path()); + m_bridge_held_dirty = false; + return true; + } + const fs::path tmp{path + ".tmp"}; { - std::ofstream out{tmp.std_path(), std::ios::trunc}; - out << m_bridge_anchor.first.ToString() << ' ' << m_bridge_anchor.second << '\n'; - if (!out) { + AutoFile out{fsbridge::fopen(tmp, "wb")}; + if (out.IsNull()) { LogWarning("bridge: cannot write %s\n", fs::PathToString(tmp)); - return; + return false; + } + try { + out << BRIDGE_HELD_MAGIC << BRIDGE_HELD_VERSION << m_bridge_scanned.first << m_bridge_scanned.second; + out << static_cast(m_bridge_held_txs.size()); + for (const BridgeHeld& entry : m_bridge_held_txs) { + out << TX_WITH_WITNESS(*entry.tx) << static_cast(count_microseconds(entry.since)) + << entry.since_height << static_cast(entry.state); + } + out << static_cast(m_bridge_recent.size()); + for (const auto& [txid, height] : m_bridge_recent) out << txid.ToUint256() << height; + // On disk before it replaces the old file, so a power cut leaves one or the other, whole. + if (!out.Commit()) throw std::runtime_error("cannot sync"); + if (out.fclose() != 0) throw std::runtime_error("cannot close"); + } catch (const std::exception& e) { + LogWarning("bridge: cannot write %s: %s\n", fs::PathToString(tmp), e.what()); + return false; } } - if (!RenameOver(tmp, path)) LogWarning("bridge: cannot rename %s\n", fs::PathToString(tmp)); + if (!RenameOver(tmp, path)) { + LogWarning("bridge: cannot rename %s\n", fs::PathToString(tmp)); + return false; + } + // The rename itself on disk before the anchor's: ordering must not rest on the filesystem. + DirectoryCommit(path.parent_path()); + m_bridge_held_dirty = false; + return true; +} + +void PeerManagerImpl::BridgeMaybeSave(bool force) +{ + AssertLockHeld(g_msgproc_mutex); + AssertLockNotHeld(::cs_main); + const auto [anchor, anchor_dirty]{WITH_LOCK(cs_main, return std::make_pair(m_bridge_anchor, m_bridge_anchor_dirty))}; + if (!m_bridge_held_dirty && !anchor_dirty) return; + const auto now{SteadyClock::now()}; + if (!force && now - m_bridge_saved_at < BRIDGE_SAVE_INTERVAL) return; + m_bridge_saved_at = now; + // The anchor says how far Bitcoin has been fed, so it must never be on disk ahead of the queue holding what those + // blocks left behind: the queue is written and synced first, and the anchor only once that has succeeded. A crash + // in between re-feeds a block, which is harmless -- nothing tracked is fed twice. + if (m_bridge_held_dirty && !BridgeSaveHeld()) return; + if (anchor_dirty && BridgeWriteAnchor(anchor)) { + LOCK(cs_main); + if (m_bridge_anchor == anchor) m_bridge_anchor_dirty = false; + } +} + +void PeerManagerImpl::BridgeLoadHeld() +{ + AssertLockHeld(g_msgproc_mutex); + m_bridge_held_loaded = true; + m_bridge_loaded_at = SteadyClock::now(); + m_bridge_fee_rechecked_at = GetTime(); + const int height{WITH_LOCK(cs_main, return m_chainman.ActiveChain().Height())}; + AutoFile in{fsbridge::fopen(BridgeHeldPath(), "rb")}; + if (in.IsNull()) return; + const auto add{[&](CTransactionRef tx, int64_t since, int since_height, BridgeState state) EXCLUSIVE_LOCKS_REQUIRED(NetEventsInterface::g_msgproc_mutex) { + const Txid txid{tx->GetHash()}; + if (m_bridge_held_txids.contains(txid) || m_bridge_fed_txids.contains(txid)) return; // first one wins + const int64_t weight{GetTransactionWeight(*tx)}; + m_bridge_held_txs.push_back(BridgeHeld{std::move(tx), std::chrono::microseconds{since}, since_height, weight, std::nullopt, state}); + if (state == BridgeState::WAITING) { + m_bridge_held_txids.insert(txid); + m_bridge_held_weight += weight; + if (since_height > height - BRIDGE_HELD_YOUNG_BLOCKS) m_bridge_young_weight += weight; + } else { + m_bridge_fed_txids.insert(txid); + } + }}; + try { + uint64_t first{0}; + in >> first; + if (first == BRIDGE_HELD_MAGIC) { + uint32_t version{0}; + in >> version; + if (version != BRIDGE_HELD_VERSION) { + LogWarning("bridge: %s is version %u, which this node cannot read; starting without it\n", + fs::PathToString(BridgeHeldPath()), version); + return; + } + in >> m_bridge_scanned.first >> m_bridge_scanned.second; + uint64_t count{0}; + in >> count; + for (uint64_t i = 0; i < count; ++i) { + CMutableTransaction mtx; + int64_t since{0}; + int since_height{0}; + uint8_t state{0}; + in >> TX_WITH_WITNESS(mtx) >> since >> since_height >> state; + add(MakeTransactionRef(std::move(mtx)), since, since_height, state == uint8_t(BridgeState::FED) ? BridgeState::FED : BridgeState::WAITING); + } + uint64_t recent{0}; + in >> recent; + for (uint64_t i = 0; i < recent; ++i) { + uint256 hash; + int mined_at{0}; + in >> hash >> mined_at; + const Txid txid{Txid::FromUint256(hash)}; + if (m_bridge_recent_txids.insert(txid).second) m_bridge_recent.emplace_back(txid, mined_at); + } + } else { + // Version 1: a count, then transactions, all waiting; how long they have waited in our blocks is unknown. + for (uint64_t i = 0; i < first; ++i) { + CMutableTransaction mtx; + int64_t since{0}; + in >> TX_WITH_WITNESS(mtx) >> since; + add(MakeTransactionRef(std::move(mtx)), since, height, BridgeState::WAITING); + } + } + } catch (const std::exception& e) { + // Only the tail is lost, and it is lost for good: the anchor has passed those blocks, so they are not fed + // again (a rewind -- removing bridgefeed.dat -- is the recovery). The file is synced to a temporary and renamed + // over, so neither a crash nor a power cut mid-write can cause this; only damage to the file can. + LogWarning("bridge: %s is unreadable past %u transactions: %s\n", + fs::PathToString(BridgeHeldPath()), m_bridge_held_txs.size(), e.what()); + } + if (!m_bridge_held_txs.empty()) { + LogInfo("bridge: resuming with %u held and %u fed Bitcoin transaction(s), %u WU held\n", + m_bridge_held_txids.size(), m_bridge_fed_txids.size(), m_bridge_held_weight); + } } CBlockLocator PeerManagerImpl::BridgeLocator(NodeId nodeid) @@ -2924,6 +3172,12 @@ void PeerManagerImpl::BridgeRequestBlocks(CNode& node, Peer& peer, std::chrono:: if (fork_height <= 0) return; // Until the fork parent is connected, our UTXO set cannot judge Bitcoin's post-fork transactions. if (m_chainman.ActiveChain().Height() < fork_height - 1) return; + // Not before what we track is loaded, nor while we are catching up (BridgeReady): fed then, a transaction whose + // parent is in a mempool.dat not yet read, or was mined in a block not yet reconnected, looks like an orphan. + if (!m_bridge_held_loaded || !BridgeReady(m_chainman.ActiveChain().Height(), + m_chainman.m_best_header ? m_chainman.m_best_header->nHeight : -1)) { + return; + } ForeignChain& fc{*Assert(State(node.GetId())->m_foreign)}; // Announcements normally keep us current; poll now and then in case one was missed. @@ -2935,6 +3189,20 @@ void PeerManagerImpl::BridgeRequestBlocks(CNode& node, Peer& peer, std::chrono:: } if (fc.pending.empty()) return; + // Back-pressure. Nothing held is ever refused, so this is what bounds the queue: while what we hold from our + // recent blocks is at the limit, fetch no more. The blocks wait on the Bitcoin node, not in our memory. + if (m_bridge_young_weight >= m_opts.bridge_held_max_weight) { + if (!m_bridge_feed_paused) { + m_bridge_feed_paused = true; + LogInfo("bridge: holding %u WU from our last %d blocks (limit %u); pausing the Bitcoin feed until it drains\n", + m_bridge_young_weight, BRIDGE_HELD_YOUNG_BLOCKS, m_opts.bridge_held_max_weight); + } + return; + } + if (m_bridge_feed_paused) { + m_bridge_feed_paused = false; + LogInfo("bridge: resuming the Bitcoin feed (%u WU held from our last %d blocks)\n", m_bridge_young_weight, BRIDGE_HELD_YOUNG_BLOCKS); + } // One peer feeds at a time so blocks are fetched and offered strictly oldest-first. Another // peer takes the slot when the feeder is gone, has nothing queued, or has not been served // within BRIDGE_BLOCK_TIMEOUT. @@ -2962,7 +3230,7 @@ void PeerManagerImpl::BridgeRequestBlocks(CNode& node, Peer& peer, std::chrono:: MakeAndPushMessage(node, NetMsgType::GETDATA, std::vector{{MSG_BLOCK | MSG_WITNESS_FLAG, hash}}); } -PeerManagerImpl::BridgeTxResult PeerManagerImpl::BridgeOfferTx(const CTransactionRef& tx, std::string& reason) +PeerManagerImpl::BridgeTxResult PeerManagerImpl::BridgeOfferTx(const CTransactionRef& tx, std::string& reason, std::optional* feerate) { AssertLockHeld(g_msgproc_mutex); LOCK2(cs_main, m_tx_download_mutex); @@ -2981,75 +3249,498 @@ PeerManagerImpl::BridgeTxResult PeerManagerImpl::BridgeOfferTx(const CTransactio break; } reason = result.m_state.GetRejectReason(); + if (feerate) *feerate = result.m_effective_feerate; // set for fee refusals only LogDebug(BCLog::MEMPOOL, "bridge: Bitcoin tx %s not accepted: %s\n", tx->GetHash().ToString(), result.m_state.ToString()); const TxValidationResult what{result.m_state.GetResult()}; if (what == TxValidationResult::TX_MISSING_INPUTS) { - // A child of a held transaction is held with it (parents precede children in the queue). - for (const CTxIn& in : tx->vin) { - if (m_bridge_held_txids.count(in.prevout.hash) > 0) { - reason = "parent-held"; - return BridgeTxResult::HELD; - } + // Held only if every missing input comes from a transaction we track and expect (parents precede children in + // the queue). Any other missing input means it can never go in, however many of its parents merely wait. + if (BridgeClassifyInputs(*tx) == BridgeInputs::WAITS) { + reason = "parent-held"; + return BridgeTxResult::HELD; } return BridgeTxResult::MISSING; } - if (reason == "txn-already-in-mempool" || reason == "txn-already-known") return BridgeTxResult::KNOWN; + // Already here: in our mempool (possibly with another witness), or confirmed. + if (reason == "txn-already-in-mempool" || reason == "txn-same-nonwitness-data-in-mempool" || + reason == "txn-already-known") { + return BridgeTxResult::KNOWN; + } + // A version 3 transaction over the TRUC size cap can never be accepted, however long it waits. Its vsize + // here ignores the sigop adjustment, which only makes it larger, so nothing that could pass is dropped. + if (reason == "TRUC-violation" && tx->version == TRUC_VERSION && GetVirtualTransactionSize(*tx) > TRUC_MAX_VSIZE) { + return BridgeTxResult::OTHER; + } // May clear later: locktime or sequence not yet reached on our chain, ancestors still - // unconfirmed here, or mempool limits. - if (what == TxValidationResult::TX_PREMATURE_SPEND || reason == "too-long-mempool-chain" || reason == "mempool full" || reason == "mempool min fee not met") { + // unconfirmed here, or mempool limits. These are the reasons a node running the cluster + // mempool emits for a condition that a later block clears; "too-long-mempool-chain" is + // pre-cluster-mempool and is never emitted, so a chain that overflows a cluster used to + // fall through to OTHER and be dropped, taking every later Bitcoin transaction that spends + // its outputs with it. + if (what == TxValidationResult::TX_PREMATURE_SPEND || + reason == "too-large-cluster" || + reason == "TRUC-violation" || + reason == "mempool full" || + reason == "mempool min fee not met" || + reason == "min relay fee not met" || + // It conflicts with more of our mempool than a replacement may evict; clears as they are mined or expire. + reason.starts_with("too many potential replacements") || + // Its parent is in our mempool with ephemeral dust this transaction does not spend (a sibling spent it, + // or will). Every child must wait for that parent to confirm here; then it is accepted. + reason == "missing-ephemeral-spends") { return BridgeTxResult::HELD; } + // A replacement refused where no input actually conflicts: all it would replace is a TRUC sibling (its parent's one + // unconfirmed child slot is taken). That clears when the parent is mined here. Refused over a real conflict, it + // yields to the eCash spend that was here first, as the Python bridge does. + if (reason.starts_with("insufficient fee") || reason == "replacement-failed") { + LOCK(m_mempool.cs); + bool conflicts{false}; + for (const CTxIn& in : tx->vin) { + if (m_mempool.GetConflictTx(in.prevout)) { + conflicts = true; + break; + } + } + if (!conflicts) return BridgeTxResult::HELD; + } return BridgeTxResult::OTHER; } -void PeerManagerImpl::BridgeHoldTx(const CTransactionRef& tx, std::chrono::microseconds now) +bool PeerManagerImpl::BridgeHasHeldParent(const CTransaction& tx) const { AssertLockHeld(g_msgproc_mutex); - if (m_bridge_held_txs.size() >= BRIDGE_HELD_TXS_MAX) { - m_bridge_held_txids.erase(m_bridge_held_txs.front().first->GetHash()); - m_bridge_held_txs.pop_front(); + for (const CTxIn& in : tx.vin) { + if (m_bridge_held_txids.contains(in.prevout.hash)) return true; } - m_bridge_held_txs.emplace_back(tx, now); - m_bridge_held_txids.insert(tx->GetHash()); + return false; } -void PeerManagerImpl::BridgeRetryHeldTxs(std::chrono::microseconds now) +std::vector PeerManagerImpl::BridgeHeldParents(const CTransaction& tx) const { AssertLockHeld(g_msgproc_mutex); - if (m_bridge_held_txs.empty()) return; - const uint256 tip{WITH_LOCK(cs_main, return m_chainman.ActiveTip() ? m_chainman.ActiveTip()->GetBlockHash() : uint256{})}; - if (tip == m_bridge_held_tip && now - m_bridge_held_retried_at < BRIDGE_HELD_TXS_RETRY_INTERVAL) return; - m_bridge_held_tip = tip; - m_bridge_held_retried_at = now; - int accepted{0}, dropped{0}; - std::deque> held; - for (const auto& [tx, since] : m_bridge_held_txs) { - std::string reason; - bool keep{false}; - switch (BridgeOfferTx(tx, reason)) { - case BridgeTxResult::ACCEPTED: - ++accepted; + std::vector parents; + for (const CTxIn& in : tx.vin) { + if (m_bridge_held_txids.contains(in.prevout.hash) && + std::find(parents.begin(), parents.end(), in.prevout.hash) == parents.end()) { + parents.push_back(in.prevout.hash); + } + } + return parents; +} + +std::set PeerManagerImpl::BridgeOfferPackage(const Package& package) +{ + AssertLockHeld(g_msgproc_mutex); + LOCK2(cs_main, m_tx_download_mutex); + const PackageMempoolAcceptResult result{ProcessNewPackage(m_chainman.ActiveChainstate(), m_mempool, package, + /*test_accept=*/false, /*client_maxfeerate=*/std::nullopt)}; + std::set in_mempool; + for (const CTransactionRef& tx : package) { + const auto it{result.m_tx_results.find(tx->GetWitnessHash())}; + if (it == result.m_tx_results.end()) continue; + switch (it->second.m_result_type) { + case MempoolAcceptResult::ResultType::VALID: + m_mempool.AddUnbroadcastTx(tx->GetHash()); + m_txdownloadman.MempoolAcceptedTx(tx); + InitiateTxBroadcastToAll(tx->GetHash(), tx->GetWitnessHash()); + in_mempool.insert(tx->GetHash()); break; - case BridgeTxResult::HELD: - keep = now - since < BRIDGE_HELD_TXS_MAX_AGE; - if (!keep) ++dropped; + case MempoolAcceptResult::ResultType::MEMPOOL_ENTRY: + case MempoolAcceptResult::ResultType::DIFFERENT_WITNESS: + in_mempool.insert(tx->GetHash()); break; - case BridgeTxResult::KNOWN: - case BridgeTxResult::MISSING: - case BridgeTxResult::OTHER: - ++dropped; + case MempoolAcceptResult::ResultType::INVALID: break; } - if (keep) { - held.emplace_back(tx, since); + } + if (!in_mempool.contains(package.back()->GetHash())) { + LogDebug(BCLog::MEMPOOL, "bridge: package of %u with child %s not accepted: %s\n", package.size(), + package.back()->GetHash().ToString(), result.m_state.ToString()); + } + return in_mempool; +} + +void PeerManagerImpl::BridgeTrackTx(const CTransactionRef& tx, BridgeState state, std::chrono::microseconds now, int height, std::optional feerate) +{ + AssertLockHeld(g_msgproc_mutex); + // Never refused, however full the queue: a transaction turned away here is never fed again, nor is anything + // spending it. BridgeRequestBlocks stops fetching instead, until the queue drains. + const int64_t weight{GetTransactionWeight(*tx)}; + m_bridge_held_txs.push_back(BridgeHeld{tx, now, height, weight, feerate, state}); + if (state == BridgeState::WAITING) { + m_bridge_held_txids.insert(tx->GetHash()); + m_bridge_held_weight += weight; + m_bridge_young_weight += weight; + } else { + m_bridge_fed_txids.insert(tx->GetHash()); + } + m_bridge_held_dirty = true; +} + +bool PeerManagerImpl::BridgeTracked(const Txid& txid) const +{ + AssertLockHeld(g_msgproc_mutex); + return m_bridge_held_txids.contains(txid) || m_bridge_fed_txids.contains(txid) || m_bridge_recent_txids.contains(txid); +} + +void PeerManagerImpl::BridgeNoteDeadSpends(const CTransaction& tx) +{ + AssertLockHeld(g_msgproc_mutex); + for (const CTxIn& in : tx.vin) { + if (m_bridge_held_txids.contains(in.prevout.hash)) m_bridge_dead_spends[in.prevout.hash].push_back(in.prevout.n); + } +} + +void PeerManagerImpl::BridgeDropUnpaid(int tip_height) +{ + AssertLockHeld(g_msgproc_mutex); + // Policy admits a transaction with dust (an anchor) only at zero fee, prioritisetransaction or not, and only in a + // package with a child that spends the dust. On Bitcoin that child was its fee payer. When the fee payer can never + // go in here -- typically it added a post-fork Bitcoin coin to pay the fee -- nothing we hold can carry the + // transaction: it is unpaid, and would wait forever with everything behind it. (Anyone can still put it in here + // with a new fee payer: an anchor is spendable by anyone.) Dropped at once, not at the next pass, so that no save + // of the queue can keep it after the fee payer is gone. + int unpaid{0}, behind{0}; + while (!m_bridge_dead_spends.empty()) { + std::map> spends; + std::swap(spends, m_bridge_dead_spends); + std::unordered_set gone; + std::deque kept; + for (BridgeHeld& entry : m_bridge_held_txs) { + const CTransaction& tx{*entry.tx}; + bool drop{false}; + if (entry.state == BridgeState::WAITING) { + if (const auto it{spends.find(tx.GetHash())}; it != spends.end()) { + for (const uint32_t n : it->second) { + if (n < tx.vout.size() && IsDust(tx.vout[n], m_mempool.m_opts.dust_relay_feerate)) drop = true; + } + if (drop) ++unpaid; + } + if (!drop && std::any_of(tx.vin.begin(), tx.vin.end(), [&](const CTxIn& in) { return gone.contains(in.prevout.hash); })) { + drop = true; + ++behind; + } + } + if (!drop) { + kept.push_back(std::move(entry)); + continue; + } + gone.insert(tx.GetHash()); + m_bridge_held_txids.erase(tx.GetHash()); + BridgeNoteDeadSpends(tx); // it may in turn have been the fee payer of another waiting transaction + } + m_bridge_held_txs = std::move(kept); + } + if (unpaid + behind == 0) return; + m_bridge_held_weight = 0; + m_bridge_young_weight = 0; + for (const BridgeHeld& entry : m_bridge_held_txs) { + if (entry.state != BridgeState::WAITING) continue; + m_bridge_held_weight += entry.weight; + if (entry.since_height > tip_height - BRIDGE_HELD_YOUNG_BLOCKS) m_bridge_young_weight += entry.weight; + } + m_bridge_held_dirty = true; + LogInfo("bridge: dropped %d held Bitcoin transactions whose fee payer can never go in, and %d waiting behind them\n", + unpaid, behind); +} + +PeerManagerImpl::BridgeInputs PeerManagerImpl::BridgeClassifyInputs(const CTransaction& tx) +{ + AssertLockHeld(g_msgproc_mutex); + AssertLockHeld(cs_main); + LOCK(m_mempool.cs); + // GetCoin, not HaveCoin: CCoinsViewMemPool overrides only GetCoin, and HaveCoin would skip the mempool. + const CCoinsViewMemPool view{&m_chainman.ActiveChainstate().CoinsTip(), m_mempool}; + bool waits{false}; + for (const CTxIn& in : tx.vin) { + if (view.GetCoin(in.prevout)) continue; + // Missing. From a transaction we track it waits: held, fed but lost from our mempool (it is offered again + // first), or mined here lately (our chain may yet come back to that block). From any other it is spent or was + // never here, and the transaction can never go in. + if (!BridgeTracked(in.prevout.hash)) return BridgeInputs::DEAD; + waits = true; + } + return waits ? BridgeInputs::WAITS : BridgeInputs::AVAILABLE; +} + +bool PeerManagerImpl::BridgeReady(int tip_height, int best_header_height) +{ + AssertLockHeld(g_msgproc_mutex); + // Not while the node is catching up. mempool.dat is loaded only after the blocks are imported, while networking + // already runs; and after a crash our chain restarts where it was last flushed -- up to an hour behind -- so what + // we saw mined may not be mined here again yet. Judging Bitcoin transactions then takes their children for orphans. + if (!m_mempool.GetLoadTried() || m_chainman.m_blockman.LoadingBlocks() || m_chainman.IsInitialBlockDownload()) return false; + if (tip_height >= m_bridge_scanned.second) { + m_bridge_catchup_logged = false; + return true; + } + // Behind the height we last read at. Wait while a known header reaches it (we are re-syncing), and briefly after + // startup while headers arrive. Otherwise it is not coming back -- a wiped or reset chain, an invalidated branch, + // a reorganisation to fewer blocks -- so read from here rather than stall. + if (best_header_height >= m_bridge_scanned.second || SteadyClock::now() - m_bridge_loaded_at < BRIDGE_CATCHUP_GRACE) { + if (!m_bridge_catchup_logged) { + LogInfo("bridge: waiting for our chain to reach height %d (now %d) before judging Bitcoin transactions\n", + m_bridge_scanned.second, tip_height); + m_bridge_catchup_logged = true; + } + return false; + } + LogInfo("bridge: our chain is at %d, below the height %d we last read at, and no known header gets back there; reading from here\n", + tip_height, m_bridge_scanned.second); + m_bridge_scanned = {uint256{}, -1}; + m_bridge_catchup_logged = false; + return true; +} + +std::unordered_set PeerManagerImpl::BridgeScanMined() +{ + AssertLockHeld(g_msgproc_mutex); + AssertLockNotHeld(::cs_main); + std::vector connected, disconnected; + int fork_height{-1}, tip_height{-1}; + { + LOCK(cs_main); + const CChain& chain{m_chainman.ActiveChain()}; + const CBlockIndex* tip{chain.Tip()}; + if (!tip) return {}; + tip_height = tip->nHeight; + const CBlockIndex* last{m_bridge_scanned.first.IsNull() ? nullptr : m_chainman.m_blockman.LookupBlockIndex(m_bridge_scanned.first)}; + const CBlockIndex* fork{last ? chain.FindFork(last) : nullptr}; + m_bridge_scanned = {tip->GetBlockHash(), tip->nHeight}; + if (!fork || tip->nHeight - fork->nHeight > BRIDGE_SCAN_MAX_BLOCKS) { + // Nothing read yet, or a gap too long to read: start from here. What was mined in a gap is offered once + // more and found already here. + if (fork) LogInfo("bridge: %d of our blocks since the last pass; not reading them for confirmations\n", tip->nHeight - fork->nHeight); + return {}; + } + fork_height = fork->nHeight; + for (const CBlockIndex* index{tip}; index != fork; index = index->pprev) connected.push_back(index); + for (const CBlockIndex* index{last}; index != fork; index = index->pprev) disconnected.push_back(index); + } + // Reorganised away: what we saw mined above the fork is not mined any more. Take it back from those blocks (still on + // disk) and track it as fed again, at the front of the queue -- its parents are below the fork or earlier among + // these, and anything spending it comes later -- so the pass finds it in the mempool, offers it again, or sees it + // mined on the new branch. Untracked, a later mempool loss would take it and everything spending it. + std::unordered_set unmined; + while (!m_bridge_recent.empty() && m_bridge_recent.back().second > fork_height) { + unmined.insert(m_bridge_recent.back().first); + m_bridge_recent_txids.erase(m_bridge_recent.back().first); + m_bridge_recent.pop_back(); + } + if (!unmined.empty()) { + std::vector back; + for (auto it{disconnected.rbegin()}; it != disconnected.rend(); ++it) { + CBlock block; + if (!m_chainman.m_blockman.ReadBlock(block, **it)) continue; + for (const CTransactionRef& tx : block.vtx) { + if (unmined.contains(tx->GetHash())) back.push_back(tx); + } + } + const auto now{GetTime()}; + for (auto it{back.rbegin()}; it != back.rend(); ++it) { + const Txid& txid{(*it)->GetHash()}; + if (m_bridge_held_txids.contains(txid) || m_bridge_fed_txids.contains(txid)) continue; + m_bridge_held_txs.push_front(BridgeHeld{*it, now, tip_height, GetTransactionWeight(**it), std::nullopt, BridgeState::FED}); + m_bridge_fed_txids.insert(txid); + } + m_bridge_held_dirty = true; + LogInfo("bridge: %u fed transaction(s) mined in blocks our chain reorganised away are tracked again\n", back.size()); + } + std::unordered_set mined; + for (auto it{connected.rbegin()}; it != connected.rend(); ++it) { + CBlock block; + if (!m_chainman.m_blockman.ReadBlock(block, **it)) { + LogWarning("bridge: cannot read our block %s for confirmations\n", (*it)->GetBlockHash().ToString()); + continue; // what it mined is just offered once more + } + for (const CTransactionRef& tx : block.vtx) { + const Txid& txid{tx->GetHash()}; + if (!m_bridge_held_txids.contains(txid) && !m_bridge_fed_txids.contains(txid)) continue; + mined.insert(txid); + if (m_bridge_recent_txids.insert(txid).second) m_bridge_recent.emplace_back(txid, (*it)->nHeight); + } + } + // Buried deep enough: forget it. + while (!m_bridge_recent.empty() && m_bridge_recent.front().second <= tip_height - BRIDGE_CONFIRMED_KEEP_BLOCKS) { + m_bridge_recent_txids.erase(m_bridge_recent.front().first); + m_bridge_recent.pop_front(); + } + return mined; +} + +void PeerManagerImpl::BridgeRetryHeldTxs(std::chrono::microseconds now) +{ + AssertLockHeld(g_msgproc_mutex); + if (!m_bridge_held_loaded) BridgeLoadHeld(); + const auto [tip, tip_height, best_header_height]{WITH_LOCK(cs_main, return std::make_tuple( + m_chainman.ActiveTip() ? m_chainman.ActiveTip()->GetBlockHash() : uint256{}, m_chainman.ActiveChain().Height(), + m_chainman.m_best_header ? m_chainman.m_best_header->nHeight : -1))}; + if (!BridgeReady(tip_height, best_header_height)) return; + if (m_bridge_held_txs.empty() && m_bridge_recent.empty()) { + // Nothing tracked, so nothing to read our blocks for; keep our place so a later pass starts from here. + m_bridge_scanned = {tip, tip_height}; + return; + } + if (!m_bridge_retry_now && tip == m_bridge_held_tip && now - m_bridge_held_retried_at < BRIDGE_HELD_TXS_RETRY_INTERVAL) return; + m_bridge_held_tip = tip; + m_bridge_held_retried_at = now; + m_bridge_retry_now = false; + const auto started{SteadyClock::now()}; + const std::unordered_set mined{BridgeScanMined()}; + // What a fee refusal is measured against: the higher of the relay floor and the mempool's own. + const CFeeRate floor{std::max(m_mempool.GetMinFee(), m_mempool.m_opts.min_relay_feerate)}; + // Now and then offer what the fee floor skips anyway: prioritisetransaction may have lifted it, or it may have gone + // dead (an input spent here) and should leave, taking what waits behind it. + const bool recheck_fees{now - m_bridge_fee_rechecked_at >= BRIDGE_FEE_RECHECK_INTERVAL}; + if (recheck_fees) m_bridge_fee_rechecked_at = now; + int offered{0}, unasked{0}, accepted{0}, refed{0}, packaged{0}, known{0}, confirmed{0}, dropped{0}; + // Held transactions this pass found merely below the fee floor, with their place in `held`: a child that + // pays for them can take them in as a package. + std::map below_floor; + std::deque held; + for (BridgeHeld& entry : m_bridge_held_txs) { + const CTransactionRef tx{entry.tx}; + const Txid txid{tx->GetHash()}; + const bool was_fed{entry.state == BridgeState::FED}; + std::optional next; // unset: stop tracking it + if (mined.contains(txid)) { + // Mined here: done. It is remembered a while (m_bridge_recent), so nothing spending it is taken for an + // orphan if our chain reorganises or restarts behind this block. + ++confirmed; + } else if (was_fed && m_mempool.exists(txid)) { + next = BridgeState::FED; + } else { + // Waiting; or fed and since lost from our mempool (evicted, expired, or a restart without mempool.dat). + // Either way it is offered again, after its parents, which precede it and are settled by now. + bool waits{false}, carries{true}; + for (const CTxIn& in : tx->vin) { + if (!m_bridge_held_txids.contains(in.prevout.hash)) continue; + waits = true; + if (!below_floor.contains(in.prevout.hash)) carries = false; + } + if (waits) { + // A parent is still held, so this cannot go in by itself and the mempool need not be asked. It can go + // in as the child of a package when every held parent is refused only for being below the fee floor + // -- on Bitcoin this child paid for them. + next = BridgeState::WAITING; + const std::vector parents{carries ? BridgeHeldParents(*tx) : std::vector{}}; + if (carries && parents.size() < MAX_PACKAGE_COUNT) { + std::vector at; + for (const Txid& p : parents) at.push_back(below_floor.at(p)); + std::sort(at.begin(), at.end()); + Package package; + int64_t package_weight{entry.weight}; + for (const size_t k : at) { + package.push_back(held[k].tx); + package_weight += held[k].weight; + } + package.push_back(tx); + // Core refuses a package over MAX_PACKAGE_WEIGHT outright; don't ask every pass. + const std::set in{package_weight <= int64_t{MAX_PACKAGE_WEIGHT} ? BridgeOfferPackage(package) : std::set{}}; + for (const Txid& p : parents) { + if (!in.contains(p)) continue; + BridgeHeld& parent{held[below_floor.at(p)]}; + parent.state = BridgeState::FED; + parent.feerate.reset(); + m_bridge_held_txids.erase(p); + m_bridge_fed_txids.insert(p); + below_floor.erase(p); + ++packaged; + } + if (in.contains(txid)) { + next = BridgeState::FED; + ++packaged; + } else if (WITH_LOCK(cs_main, return BridgeClassifyInputs(*tx)) == BridgeInputs::DEAD) { + // Another input has since gone (a parent dropped): it can never go in, nor pay for them. + next.reset(); + ++dropped; + BridgeNoteDeadSpends(*tx); + } + } + } else if (!was_fed && !recheck_fees && entry.feerate && *entry.feerate < floor && !m_mempool.exists(txid)) { + // Refused on fee last time and its own feerate is still under the floor, so the mempool would refuse + // it again (the check looks at nothing else). Keep it without asking; a child can still carry it. + next = BridgeState::WAITING; + ++unasked; + below_floor.emplace(txid, held.size()); + } else { + ++offered; + std::string reason; + std::optional feerate; + switch (BridgeOfferTx(tx, reason, &feerate)) { + case BridgeTxResult::ACCEPTED: + next = BridgeState::FED; + ++(was_fed ? refed : accepted); + break; + case BridgeTxResult::HELD: + // Still refused for a reason that clears. However long it has waited, it stays: the block it came + // in is never fed again, so dropping it here would lose it and everything spending it. + next = BridgeState::WAITING; + entry.feerate = BridgeBelowFloor(reason) ? feerate : std::nullopt; + if (BridgeBelowFloor(reason)) below_floor.emplace(txid, held.size()); + break; + case BridgeTxResult::KNOWN: + // Already here: in our mempool (someone else relayed it), so watch it as fed; or mined since this + // pass read our blocks, so remember it as mined like the rest. + if (m_mempool.exists(txid)) { + next = BridgeState::FED; + } else if (m_bridge_recent_txids.insert(txid).second) { + m_bridge_recent.emplace_back(txid, tip_height + 1); + } + ++known; + break; + case BridgeTxResult::MISSING: + case BridgeTxResult::OTHER: + ++dropped; + BridgeNoteDeadSpends(*tx); + break; + } + } + } + m_bridge_held_txids.erase(txid); + m_bridge_fed_txids.erase(txid); + if (!next) continue; + if (*next == BridgeState::FED) { + entry.feerate.reset(); + m_bridge_fed_txids.insert(txid); } else { - m_bridge_held_txids.erase(tx->GetHash()); + if (was_fed) entry.since_height = tip_height; // waiting again, so young again + m_bridge_held_txids.insert(txid); } + entry.state = *next; + held.push_back(std::move(entry)); } m_bridge_held_txs = std::move(held); - if (accepted + dropped > 0) { - LogInfo("bridge: re-offered held Bitcoin transactions: %d accepted, %d dropped, %u still held\n", accepted, dropped, m_bridge_held_txs.size()); + if (!m_bridge_dead_spends.empty()) BridgeDropUnpaid(tip_height); + m_bridge_held_weight = 0; + m_bridge_young_weight = 0; + std::optional oldest; + for (const BridgeHeld& entry : m_bridge_held_txs) { + if (entry.state != BridgeState::WAITING) continue; + m_bridge_held_weight += entry.weight; + if (entry.since_height > tip_height - BRIDGE_HELD_YOUNG_BLOCKS) m_bridge_young_weight += entry.weight; + if (!oldest || entry.since < *oldest) oldest = entry.since; + } + // Held longer than BRIDGE_HELD_YOUNG_BLOCKS: kept (it may yet clear) but no longer able to pause the feed. + const uint64_t parked{m_bridge_held_weight - m_bridge_young_weight}; + if (parked > m_opts.bridge_held_max_weight && !m_bridge_parked_warned) { + LogWarning("bridge: %u WU has been held for over %d blocks without clearing (more than the %u WU limit); it is kept, but check what it is waiting for\n", + parked, BRIDGE_HELD_YOUNG_BLOCKS, m_opts.bridge_held_max_weight); + } + m_bridge_parked_warned = parked > m_opts.bridge_held_max_weight; + const std::string summary{strprintf("%u still held, %u fed awaiting confirmation%s (%d offered, %d under the fee floor unasked, %.1f ms)", + m_bridge_held_txids.size(), m_bridge_fed_txids.size(), + oldest ? strprintf(", oldest held %dh", std::chrono::duration_cast(now - *oldest).count()) : "", + offered, unasked, Ticks(SteadyClock::now() - started) / 1000.0)}; + if (accepted + refed + packaged + known + confirmed + dropped > 0) { + m_bridge_held_dirty = true; + LogInfo("bridge: re-offered held Bitcoin transactions: %d accepted, %d fed again after being lost, %d in packages, %d already there, %d mined here, %d dropped, %s\n", + accepted, refed, packaged, known, confirmed, dropped, summary); + } else { + LogDebug(BCLog::MEMPOOL, "bridge: retry pass: nothing accepted, %s\n", summary); } + BridgeMaybeSave(/*force=*/false); } bool PeerManagerImpl::BridgeProcessBlock(CNode& pfrom, Peer& peer, const std::shared_ptr& block) @@ -3093,40 +3784,76 @@ bool PeerManagerImpl::BridgeProcessBlock(CNode& pfrom, Peer& peer, const std::sh // Offer every transaction to the mempool in block order. Only the oldest pending block is // requested, so block order is guaranteed without a response buffer. const auto now{GetTime()}; + const int tip_height{WITH_LOCK(cs_main, return m_chainman.ActiveChain().Height())}; int accepted{0}, known{0}, missing{0}, held{0}, other{0}; std::map reasons; for (size_t i{1}; i < block->vtx.size(); ++i) { const CTransactionRef& tx{block->vtx[i]}; std::string reason; - switch (BridgeOfferTx(tx, reason)) { + std::optional feerate; + if (BridgeTracked(tx->GetHash())) { + // Fed already: a Bitcoin reorg feeding a block again, or a restart before the last one was saved. + ++known; + continue; + } + if (BridgeHasHeldParent(*tx)) { + // Its parent is held, so the mempool would only report missing inputs -- unless another input is one we + // don't expect at all, and then it can never go in. + BridgeInputs inputs; + { + LOCK(cs_main); + inputs = BridgeClassifyInputs(*tx); + } + if (inputs == BridgeInputs::DEAD) { + ++missing; + BridgeNoteDeadSpends(*tx); + continue; + } + if (inputs == BridgeInputs::WAITS) { + ++held; + ++reasons["parent-held"]; + BridgeTrackTx(tx, BridgeState::WAITING, now, tip_height, std::nullopt); + continue; + } + // AVAILABLE: the held parent reached our mempool by another route; offer this one. + } + switch (BridgeOfferTx(tx, reason, &feerate)) { case BridgeTxResult::ACCEPTED: ++accepted; + BridgeTrackTx(tx, BridgeState::FED, now, tip_height, std::nullopt); break; case BridgeTxResult::KNOWN: ++known; + // Already in our mempool (another route): watch it like our own, in case the mempool is lost. + if (m_mempool.exists(tx->GetHash())) BridgeTrackTx(tx, BridgeState::FED, now, tip_height, std::nullopt); break; case BridgeTxResult::MISSING: ++missing; + BridgeNoteDeadSpends(*tx); break; case BridgeTxResult::HELD: ++held; ++reasons[reason]; - BridgeHoldTx(tx, now); + BridgeTrackTx(tx, BridgeState::WAITING, now, tip_height, BridgeBelowFloor(reason) ? feerate : std::nullopt); + // A child later in this block, or the next one, may pay for it: don't wait for our tip to move. + if (BridgeBelowFloor(reason)) m_bridge_retry_now = true; break; case BridgeTxResult::OTHER: ++other; ++reasons[reason]; + BridgeNoteDeadSpends(*tx); break; } } + if (!m_bridge_dead_spends.empty()) BridgeDropUnpaid(tip_height); std::string detail; for (const auto& [reason, n] : reasons) detail += strprintf(" %s=%d", reason, n); LogInfo("bridge: Bitcoin block %s (height %d, peer=%d): %u tx, %d accepted, %d already known, %d missing inputs, %d held, %d other%s\n", hash.ToString(), height, pfrom.GetId(), block->vtx.size() - 1, accepted, known, missing, held, other, detail); - LOCK(cs_main); - BridgeMarkProcessed(pfrom.GetId(), hash); - if (m_bridge_anchor_dirty) BridgeSaveAnchor(); + WITH_LOCK(cs_main, BridgeMarkProcessed(pfrom.GetId(), hash)); + // Outside cs_main -- the queue can run to a hundred MB -- and at most every BRIDGE_SAVE_INTERVAL. + BridgeMaybeSave(/*force=*/false); return true; } @@ -6367,8 +7094,10 @@ bool PeerManagerImpl::SendMessages(CNode& node) MaybeSendSendHeaders(node, peer); - if (BridgeFeeds(peer)) { - // Held Bitcoin transactions may have become acceptable (our tip moved, their ancestors confirmed here). + if (BridgeEnabled()) { + // Held Bitcoin transactions may have become acceptable (our tip moved, their ancestors confirmed here), and fed + // ones may have been mined or lost. None of that needs a Bitcoin peer, so any peer runs it; the pass limits + // itself to one per tip change or retry interval. BridgeRetryHeldTxs(current_time); } diff --git a/src/net_processing.h b/src/net_processing.h index 36ae021f679d..45e513e0e6e6 100644 --- a/src/net_processing.h +++ b/src/net_processing.h @@ -73,10 +73,21 @@ struct PeerManagerInfo { bool ignores_incoming_txs{false}; }; +/** Bridge: the Bitcoin feed pauses while the transactions held from our recent blocks weigh this much. A Bitcoin + * block is 4 MWU, so this is a hundred blocks' worth -- about 100 MB serialized and 200-400 MB in memory at the + * limit, on top of the mempool. Nothing is refused when it is reached; the blocks wait on the Bitcoin node. */ +static constexpr uint64_t DEFAULT_BRIDGE_HELD_MAX_WEIGHT{100 * 4'000'000}; +/** Bridge: a held transaction counts toward that limit for this many of our blocks. Older ones -- which may never + * clear, like a transaction below the relay floor whose paying child is dead -- are kept, but no longer hold up the + * feed; the age is in our blocks, so the pause still holds while our chain itself is stalled. */ +static constexpr int BRIDGE_HELD_YOUNG_BLOCKS{144}; + class PeerManager : public CValidationInterface, public NetEventsInterface { public: struct Options { + //! Bridge: weight held from our last BRIDGE_HELD_YOUNG_BLOCKS blocks at which the Bitcoin feed pauses + uint64_t bridge_held_max_weight{DEFAULT_BRIDGE_HELD_MAX_WEIGHT}; //! Whether this node is running in -blocksonly mode bool ignore_incoming_txs{DEFAULT_BLOCKSONLY}; //! Whether transaction reconciliation protocol is enabled @@ -101,6 +112,9 @@ class PeerManager : public CValidationInterface, public NetEventsInterface CTxMemPool& pool, node::Warnings& warnings, Options opts); virtual ~PeerManager() = default; + /** Bridge: write what the Bitcoin block feed tracks. Called at shutdown, once the message handler has stopped. */ + virtual void FlushBridge() {} + /** * Attempt to manually fetch block from a given peer. We must already have the header. * diff --git a/src/node/peerman_args.cpp b/src/node/peerman_args.cpp index 9745d69d5ae3..6f9f4ef3162a 100644 --- a/src/node/peerman_args.cpp +++ b/src/node/peerman_args.cpp @@ -25,6 +25,10 @@ void ApplyArgsManOptions(const ArgsManager& argsman, PeerManager::Options& optio if (auto value{argsman.GetBoolArg("-blocksonly")}) options.ignore_incoming_txs = *value; if (auto value{argsman.GetBoolArg("-privatebroadcast")}) options.private_broadcast = *value; + + if (auto value{argsman.GetIntArg("-bridgeheldmaxweight")}) { + options.bridge_held_max_weight = uint64_t(std::max(*value, 1)); + } } } // namespace node