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

issue dergoegge opened 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

    <details>

    <summary>Vibe coded functional test patch to demonstrate (`-DABORT_ON_FAILED_ASSUME` is required):</summary>

    From 54aea28e6439be5645267f0344a6337d9798ba14 Mon Sep 17 00:00:00 2001
    From: dergoegge <n.goeggi@gmail.com>
    Date: Tue, 6 Jan 2026 10:49:02 +0000
    Subject: [PATCH] truc wallet reorg bug
    
    ---
     test/functional/mempool_truc.py | 163 +++++++++++++++++++++++++-------
     1 file changed, 127 insertions(+), 36 deletions(-)
    
    diff --git a/test/functional/mempool_truc.py b/test/functional/mempool_truc.py
    index 2586308e4d..a155d57b68 100755
    --- a/test/functional/mempool_truc.py
    +++ b/test/functional/mempool_truc.py
    @@ -47,6 +47,36 @@ class MempoolTRUC(BitcoinTestFramework):
             self.extra_args = [[]]
             self.setup_clean_chain = True
    
    +    def skip_test_if_missing_module(self):
    +        self.skip_if_no_wallet()
    +
    +    def create_tx(self, wallet, inputs, outputs, version, prevtxs=None):
    +        """Create a signed transaction using the real wallet.
    +
    +        Args:
    +            wallet: The wallet RPC to use
    +            inputs: List of inputs (empty to let fundrawtransaction pick)
    +            outputs: Dict of address -> amount
    +            version: Transaction version
    +            prevtxs: Optional list of previous transaction info for signing
    +                     unbroadcast transactions. Each entry should have:
    +                     {"txid": ..., "vout": ..., "scriptPubKey": ..., "amount": ...}
    +        """
    +        raw_tx = wallet.createrawtransaction(inputs=inputs, outputs=outputs, version=version)
    +        if inputs == []:
    +            raw_tx = wallet.fundrawtransaction(raw_tx, {'include_unsafe': True})["hex"]
    +        if prevtxs:
    +            signed_tx = wallet.signrawtransactionwithwallet(raw_tx, prevtxs)
    +        else:
    +            signed_tx = wallet.signrawtransactionwithwallet(raw_tx)
    +        return signed_tx["hex"]
    +
    +    def send_tx(self, wallet, inputs, outputs, version, prevtxs=None):
    +        """Create and broadcast a transaction using the real wallet."""
    +        tx_hex = self.create_tx(wallet, inputs, outputs, version, prevtxs)
    +        txid = wallet.sendrawtransaction(tx_hex)
    +        return txid
    +
         def check_mempool(self, txids):
             """Assert exact contents of the node's mempool (by txid)."""
             mempool_contents = self.nodes[0].getrawmempool()
    @@ -173,41 +203,102 @@ class MempoolTRUC(BitcoinTestFramework): [@cleanup](/bitcoin-bitcoin/contributor/cleanup/)()
         def test_truc_reorg(self):
             node = self.nodes[0]
    +        self.log.info("Test TRUC transactions survive reorg correctly")
    +
    +        # Create a wallet for this test
    +        node.createwallet("truc_reorg_wallet")
    +        wallet = node.get_wallet_rpc("truc_reorg_wallet")
    +
    +        # Fund the wallet
    +        funding_addr = wallet.getnewaddress()
    +        self.generatetoaddress(node, 1, funding_addr)
    +        self.generate(node, 100)  # Mature the coinbase
    
             # Prep for fork
             fork_blocks = create_empty_fork(node)
    -        self.log.info("Test that, during a reorg, TRUC rules are not enforced")
             self.check_mempool([])
    
    -        # Testing 2<-3 versions allowed
    -        tx_v2_block = self.wallet.create_self_transfer(version=2)
    +        # tx_chain_1: v2 transaction with 2 outputs
    +        addr1 = wallet.getnewaddress()
    +        addr2 = wallet.getnewaddress()
    +        tx_chain_1_hex = self.create_tx(wallet, [], {addr1: 10, addr2: 10}, version=2)
    +        tx_chain_1_decoded = node.decoderawtransaction(tx_chain_1_hex)
    +        tx_chain_1_txid = tx_chain_1_decoded["txid"]
    +
    +        # Find which vout corresponds to which address and get scriptPubKey info
    +        vout_for_addr1 = None
    +        vout_for_addr2 = None
    +        scriptpubkey_addr1 = None
    +        scriptpubkey_addr2 = None
    +        for vout in tx_chain_1_decoded["vout"]:
    +            if vout["scriptPubKey"].get("address") == addr1:
    +                vout_for_addr1 = vout["n"]
    +                scriptpubkey_addr1 = vout["scriptPubKey"]["hex"]
    +            elif vout["scriptPubKey"].get("address") == addr2:
    +                vout_for_addr2 = vout["n"]
    +                scriptpubkey_addr2 = vout["scriptPubKey"]["hex"]
    +
    +        # tx_chain_2: v3 transaction spending first output of tx_chain_1
    +        addr3 = wallet.getnewaddress()
    +        prevtxs_chain_2 = [{
    +            "txid": tx_chain_1_txid,
    +            "vout": vout_for_addr1,
    +            "scriptPubKey": scriptpubkey_addr1,
    +            "amount": Decimal("10")
    +        }]
    +        tx_chain_2_hex = self.create_tx(
    +            wallet,
    +            [{"txid": tx_chain_1_txid, "vout": vout_for_addr1}],
    +            {addr3: Decimal("9.99999")},
    +            version=3,
    +            prevtxs=prevtxs_chain_2
    +        )
    +        tx_chain_2_decoded = node.decoderawtransaction(tx_chain_2_hex)
    +        tx_chain_2_txid = tx_chain_2_decoded["txid"]
    +
    +        # Get scriptPubKey for tx_chain_2 output
    +        scriptpubkey_addr3 = tx_chain_2_decoded["vout"][0]["scriptPubKey"]["hex"]
    +
    +        # tx_chain_3: v3 transaction spending output of tx_chain_2
    +        addr4 = wallet.getnewaddress()
    +        prevtxs_chain_3 = [{
    +            "txid": tx_chain_2_txid,
    +            "vout": 0,
    +            "scriptPubKey": scriptpubkey_addr3,
    +            "amount": Decimal("9.99999")
    +        }]
    +        tx_chain_3_hex = self.create_tx(
    +            wallet,
    +            [{"txid": tx_chain_2_txid, "vout": 0}],
    +            {addr4: Decimal("9.99998")},
    +            version=3,
    +            prevtxs=prevtxs_chain_3
    +        )
    +        tx_chain_3_decoded = node.decoderawtransaction(tx_chain_3_hex)
    +        tx_chain_3_txid = tx_chain_3_decoded["txid"]
    
    -        # Testing 3<-2 versions allowed
    -        tx_v3_block = self.wallet.create_self_transfer(version=3)
    +        tx_to_mine = [tx_chain_1_hex, tx_chain_2_hex, tx_chain_3_hex]
    +        self.generateblock(node, output="raw(42)", transactions=tx_to_mine)
    
    -        # Testing overly-large child size
    -        tx_v3_block2 = self.wallet.create_self_transfer(version=3)
    +        self.check_mempool([])
    
    -        # Also create a linear chain of 3 TRUC transactions that will be directly mined, followed by one v2 in-mempool after block is made
    -        tx_chain_1 = self.wallet.create_self_transfer(version=3)
    -        tx_chain_2 = self.wallet.create_self_transfer(utxo_to_spend=tx_chain_1["new_utxo"], version=3)
    -        tx_chain_3 = self.wallet.create_self_transfer(utxo_to_spend=tx_chain_2["new_utxo"], version=3)
    +        self.trigger_reorg(fork_blocks)
    
    -        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"]]
    -        self.generateblock(node, output="raw(42)", transactions=tx_to_mine)
    +        self.check_mempool([tx_chain_1_txid, tx_chain_2_txid, tx_chain_3_txid])
    
    -        self.check_mempool([])
    +        # tx_chain_4: v2 transaction funded by the wallet (spending unconfirmed outputs from tx_chain_1)
    +        addr5 = wallet.getnewaddress()
    +        self.log.info("Creating tx_chain_4 spending from unconfirmed tx_chain_1")
    +        tx_chain_4_txid = self.send_tx(
    +            wallet,
    +            [],  # Let fundrawtransaction pick inputs
    +            {addr5: Decimal("9.99")},
    +            version=2
    +        )
    
    -        tx_v2_from_v3 = self.wallet.send_self_transfer(from_node=node, utxo_to_spend=tx_v3_block["new_utxo"], version=2)
    -        tx_v3_from_v2 = self.wallet.send_self_transfer(from_node=node, utxo_to_spend=tx_v2_block["new_utxo"], version=3)
    -        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)
    -        assert_greater_than(node.getmempoolentry(tx_v3_child_large["txid"])["vsize"], TRUC_CHILD_MAX_VSIZE)
    -        tx_chain_4 = self.wallet.send_self_transfer(from_node=node, utxo_to_spend=tx_chain_3["new_utxo"], version=2)
    -        self.check_mempool([tx_v2_from_v3["txid"], tx_v3_from_v2["txid"], tx_v3_child_large["txid"], tx_chain_4["txid"]])
    +        self.check_mempool([tx_chain_1_txid, tx_chain_2_txid, tx_chain_3_txid, tx_chain_4_txid])
    
    -        # Reorg should have all block transactions re-accepted, ignoring TRUC enforcement
    -        self.trigger_reorg(fork_blocks)
    -        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"]])
    +        wallet.unloadwallet() [@cleanup](/bitcoin-bitcoin/contributor/cleanup/)(extra_args=["-limitclustercount=1"])
         def test_nondefault_package_limits(self):
    @@ -658,20 +749,20 @@ class MempoolTRUC(BitcoinTestFramework):
             node = self.nodes[0]
             self.wallet = MiniWallet(node)
             self.generate(self.wallet, 200)
    -        self.test_truc_max_vsize()
    -        self.test_truc_acceptance()
    -        self.test_truc_replacement()
    +        #self.test_truc_max_vsize()
    +        #self.test_truc_acceptance()
    +        #self.test_truc_replacement()
             self.test_truc_reorg()
    -        self.test_nondefault_package_limits()
    -        self.test_truc_ancestors_package()
    -        self.test_truc_ancestors_package_and_mempool()
    -        self.test_sibling_eviction_package()
    -        self.test_truc_package_inheritance()
    -        self.test_truc_in_testmempoolaccept()
    -        self.test_reorg_2child_rbf()
    -        self.test_truc_sibling_eviction()
    -        self.test_reorg_sibling_eviction_1p2c()
    -        self.test_minrelay_in_package_combos()
    +        #self.test_nondefault_package_limits()
    +        #self.test_truc_ancestors_package()
    +        #self.test_truc_ancestors_package_and_mempool()
    +        #self.test_sibling_eviction_package()
    +        #self.test_truc_package_inheritance()
    +        #self.test_truc_in_testmempoolaccept()
    +        #self.test_reorg_2child_rbf()
    +        #self.test_truc_sibling_eviction()
    +        #self.test_reorg_sibling_eviction_1p2c()
    +        #self.test_minrelay_in_package_combos()
    
    
     if __name__ == "__main__":
    --
    2.50.1
    

    </details>

    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-04-21 18:12 UTC

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