wallet: Assertion `!wtx.truc_child_in_mempool.has_value()’ failed #34206

issue dergoegge openend this issue on January 6, 2026
  1. dergoegge commented at 11:02 am on January 6, 2026: member

    The following Assume in the wallet will fail (triggered through rpc) under certain reorg conditions: https://github.com/bitcoin/bitcoin/blob/114901c0655d1dea3024a1daa362716c948bbde3/src/wallet/spend.cpp#L413

      0From 54aea28e6439be5645267f0344a6337d9798ba14 Mon Sep 17 00:00:00 2001
      1From: dergoegge <n.goeggi@gmail.com>
      2Date: Tue, 6 Jan 2026 10:49:02 +0000
      3Subject: [PATCH] truc wallet reorg bug
      4
      5---
      6 test/functional/mempool_truc.py | 163 +++++++++++++++++++++++++-------
      7 1 file changed, 127 insertions(+), 36 deletions(-)
      8
      9diff --git a/test/functional/mempool_truc.py b/test/functional/mempool_truc.py
     10index 2586308e4d..a155d57b68 100755
     11--- a/test/functional/mempool_truc.py
     12+++ b/test/functional/mempool_truc.py
     13@@ -47,6 +47,36 @@ class MempoolTRUC(BitcoinTestFramework):
     14         self.extra_args = [[]]
     15         self.setup_clean_chain = True
     16
     17+    def skip_test_if_missing_module(self):
     18+        self.skip_if_no_wallet()
     19+
     20+    def create_tx(self, wallet, inputs, outputs, version, prevtxs=None):
     21+        """Create a signed transaction using the real wallet.
     22+
     23+        Args:
     24+            wallet: The wallet RPC to use
     25+            inputs: List of inputs (empty to let fundrawtransaction pick)
     26+            outputs: Dict of address -> amount
     27+            version: Transaction version
     28+            prevtxs: Optional list of previous transaction info for signing
     29+                     unbroadcast transactions. Each entry should have:
     30+                     {"txid": ..., "vout": ..., "scriptPubKey": ..., "amount": ...}
     31+        """
     32+        raw_tx = wallet.createrawtransaction(inputs=inputs, outputs=outputs, version=version)
     33+        if inputs == []:
     34+            raw_tx = wallet.fundrawtransaction(raw_tx, {'include_unsafe': True})["hex"]
     35+        if prevtxs:
     36+            signed_tx = wallet.signrawtransactionwithwallet(raw_tx, prevtxs)
     37+        else:
     38+            signed_tx = wallet.signrawtransactionwithwallet(raw_tx)
     39+        return signed_tx["hex"]
     40+
     41+    def send_tx(self, wallet, inputs, outputs, version, prevtxs=None):
     42+        """Create and broadcast a transaction using the real wallet."""
     43+        tx_hex = self.create_tx(wallet, inputs, outputs, version, prevtxs)
     44+        txid = wallet.sendrawtransaction(tx_hex)
     45+        return txid
     46+
     47     def check_mempool(self, txids):
     48         """Assert exact contents of the node's mempool (by txid)."""
     49         mempool_contents = self.nodes[0].getrawmempool()
     50@@ -173,41 +203,102 @@ class MempoolTRUC(BitcoinTestFramework): [@cleanup](/bitcoin-bitcoin/contributor/cleanup/)()
     51     def test_truc_reorg(self):
     52         node = self.nodes[0]
     53+        self.log.info("Test TRUC transactions survive reorg correctly")
     54+
     55+        # Create a wallet for this test
     56+        node.createwallet("truc_reorg_wallet")
     57+        wallet = node.get_wallet_rpc("truc_reorg_wallet")
     58+
     59+        # Fund the wallet
     60+        funding_addr = wallet.getnewaddress()
     61+        self.generatetoaddress(node, 1, funding_addr)
     62+        self.generate(node, 100)  # Mature the coinbase
     63
     64         # Prep for fork
     65         fork_blocks = create_empty_fork(node)
     66-        self.log.info("Test that, during a reorg, TRUC rules are not enforced")
     67         self.check_mempool([])
     68
     69-        # Testing 2<-3 versions allowed
     70-        tx_v2_block = self.wallet.create_self_transfer(version=2)
     71+        # tx_chain_1: v2 transaction with 2 outputs
     72+        addr1 = wallet.getnewaddress()
     73+        addr2 = wallet.getnewaddress()
     74+        tx_chain_1_hex = self.create_tx(wallet, [], {addr1: 10, addr2: 10}, version=2)
     75+        tx_chain_1_decoded = node.decoderawtransaction(tx_chain_1_hex)
     76+        tx_chain_1_txid = tx_chain_1_decoded["txid"]
     77+
     78+        # Find which vout corresponds to which address and get scriptPubKey info
     79+        vout_for_addr1 = None
     80+        vout_for_addr2 = None
     81+        scriptpubkey_addr1 = None
     82+        scriptpubkey_addr2 = None
     83+        for vout in tx_chain_1_decoded["vout"]:
     84+            if vout["scriptPubKey"].get("address") == addr1:
     85+                vout_for_addr1 = vout["n"]
     86+                scriptpubkey_addr1 = vout["scriptPubKey"]["hex"]
     87+            elif vout["scriptPubKey"].get("address") == addr2:
     88+                vout_for_addr2 = vout["n"]
     89+                scriptpubkey_addr2 = vout["scriptPubKey"]["hex"]
     90+
     91+        # tx_chain_2: v3 transaction spending first output of tx_chain_1
     92+        addr3 = wallet.getnewaddress()
     93+        prevtxs_chain_2 = [{
     94+            "txid": tx_chain_1_txid,
     95+            "vout": vout_for_addr1,
     96+            "scriptPubKey": scriptpubkey_addr1,
     97+            "amount": Decimal("10")
     98+        }]
     99+        tx_chain_2_hex = self.create_tx(
    100+            wallet,
    101+            [{"txid": tx_chain_1_txid, "vout": vout_for_addr1}],
    102+            {addr3: Decimal("9.99999")},
    103+            version=3,
    104+            prevtxs=prevtxs_chain_2
    105+        )
    106+        tx_chain_2_decoded = node.decoderawtransaction(tx_chain_2_hex)
    107+        tx_chain_2_txid = tx_chain_2_decoded["txid"]
    108+
    109+        # Get scriptPubKey for tx_chain_2 output
    110+        scriptpubkey_addr3 = tx_chain_2_decoded["vout"][0]["scriptPubKey"]["hex"]
    111+
    112+        # tx_chain_3: v3 transaction spending output of tx_chain_2
    113+        addr4 = wallet.getnewaddress()
    114+        prevtxs_chain_3 = [{
    115+            "txid": tx_chain_2_txid,
    116+            "vout": 0,
    117+            "scriptPubKey": scriptpubkey_addr3,
    118+            "amount": Decimal("9.99999")
    119+        }]
    120+        tx_chain_3_hex = self.create_tx(
    121+            wallet,
    122+            [{"txid": tx_chain_2_txid, "vout": 0}],
    123+            {addr4: Decimal("9.99998")},
    124+            version=3,
    125+            prevtxs=prevtxs_chain_3
    126+        )
    127+        tx_chain_3_decoded = node.decoderawtransaction(tx_chain_3_hex)
    128+        tx_chain_3_txid = tx_chain_3_decoded["txid"]
    129
    130-        # Testing 3<-2 versions allowed
    131-        tx_v3_block = self.wallet.create_self_transfer(version=3)
    132+        tx_to_mine = [tx_chain_1_hex, tx_chain_2_hex, tx_chain_3_hex]
    133+        self.generateblock(node, output="raw(42)", transactions=tx_to_mine)
    134
    135-        # Testing overly-large child size
    136-        tx_v3_block2 = self.wallet.create_self_transfer(version=3)
    137+        self.check_mempool([])
    138
    139-        # Also create a linear chain of 3 TRUC transactions that will be directly mined, followed by one v2 in-mempool after block is made
    140-        tx_chain_1 = self.wallet.create_self_transfer(version=3)
    141-        tx_chain_2 = self.wallet.create_self_transfer(utxo_to_spend=tx_chain_1["new_utxo"], version=3)
    142-        tx_chain_3 = self.wallet.create_self_transfer(utxo_to_spend=tx_chain_2["new_utxo"], version=3)
    143+        self.trigger_reorg(fork_blocks)
    144
    145-        tx_to_mine = [tx_v3_block["hex"], tx_v2_block["hex"], tx_v3_block2["hex"], tx_chain_1["hex"], tx_chain_2["hex"], tx_chain_3["hex"]]
    146-        self.generateblock(node, output="raw(42)", transactions=tx_to_mine)
    147+        self.check_mempool([tx_chain_1_txid, tx_chain_2_txid, tx_chain_3_txid])
    148
    149-        self.check_mempool([])
    150+        # tx_chain_4: v2 transaction funded by the wallet (spending unconfirmed outputs from tx_chain_1)
    151+        addr5 = wallet.getnewaddress()
    152+        self.log.info("Creating tx_chain_4 spending from unconfirmed tx_chain_1")
    153+        tx_chain_4_txid = self.send_tx(
    154+            wallet,
    155+            [],  # Let fundrawtransaction pick inputs
    156+            {addr5: Decimal("9.99")},
    157+            version=2
    158+        )
    159
    160-        tx_v2_from_v3 = self.wallet.send_self_transfer(from_node=node, utxo_to_spend=tx_v3_block["new_utxo"], version=2)
    161-        tx_v3_from_v2 = self.wallet.send_self_transfer(from_node=node, utxo_to_spend=tx_v2_block["new_utxo"], version=3)
    162-        tx_v3_child_large = self.wallet.send_self_transfer(from_node=node, utxo_to_spend=tx_v3_block2["new_utxo"], target_vsize=1250, version=3)
    163-        assert_greater_than(node.getmempoolentry(tx_v3_child_large["txid"])["vsize"], TRUC_CHILD_MAX_VSIZE)
    164-        tx_chain_4 = self.wallet.send_self_transfer(from_node=node, utxo_to_spend=tx_chain_3["new_utxo"], version=2)
    165-        self.check_mempool([tx_v2_from_v3["txid"], tx_v3_from_v2["txid"], tx_v3_child_large["txid"], tx_chain_4["txid"]])
    166+        self.check_mempool([tx_chain_1_txid, tx_chain_2_txid, tx_chain_3_txid, tx_chain_4_txid])
    167
    168-        # Reorg should have all block transactions re-accepted, ignoring TRUC enforcement
    169-        self.trigger_reorg(fork_blocks)
    170-        self.check_mempool([tx_v3_block["txid"], tx_v2_block["txid"], tx_v3_block2["txid"], tx_v2_from_v3["txid"], tx_v3_from_v2["txid"], tx_v3_child_large["txid"], tx_chain_1["txid"], tx_chain_2["txid"], tx_chain_3["txid"], tx_chain_4["txid"]])
    171+        wallet.unloadwallet() [@cleanup](/bitcoin-bitcoin/contributor/cleanup/)(extra_args=["-limitclustercount=1"])
    172     def test_nondefault_package_limits(self):
    173@@ -658,20 +749,20 @@ class MempoolTRUC(BitcoinTestFramework):
    174         node = self.nodes[0]
    175         self.wallet = MiniWallet(node)
    176         self.generate(self.wallet, 200)
    177-        self.test_truc_max_vsize()
    178-        self.test_truc_acceptance()
    179-        self.test_truc_replacement()
    180+        #self.test_truc_max_vsize()
    181+        #self.test_truc_acceptance()
    182+        #self.test_truc_replacement()
    183         self.test_truc_reorg()
    184-        self.test_nondefault_package_limits()
    185-        self.test_truc_ancestors_package()
    186-        self.test_truc_ancestors_package_and_mempool()
    187-        self.test_sibling_eviction_package()
    188-        self.test_truc_package_inheritance()
    189-        self.test_truc_in_testmempoolaccept()
    190-        self.test_reorg_2child_rbf()
    191-        self.test_truc_sibling_eviction()
    192-        self.test_reorg_sibling_eviction_1p2c()
    193-        self.test_minrelay_in_package_combos()
    194+        #self.test_nondefault_package_limits()
    195+        #self.test_truc_ancestors_package()
    196+        #self.test_truc_ancestors_package_and_mempool()
    197+        #self.test_sibling_eviction_package()
    198+        #self.test_truc_package_inheritance()
    199+        #self.test_truc_in_testmempoolaccept()
    200+        #self.test_reorg_2child_rbf()
    201+        #self.test_truc_sibling_eviction()
    202+        #self.test_reorg_sibling_eviction_1p2c()
    203+        #self.test_minrelay_in_package_combos()
    204
    205
    206 if __name__ == "__main__":
    207--
    2082.50.1
    

    Assertion was added in #32896

    This was found with a test running on Antithesis.

  2. fanquake commented at 11:13 am on January 6, 2026: member
  3. fanquake added the label Wallet on Jan 6, 2026
  4. instagibbs commented at 2:13 pm on January 6, 2026: member
    I’m less familiar with the wallet code these days, but I think an ok response may be to consider the coin not available in this case?
  5. ismaelsadeeq commented at 2:15 pm on January 6, 2026: member

    I think I understand the issue. The Assume is what’s wrong there, and it should be deleted.

    A simpler case could also trigger this: Block 100 - has tx A (non-TRUC) Block 101 - has tx B spending A (TRUC) A reorg happens with a new chain at blocks 100, 101, and 102, where in the alternate chain both tx A and tx B are not mined, so they end up in the mempool. When you try to spend an output of A, we assume that A has no TRUC children. That’s correct if a reorg did not happen, but when a reorg happens, that’s an incorrect assumption. The correct fix here is to just get rid of the assumption

  6. glozow commented at 6:46 pm on January 7, 2026: member

    The correct fix here is to just get rid of the assumption

    Agree removing the assumption seems fine. It’s only there to catch simple cases of tracking a child incorrectly; what we really care about is if the wallet incorrectly allows you to spend coins you shouldn’t.

    I think an ok response may be to consider the coin not available in this case?

    I can’t think of a case where something would go wrong, but probably safer to do this?

  7. instagibbs commented at 1:45 pm on January 9, 2026: member
    I think I changed my mind, we should just ignore it, otherwise on reorg users may get “stuck” and being able to CPFP with other utxos will help resolve it cleanly. Opened #34238
  8. fanquake closed this on Jan 15, 2026

  9. fanquake referenced this in commit 37cb209277 on Jan 15, 2026

github-metadata-mirror

This is a metadata mirror of the GitHub repository bitcoin/bitcoin. This site is not affiliated with GitHub. Content is generated from a GitHub metadata backup.
generated: 2026-01-22 09:13 UTC

This site is hosted by @0xB10C
More mirrored repositories can be found on mirror.b10c.me