diff --git a/node/utxo_db.py b/node/utxo_db.py index 4631d368d..7ccc6b330 100644 --- a/node/utxo_db.py +++ b/node/utxo_db.py @@ -714,8 +714,31 @@ def abort() -> bool: ), ) + # -- BUG-4: evict stale mempool txs referencing spent inputs ---- + # Must run BEFORE COMMIT when the caller holds an external + # connection (manage_tx=False), because SQLite only allows + # one writer at a time. When we own the transaction + # (manage_tx=True), we commit first, then evict on a fresh + # connection so a failure cannot roll back the durable spend. + _spent_ids = list(set(input_box_ids + list(data_inputs))) + if _spent_ids: + if not manage_tx: + # External-connection path: evict inside the caller's + # transaction so the DELETEs share the same write lock. + try: + self._evict_stale_data_input_txs(_spent_ids, conn=conn) + except Exception: + pass # best-effort; outer caller will commit the spend if manage_tx: conn.execute("COMMIT") + # Own-transaction path: spend is committed. Evict on a + # separate connection - a failure here does not affect + # the committed transaction. + if _spent_ids: + try: + self._evict_stale_data_input_txs(_spent_ids) + except Exception: + pass # best-effort; already committed return True except Exception: @@ -729,6 +752,7 @@ def abort() -> bool: if own: conn.close() + # -- state root ---------------------------------------------------------- def compute_state_root(self) -> str: @@ -1025,9 +1049,16 @@ def mempool_add(self, tx: dict) -> bool: conn.close() def mempool_remove(self, tx_id: str): - """Remove a transaction from the mempool.""" + """Remove a transaction from the mempool. + + Uses BEGIN IMMEDIATE to ensure atomicity of the two DELETE + operations. Without it, a crash between deletes can leave + orphan utxo_mempool_inputs rows, causing a persistent UTXO + lock / mempool DoS (BUG-1). + """ conn = self._conn() try: + conn.execute("BEGIN IMMEDIATE") conn.execute( "DELETE FROM utxo_mempool_inputs WHERE tx_id = ?", (tx_id,) ) @@ -1035,9 +1066,94 @@ def mempool_remove(self, tx_id: str): "DELETE FROM utxo_mempool WHERE tx_id = ?", (tx_id,) ) conn.commit() + except Exception: + try: + conn.execute("ROLLBACK") + except Exception: + pass + raise finally: conn.close() + def _evict_stale_data_input_txs(self, spent_box_ids: List[str], + conn: Optional[sqlite3.Connection] = None) -> int: + """Remove mempool txs whose inputs or data_inputs include any of spent_box_ids. + + BUG-4 fix: apply_transaction() now proactively evicts mempool + transactions that became invalid because a box they depend on + (as a regular input or data_input) was just spent. Without this, + stale txs hold their normal inputs reserved in utxo_mempool_inputs + until candidate selection catches them — an availability gap. + + Search strategy: + 1. Check utxo_mempool_inputs for txs claiming any spent box as a + regular input. + 2. Scan utxo_mempool.tx_data_json for txs whose data_inputs + reference any spent box (since data_inputs are not recorded + in utxo_mempool_inputs — they are read-only references). + """ + if not spent_box_ids: + return 0 + own_conn = conn is None + if own_conn: + conn = self._conn() + try: + spent_set = set(spent_box_ids) + stale_tx_ids = set() + + # 1. Txs claiming spent boxes as regular inputs + placeholders = ",".join("?" for _ in spent_box_ids) + rows = conn.execute( + f"SELECT DISTINCT tx_id FROM utxo_mempool_inputs " + f"WHERE box_id IN ({placeholders})", + spent_box_ids, + ).fetchall() + for row in rows: + stale_tx_ids.add(row["tx_id"]) + + # 2. Txs referencing spent boxes as data_inputs + # (not stored in utxo_mempool_inputs, so parse tx_data_json) + all_mempool = conn.execute( + "SELECT tx_id, tx_data_json FROM utxo_mempool" + ).fetchall() + for mp_row in all_mempool: + if mp_row["tx_id"] in stale_tx_ids: + continue # already flagged + try: + tx_data = json.loads(mp_row["tx_data_json"]) + di = tx_data.get("data_inputs", []) + if di and spent_set & set(di): + stale_tx_ids.add(mp_row["tx_id"]) + except (json.JSONDecodeError, TypeError): + continue + + if not stale_tx_ids: + return 0 + + tx_ids = list(stale_tx_ids) + tx_placeholders = ",".join("?" for _ in tx_ids) + conn.execute( + f"DELETE FROM utxo_mempool_inputs WHERE tx_id IN ({tx_placeholders})", + tx_ids, + ) + conn.execute( + f"DELETE FROM utxo_mempool WHERE tx_id IN ({tx_placeholders})", + tx_ids, + ) + if own_conn: + conn.commit() + return len(tx_ids) + except Exception: + if own_conn: + try: + conn.execute("ROLLBACK") + except Exception: + pass + return 0 + finally: + if own_conn: + conn.close() + def mempool_get_block_candidates(self, max_count: int = 100) -> List[dict]: """Get highest-fee transactions from mempool for block inclusion.""" self.mempool_clear_expired() diff --git a/tests/test_bug4_external_conn.py b/tests/test_bug4_external_conn.py new file mode 100644 index 000000000..406d5050d --- /dev/null +++ b/tests/test_bug4_external_conn.py @@ -0,0 +1,189 @@ +"""Regression test: BUG-4 stale eviction must work via external-connection path. + +The utxo_endpoints.py transfer endpoint opens BEGIN IMMEDIATE on its own +connection, then calls apply_transaction(conn=outer_conn). The eviction must +run on that same connection inside the transaction. +""" +import json, time, sqlite3, pytest +from node.utxo_db import UtxoDB + + +@pytest.fixture +def db(tmp_path): + db_path = str(tmp_path / "test_utxo.db") + instance = UtxoDB(db_path) + instance.init_tables() + return instance + + +def _mint(db, addr, value, height=1): + """Mint a box and return its box_id.""" + result = db.apply_transaction({ + "tx_type": "mining_reward", + "inputs": [], + "outputs": [{"address": addr, "value_nrtc": value}], + "fee_nrtc": 0, + "data_inputs": [], + "_allow_minting": True, + }, block_height=height) + assert result, "minting failed" + conn = db._conn() + try: + row = conn.execute( + "SELECT box_id FROM utxo_boxes WHERE owner_address=? AND spent_at IS NULL ORDER BY box_id ASC LIMIT 1", + (addr,), + ).fetchone() + finally: + conn.close() + assert row, f"no box for {addr}" + return row["box_id"] + + +def _add_mempool_tx_with_data_input(db, tx_id, input_box_ids, data_input_box_ids, fee=100): + """Directly insert a mempool tx with data_inputs in tx_data_json.""" + now = int(time.time()) + tx_data = { + "tx_id": tx_id, + "data_inputs": data_input_box_ids, + } + conn = db._conn() + try: + conn.execute("BEGIN IMMEDIATE") + conn.execute( + "INSERT INTO utxo_mempool (tx_id, tx_data_json, fee_nrtc, expires_at, submitted_at) VALUES (?,?,?,?,?)", + (tx_id, json.dumps(tx_data), fee, now + 3600, now), + ) + for bid in input_box_ids: + conn.execute( + "INSERT INTO utxo_mempool_inputs (box_id, tx_id) VALUES (?,?)", + (bid, tx_id), + ) + conn.commit() + finally: + conn.close() + + +class TestExternalConnEvictionBug4: + """BUG-4 fix: stale eviction must work when apply_transaction() is called + with an external connection (the utxo_endpoints.py transfer pattern).""" + + def test_stale_data_input_tx_evicted_via_external_conn(self, db): + """Stale mempool tx referencing a spent box via data_input must be + evicted even when the spend happens via an external connection + (BEGIN IMMEDIATE on outer, apply_transaction(conn=outer), COMMIT).""" + # 1. Mint two boxes + box_a = _mint(db, "alice", 10000) + box_b = _mint(db, "bob", 20000, height=2) + + # 2. Add mempool tx that uses box_a as data_input + _add_mempool_tx_with_data_input( + db, "tx_stale_di", [box_b], [box_a], fee=50 + ) + + # Verify tx is in mempool + conn = db._conn() + try: + row = conn.execute( + "SELECT tx_id FROM utxo_mempool WHERE tx_id=?", ("tx_stale_di",) + ).fetchone() + finally: + conn.close() + assert row is not None, "stale tx should be in mempool" + + # 3. Spend box_a via EXTERNAL CONNECTION path + outer = sqlite3.connect(db.db_path) + outer.row_factory = sqlite3.Row + outer.execute("BEGIN IMMEDIATE") + + ok = db.apply_transaction({ + "tx_type": "transfer", + "inputs": [{"box_id": box_a, "spending_proof": "p2"}], + "outputs": [{"address": "carol", "value_nrtc": 9900}], + "fee_nrtc": 100, + "data_inputs": [], + }, block_height=3, conn=outer) + + outer.execute("COMMIT") + outer.close() + assert ok, "apply_transaction via external conn failed" + + # 4. Verify stale tx was evicted + conn = db._conn() + try: + mp = conn.execute( + "SELECT tx_id FROM utxo_mempool WHERE tx_id=?", ("tx_stale_di",) + ).fetchone() + inp = conn.execute( + "SELECT tx_id FROM utxo_mempool_inputs WHERE tx_id=?", ("tx_stale_di",) + ).fetchone() + finally: + conn.close() + assert mp is None, "BUG-4: stale tx should be evicted via external-conn path" + assert inp is None, "BUG-4: stale input rows should be cleaned up" + + def test_stale_regular_input_tx_evicted_via_external_conn(self, db): + """Stale mempool tx claiming a spent box as regular input must be + evicted via the external-connection path.""" + box_a = _mint(db, "alice", 10000) + + # Add mempool tx claiming box_a + _add_mempool_tx_with_data_input( + db, "tx_stale_reg", [box_a], [], fee=50 + ) + + # Spend box_a via external connection + outer = sqlite3.connect(db.db_path) + outer.row_factory = sqlite3.Row + outer.execute("BEGIN IMMEDIATE") + + ok = db.apply_transaction({ + "tx_type": "transfer", + "inputs": [{"box_id": box_a, "spending_proof": "p2"}], + "outputs": [{"address": "carol", "value_nrtc": 9900}], + "fee_nrtc": 100, + "data_inputs": [], + }, block_height=2, conn=outer) + + outer.execute("COMMIT") + outer.close() + assert ok + + # Verify eviction + conn = db._conn() + try: + mp = conn.execute( + "SELECT tx_id FROM utxo_mempool WHERE tx_id=?", ("tx_stale_reg",) + ).fetchone() + finally: + conn.close() + assert mp is None, "stale tx with regular input should be evicted" + + def test_own_conn_eviction_still_works(self, db): + """Regression guard: own-connection (manage_tx=True) path still + evicts stale txs after the fix.""" + box_a = _mint(db, "alice", 10000) + box_b = _mint(db, "bob", 20000, height=2) + + _add_mempool_tx_with_data_input( + db, "tx_stale_own", [box_b], [box_a], fee=50 + ) + + # Own-connection path (no conn=... argument) + ok = db.apply_transaction({ + "tx_type": "transfer", + "inputs": [{"box_id": box_a, "spending_proof": "p2"}], + "outputs": [{"address": "carol", "value_nrtc": 9900}], + "fee_nrtc": 100, + "data_inputs": [], + }, block_height=3) + assert ok + + # Verify eviction + conn = db._conn() + try: + mp = conn.execute( + "SELECT tx_id FROM utxo_mempool WHERE tx_id=?", ("tx_stale_own",) + ).fetchone() + finally: + conn.close() + assert mp is None, "own-connection eviction should still work" diff --git a/tests/test_p2p_blocks.py b/tests/test_p2p_blocks.py new file mode 100644 index 000000000..bb377e899 --- /dev/null +++ b/tests/test_p2p_blocks.py @@ -0,0 +1 @@ +import pytest \ No newline at end of file diff --git a/tests/test_utxo_mempool_atomicity_bug1_bug4.py b/tests/test_utxo_mempool_atomicity_bug1_bug4.py new file mode 100644 index 000000000..2258104d3 --- /dev/null +++ b/tests/test_utxo_mempool_atomicity_bug1_bug4.py @@ -0,0 +1,347 @@ +"""Tests for UTXO mempool atomicity fixes (BUG-1 + BUG-4 from PR #6146 review) + +BUG-1: mempool_remove() must atomically delete from both utxo_mempool + and utxo_mempool_inputs within a single BEGIN IMMEDIATE transaction. +BUG-4: apply_transaction() must proactively evict mempool txs whose + inputs (including data_inputs) reference boxes that were just spent. +""" +import json +import time +import pytest +from node.utxo_db import UtxoDB + +@pytest.fixture +def db(tmp_path): + db_path = str(tmp_path / "test_utxo.db") + instance = UtxoDB(db_path) + instance.init_tables() + return instance + + +def _add_box(db, box_id, value, addr="addr1", height=1, tx_idx=0): + db.add_box({ + "box_id": box_id, + "value_nrtc": value, + "proposition": addr, + "owner_address": addr, + "creation_height": height, + "transaction_id": f"tx_genesis_{box_id}", + "output_index": tx_idx, + }) + + +def _add_mempool_tx(db, tx_id, box_ids, fee=100): + """Directly insert a mempool tx (for unit-testing the helper).""" + now = int(time.time()) + conn = db._conn() + try: + conn.execute("BEGIN IMMEDIATE") + conn.execute( + "INSERT INTO utxo_mempool (tx_id, tx_data_json, fee_nrtc, expires_at, submitted_at) VALUES (?,?,?,?,?)", + (tx_id, json.dumps({"tx_id": tx_id}), fee, now + 3600, now), + ) + for bid in box_ids: + conn.execute( + "INSERT INTO utxo_mempool_inputs (box_id, tx_id) VALUES (?,?)", + (bid, tx_id), + ) + conn.commit() + finally: + conn.close() + + +# --------- BUG-1: mempool_remove atomicity --------- + +class TestMempoolRemoveAtomicityBug1: + def test_mempool_remove_deletes_both_tables(self, db): + _add_box(db, "box1", 1000) + _add_mempool_tx(db, "tx1", ["box1"]) + db.mempool_remove("tx1") + conn = db._conn() + try: + p = conn.execute("SELECT * FROM utxo_mempool WHERE tx_id=?", ("tx1",)).fetchone() + i = conn.execute("SELECT * FROM utxo_mempool_inputs WHERE tx_id=?", ("tx1",)).fetchone() + finally: + conn.close() + assert p is None + assert i is None + + def test_mempool_remove_nonexistent_is_safe(self, db): + db.mempool_remove("nonexistent_tx") + + +# --------- BUG-4: stale data_input eviction --------- + +class TestStaleDataInputEvictionBug4: + def test_evict_stale_data_input_txs(self, db): + _add_box(db, "box_a", 1000) + _add_box(db, "box_b", 2000, "addr2") + _add_mempool_tx(db, "tx_stale", ["box_a", "box_b"], 50) + evicted = db._evict_stale_data_input_txs(["box_b"]) + assert evicted == 1 + conn = db._conn() + try: + p = conn.execute("SELECT * FROM utxo_mempool WHERE tx_id=?", ("tx_stale",)).fetchone() + rows = conn.execute("SELECT * FROM utxo_mempool_inputs WHERE tx_id=?", ("tx_stale",)).fetchall() + finally: + conn.close() + assert p is None + assert len(rows) == 0 + + def test_evict_no_stale_when_not_in_mempool(self, db): + assert db._evict_stale_data_input_txs(["nope"]) == 0 + + def test_evict_empty_list(self, db): + assert db._evict_stale_data_input_txs([]) == 0 + + def test_evict_finds_tx_via_data_input_in_tx_data_json(self, db): + """Unit test: _evict_stale_data_input_txs should find mempool txs + that reference a spent box only through data_inputs (not recorded + in utxo_mempool_inputs). This is the tx_data_json scanning path.""" + _add_box(db, 'box_data', 10000) + _add_box(db, 'box_regular', 10000, 'addr2') + + # Add a mempool tx via mempool_add with a data_input + ok = db.mempool_add({ + 'tx_id': 'tx_di_test', + 'tx_type': 'transfer', + 'inputs': [{'box_id': 'box_regular', 'spending_proof': 'p'}], + 'data_inputs': ['box_data'], + 'outputs': [{'address': 'addr3', 'value_nrtc': 9900}], + 'fee_nrtc': 100, + }) + assert ok, 'mempool_add with data_input should work' + + # box_data is NOT in utxo_mempool_inputs (only regular inputs are) + conn = db._conn() + try: + row = conn.execute( + 'SELECT tx_id FROM utxo_mempool_inputs WHERE box_id=?', + ('box_data',), + ).fetchone() + finally: + conn.close() + assert row is None, 'data_input should not be in utxo_mempool_inputs' + + # But _evict_stale_data_input_txs should still find and evict the tx + evicted = db._evict_stale_data_input_txs(['box_data']) + assert evicted == 1, 'should evict tx referencing spent data_input' + + conn = db._conn() + try: + mp = conn.execute( + 'SELECT tx_id FROM utxo_mempool WHERE tx_id=?', + ('tx_di_test',), + ).fetchone() + finally: + conn.close() + assert mp is None, 'tx should be removed from mempool' + + def test_apply_transaction_evicts_stale_mempool_tx_via_input(self, db): + """Regression test: apply_transaction() should evict mempool txs + whose claimed inputs reference a box that was just spent. + Uses real mempool_add() + apply_transaction() flow.""" + # First, use apply_transaction to create a real UTXO that we can spend + result = db.apply_transaction({ + "tx_type": "mining_reward", + "inputs": [], + "outputs": [{"address": "addr1", "value_nrtc": 10000}], + "fee_nrtc": 0, + "data_inputs": [], + "_allow_minting": True, + }, block_height=1) + assert result, "minting should succeed" + + # Find the actual box_id created by the mint + conn = db._conn() + try: + row = conn.execute( + "SELECT box_id FROM utxo_boxes WHERE owner_address=? AND spent_at IS NULL ORDER BY box_id ASC LIMIT 1", + ("addr1",), + ).fetchone() + finally: + conn.close() + assert row is not None, "should have a box for addr1" + box_w = row["box_id"] + + # Add a mempool tx that claims this box as input + ok = db.mempool_add({ + "tx_id": "tx_m", + "tx_type": "transfer", + "inputs": [{"box_id": box_w, "spending_proof": "proof_w"}], + "outputs": [{"address": "addr3", "value_nrtc": 9900}], + "fee_nrtc": 100, + }) + assert ok, "mempool_add should succeed" + + # Verify tx_m is in the mempool + conn = db._conn() + try: + mp_row = conn.execute("SELECT tx_id FROM utxo_mempool WHERE tx_id=?", ("tx_m",)).fetchone() + finally: + conn.close() + assert mp_row is not None, "tx_m should be in mempool before spend" + + # Spend box_w via apply_transaction + result = db.apply_transaction({ + "tx_type": "transfer", + "inputs": [{"box_id": box_w, "spending_proof": "proof_spend"}], + "outputs": [{"address": "addr_new", "value_nrtc": 9900}], + "fee_nrtc": 100, + "data_inputs": [], + }, block_height=2) + assert result is True, "apply_transaction should succeed" + + # BUG-4 regression: tx_m should have been evicted + conn = db._conn() + try: + mp_row = conn.execute("SELECT tx_id FROM utxo_mempool WHERE tx_id=?", ("tx_m",)).fetchone() + inp_row = conn.execute("SELECT tx_id FROM utxo_mempool_inputs WHERE tx_id=?", ("tx_m",)).fetchone() + finally: + conn.close() + assert mp_row is None, "BUG-4: stale mempool tx should be evicted after its input is spent" + assert inp_row is None, "BUG-4: stale mempool input rows should be cleaned up" + + def test_apply_transaction_preserves_unrelated_mempool_txs(self, db): + """Spending a box should only evict mempool txs that depend on it, + not unrelated mempool txs.""" + # Create two boxes via minting + db.apply_transaction({ + "tx_type": "mining_reward", + "inputs": [], + "outputs": [{"address": "addr1", "value_nrtc": 10000}], + "fee_nrtc": 0, + "data_inputs": [], + "_allow_minting": True, + }, block_height=1) + db.apply_transaction({ + "tx_type": "mining_reward", + "inputs": [], + "outputs": [{"address": "addr2", "value_nrtc": 20000}], + "fee_nrtc": 0, + "data_inputs": [], + "_allow_minting": True, + }, block_height=1) + + # Find actual box_ids + conn = db._conn() + try: + box_a_row = conn.execute("SELECT box_id FROM utxo_boxes WHERE owner_address=? AND spent_at IS NULL ORDER BY box_id ASC LIMIT 1", ("addr1",)).fetchone() + box_b_row = conn.execute("SELECT box_id FROM utxo_boxes WHERE owner_address=? AND spent_at IS NULL ORDER BY box_id ASC LIMIT 1", ("addr2",)).fetchone() + finally: + conn.close() + box_a = box_a_row["box_id"] + box_b = box_b_row["box_id"] + + # tx_m2 claims box_b (unrelated to the spend of box_a) + ok = db.mempool_add({ + "tx_id": "tx_m2", + "tx_type": "transfer", + "inputs": [{"box_id": box_b, "spending_proof": "p2"}], + "outputs": [{"address": "addr5", "value_nrtc": 19900}], + "fee_nrtc": 100, + }) + assert ok + + # Spend box_a (not box_b) + result = db.apply_transaction({ + "tx_type": "transfer", + "inputs": [{"box_id": box_a, "spending_proof": "p3"}], + "outputs": [{"address": "addr_new", "value_nrtc": 9900}], + "fee_nrtc": 100, + "data_inputs": [], + }, block_height=2) + assert result + + conn = db._conn() + try: + m2 = conn.execute("SELECT tx_id FROM utxo_mempool WHERE tx_id=?", ("tx_m2",)).fetchone() + finally: + conn.close() + + assert m2 is not None, "tx_m2 should remain (its input box_b was not spent)" + def test_apply_transaction_evicts_mempool_tx_with_data_input(self, db): + """Regression test: apply_transaction() should evict mempool txs + whose data_inputs reference a box that was just spent. + Uses real mempool_add() + apply_transaction() flow. + This addresses the reviewer concern that data_inputs are not + recorded in utxo_mempool_inputs and thus need tx_data_json scanning.""" + # Create two boxes via minting + db.apply_transaction({ + 'tx_type': 'mining_reward', + 'inputs': [], + 'outputs': [{'address': 'addr_spend', 'value_nrtc': 10000}], + 'fee_nrtc': 0, + 'data_inputs': [], + '_allow_minting': True, + }, block_height=1) + db.apply_transaction({ + 'tx_type': 'mining_reward', + 'inputs': [], + 'outputs': [{'address': 'addr_input', 'value_nrtc': 10000}], + 'fee_nrtc': 0, + 'data_inputs': [], + '_allow_minting': True, + }, block_height=1) + + # Find actual box_ids + conn = db._conn() + try: + box_spend = conn.execute( + 'SELECT box_id FROM utxo_boxes WHERE owner_address=? AND spent_at IS NULL ORDER BY box_id ASC LIMIT 1', + ('addr_spend',), + ).fetchone()['box_id'] + box_input = conn.execute( + 'SELECT box_id FROM utxo_boxes WHERE owner_address=? AND spent_at IS NULL ORDER BY box_id ASC LIMIT 1', + ('addr_input',), + ).fetchone()['box_id'] + finally: + conn.close() + + # Add mempool tx that uses box_spend as a DATA input (read-only) + # and box_input as a regular input + ok = db.mempool_add({ + 'tx_id': 'tx_data_dep', + 'tx_type': 'transfer', + 'inputs': [{'box_id': box_input, 'spending_proof': 'p_input'}], + 'data_inputs': [box_spend], + 'outputs': [{'address': 'addr_out', 'value_nrtc': 9900}], + 'fee_nrtc': 100, + }) + assert ok, 'mempool_add with data_input should succeed' + + # Verify tx_data_dep is in the mempool + conn = db._conn() + try: + mp = conn.execute( + 'SELECT tx_id FROM utxo_mempool WHERE tx_id=?', ('tx_data_dep',) + ).fetchone() + finally: + conn.close() + assert mp is not None, 'tx_data_dep should be in mempool' + + # Spend box_spend via apply_transaction + # This makes box_spend unavailable, which should invalidate tx_data_dep + # (since tx_data_dep depends on box_spend as a data_input) + result = db.apply_transaction({ + 'tx_type': 'transfer', + 'inputs': [{'box_id': box_spend, 'spending_proof': 'p_spend'}], + 'outputs': [{'address': 'addr_spent_to', 'value_nrtc': 9900}], + 'fee_nrtc': 100, + 'data_inputs': [], + }, block_height=2) + assert result, 'apply_transaction spending box_spend should succeed' + + # BUG-4 regression: tx_data_dep should be evicted + conn = db._conn() + try: + mp = conn.execute( + 'SELECT tx_id FROM utxo_mempool WHERE tx_id=?', ('tx_data_dep',) + ).fetchone() + inp = conn.execute( + 'SELECT tx_id FROM utxo_mempool_inputs WHERE tx_id=?', ('tx_data_dep',) + ).fetchone() + finally: + conn.close() + assert mp is None, 'BUG-4: stale mempool tx with spent data_input should be evicted' + assert inp is None, 'BUG-4: input claims for evicted tx should be cleaned up'