wallet: store all witness variants of a transaction #35501

pull achow101 wants to merge 8 commits into bitcoin:master from achow101:wallet-tx-multiple-wtx changing 22 files +489 −170
  1. achow101 commented at 12:51 AM on June 10, 2026: member

    When the wallet is presented with a transaction that has the same txid as one already known to the wallet, but has a different witness, instead of ignoring the transaction, store it alongside the known tx. This enables the wallet to be aware of all wtxid variants of its transactions. This also allows for the wallet to be able to calculate fees for replacements better as txs with different witnesses may have different feerates.

    Specifically, the wallet stores these alternates in CWalletTx and extends the existing tx record type to essentially have a vector of transactions appended to the record. In CWalletTx, the single transaction is replaced with a map of wtxid to transaction so that all witness variants can still be represented by a single CWalletTx. For all of the various things that need the tx from a CWalletTx, a single witness variant is chosen to be the canonical tx and returned by GetTx(). This canonical tx is written into the same place as the previous single tx was written to in the tx record so that wallets can be loaded into previous versions.

    To choose the canonical transaction, if any of the variants is confirmed, then that is the canonical one. Otherwise, the witness variant with the least weight is chosen.

    An additional change I've included is to make CWalletTx RAII. This simplifies some of the implementation and enforces the assumption that a CWalletTx always has a transaction.

    Lastly, gettransaction and listtransaction have a new field alternate_wtxids to inform users of the wtxids of the witness variants for a transaction, and of course, a test.

    Closes #11240

  2. DrahtBot added the label Wallet on Jun 10, 2026
  3. DrahtBot commented at 12:51 AM on June 10, 2026: contributor

    <!--e57a25ab6845829454e8d69fc972939a-->

    The following sections might be updated with supplementary metadata relevant to reviewers and maintainers.

    <!--006a51241073e994b41acfe9ec718e94-->

    Code Coverage & Benchmarks

    For details see: https://corecheck.dev/bitcoin/bitcoin/pulls/35501.

    <!--021abf342d371248e50ceaed478a90ca-->

    Reviews

    See the guideline for information on the review process.

    Type Reviewers
    Concept ACK polespinasa, furszy, rkrux

    If your review is incorrectly listed, please copy-paste <code>&lt;!--meta-tag:bot-skip--&gt;</code> into the comment that the bot should ignore.

    <!--174a7506f384e20aa4161008e828411d-->

    Conflicts

    Reviewers, this pull request conflicts with the following ones:

    • #35569 (Encapsulation for CTransaction by purpleKarrot)
    • #35511 (RFC: consensus: Make CAmount a class by hodlinator)
    • #35302 (Silent Payments: Sending (take 2) by Eunovo)
    • #35294 (wallet: Update tx chain state during loading during AttachChain instead of before by achow101)
    • #34872 (wallet: fix mixed-input transaction accounting in history RPCs by w0xlt)
    • #34371 (wallet: allow importprunedfunds for spending transactions by 8144225309)
    • #33034 (wallet: Store transactions in a separate sqlite table by achow101)
    • #32763 (wallet: Replace CWalletTx::mapValue and vOrderForm with explicit class members by achow101)
    • #29278 (Wallet: Add maxfeerate wallet startup option by ismaelsadeeq)
    • #27865 (wallet: Track no-longer-spendable TXOs separately by achow101)

    If you consider this pull request important, please also help to review the conflicting pull requests. Ideally, start with the one that should be merged first.

    <!--5faf32d7da4f0f540f40219e4f7537a3-->

    LLM Linter (✨ experimental)

    Possible typos and grammar issues:

    • # Update listtransctions' output from old nodes to be compatible -> listtransactions [misspelled word; this is the only clear typo affecting comprehension]

    <sup>2026-06-27 02:01:54</sup>

  4. polespinasa commented at 7:22 AM on June 11, 2026: member

    Concept ACK will review :)

  5. rkrux commented at 9:34 AM on June 11, 2026: contributor

    I have read the PR description and only glanced at the diff yet, I have the following questions.

    This enables the wallet to be aware of all wtxid variants of its transactions.

    1. All - Doesn't this introduce an attack vector by making the wallet persist all (potentially several) variants of the transaction, thereby bloating the walletdb and backup?
    2. What's the benefit of storing all the variants once one of the variants have been confirmed for a while? If not much, I feel the rest of the variants should be removed from the database.
  6. achow101 commented at 5:09 PM on June 11, 2026: member
    1. All - Doesn't this introduce an attack vector by making the wallet persist all (potentially several) variants of the transaction, thereby bloating the walletdb and backup?

    It doesn't because there is no way to unconditionally add a transaction to the wallet. Unconfirmed transactions must first come through the mempool. This means that they need to be fully valid, and if replacing an existing transaction, have a higher fee rate. Confirmed transactions must be included in valid blocks that are connected to the chain tip. Both of these put natural limits on how many transactions can be added. Fundamentally, I don't see this as being any different from what the wallet already does where it stores every new transaction relevant to it as that is also theoretically unbounded growth.

    1. What's the benefit of storing all the variants once one of the variants have been confirmed for a while? If not much, I feel the rest of the variants should be removed from the database.

    It's a lot more complicated to periodically go through transaction history and delete things that are no longer relevant, and I don't think it really benefits us that much to do that. It may also be useful to a user to see their full history, including transactions that don't get confirmed. We likewise do not delete conflicted transactions that have been conflicted for a long time.

  7. rkrux commented at 2:18 PM on June 12, 2026: contributor

    Yeah, I am realising now that this doesn't introduce an attack vector.

    Unconfirmed transactions must first come through the mempool. This means that they need to be fully valid, and if replacing an existing transaction, have a higher fee rate.

    I was concerned about the wallet storing several witness-malleated unconfirmed transactions but I see that there is a check in the mempool prechecks section where the same txid and different wtxid transactions are not allowed: https://github.com/bitcoin/bitcoin/blob/4c99ed10766c7fac1353d1402d1b99de4c656c08/src/validation.cpp#L823-L827

    One way I see the wallet storing multiple witness variants would be when a low fee transaction is seen in the mempool, which makes the wallet store the first version. Then after an unclean node shutdown in absence of -persistmempool=0 (which could make the original transaction not persist on disk), a witness variant of this unconfirmed transaction comes in the mempool (maybe via a different peer) and the wallet ends up storing it.

    Both of these put natural limits on how many transactions can be added

    I can see that, practically, this would not lead to unbounded growth.

  8. in src/wallet/transaction.cpp:89 in 4ad42b5f46
      84 | +        ret = true;
      85 | +    }
      86 | +
      87 | +    CTransactionRef canon = GetTx();
      88 | +    if (force_canon || (inserted && tx->HasWitness() && (!canon->HasWitness() || (GetTransactionWeight(*tx) < GetTransactionWeight(*canon))))) {
      89 | +        m_canonical_wtxid = wtxid;
    


    furszy commented at 7:42 PM on June 12, 2026:

    As m_state is the canonical tx state. Shouldn't we update m_state only when it refers to the canonical tx and not for every arriving tx?


    achow101 commented at 8:05 PM on June 19, 2026:

    Not necessarily. It could be that we have a transaction that is inactive but still want to update m_state because a worse witness variant shows up in the mempool. In that case, the original tx is still better and I think we would want to rebroadcast that one, not the one that appeared in the mempool, so the canonical would be that original while state matched the one that showed up.

    Also, we can have state be given for a transaction that we did not see before that was confirmed. That's definitely one where we want to update the state regardless of what we have as the canonical tx before the state was received.

  9. in src/wallet/transaction.h:255 in 962b88d096
     249 | @@ -250,8 +250,10 @@ class CWalletTx
     250 |      mutable bool fChangeCached;
     251 |      mutable CAmount nChangeCached;
     252 |  
     253 | -    CWalletTx(CTransactionRef tx, const TxState& state) : m_state(state), tx(std::move(Assert(tx)))
     254 | +    CWalletTx(CTransactionRef tx, const TxState& state) : m_state(state), m_canonical_wtxid(tx->GetWitnessHash())
     255 |      {
     256 | +        Assert(tx);
    


    furszy commented at 8:08 PM on June 12, 2026:

    m_canonical_wtxid(tx->GetWitnessHash()) defers before the assert line.


    achow101 commented at 9:32 PM on June 19, 2026:

    Moved the assignment after the assert.

  10. in src/wallet/wallet.cpp:4025 in cb71cb3c7c
    4027 | -                    ins_wtx.SetTx(to_copy_wtx.tx);
    4028 | -                    ins_wtx.CopyFrom(to_copy_wtx);
    4029 | -                    return true;
    4030 | -                })) {
    4031 | +                CWalletTx copy_wtx(MakeTransactionRef(*wtx->tx), TxStateInactive{}); // This will be overwritten by CopyFrom
    4032 | +                copy_wtx.CopyFrom(*wtx);
    


    furszy commented at 8:52 PM on June 12, 2026:

    We should be more explicit during copy. This currently copies m_it_wtxOrdered which is an iterator to the main wallet wtxOrdered.

    Also, should MarkDirty after or inside CopyFrom. Cached amounts, change output status, etc. should be recomputed based on the wallet information.


    achow101 commented at 9:33 PM on June 19, 2026:

    I ended up changing the entire approach here. Since the goal is to load the transactions into the wallet as if they were just read from disk, I decided to have it take a trip through de/serialization with the new constructor. That should resolve any issues with in memory state.

  11. in src/wallet/transaction.h:336 in 962b88d096
     332 | +        m_txs.emplace(m_canonical_wtxid, std::move(canonical_tx));
     333 | +        while (!s.empty()) {
     334 | +            CTransactionRef tx;
     335 | +            s >> TX_WITH_WITNESS(tx);
     336 | +            m_txs.emplace(tx->GetWitnessHash(), std::move(tx));
     337 | +        }
    


    furszy commented at 8:59 PM on June 12, 2026:

    As a sanity check during load; could verify all the tx have the same hash but different witness hash and there are no duplicates.

    for (size_t i = 0; i < wtx_count; ++i) {
    	CTransactionRef tx;
    	s >> TX_WITH_WITNESS(tx);
    	// All variants of a CWalletTx must share the same txid; only wtxid differs
    	if (tx->GetHash() != GetHash()) {
    		throw std::ios_base::failure("Witness variant txid does not match canonical txid");
    	}
    	if (!m_txs.emplace(tx->GetWitnessHash(), std::move(tx)).second) {
    		throw std::ios_base::failure("Duplicate witness variant in CWalletTx record");
    	}
    }
    

    achow101 commented at 9:33 PM on June 19, 2026:

    Done

  12. in test/functional/wallet_listtransactions.py:308 in 21631e94b9
     303 | +        self.connect_nodes(0, 2)
     304 | +        self.sync_all()
     305 | +
     306 | +        tx_info = wallet.gettransaction(txid)
     307 | +        assert_equal(tx_info["hex"], script_path_tx)
     308 | +        assert key_path_wtxid in tx_info["alternate_wtxids"]
    


    furszy commented at 2:37 PM on June 13, 2026:

    In 21631e94b9e647f6b286195efe03137d886abd36:

    It would be good to check a few other things here:

    diff --git a/test/functional/wallet_listtransactions.py b/test/functional/wallet_listtransactions.py
    --- a/test/functional/wallet_listtransactions.py
    +++ b/test/functional/wallet_listtransactions.py
    @@ -16,6 +16,7 @@
         assert_not_equal,
         assert_array_result,
         assert_equal,
    +    assert_greater_than,
         assert_raises_rpc_error,
         find_vout_for_address,
     )
    @@ -243,6 +244,31 @@
                 assert "fee" in tx_info
                 assert_equal(any(detail["category"] == "send" for detail in tx_info["details"]), True)
     
    +    def check_tx_variants(self, wallet, txid, canonical_tx_hex, canonical_wtxid, alternate_wtxids):
    +        """Assert gettransaction and listtransactions report tx variants properly"""
    +        tx_info = wallet.gettransaction(txid)
    +        assert_equal(tx_info["hex"], canonical_tx_hex)
    +        assert_equal(tx_info["wtxid"], canonical_wtxid)
    +        # alternate_wtxids lists the other variants, never the canonical one.
    +        assert canonical_wtxid not in tx_info["alternate_wtxids"]
    +        assert_equal(set(tx_info["alternate_wtxids"]), set(alternate_wtxids))
    +
    +        # listtransactions exposes the same alternate_wtxids field as gettransaction
    +        list_entry = next(entry for entry in wallet.listtransactions() if entry["txid"] == txid)
    +        assert_equal(list_entry["alternate_wtxids"], tx_info["alternate_wtxids"])
    +
    +    # Returns the finalized psbt transaction and its wtxid
    +    def finalize_tx_variant(self, wallet, psbt, spend_path):
    +        # First check the expected spend path is being used
    +        sig_field = {"script": "taproot_script_path_sigs", "key": "taproot_key_path_sig"}
    +        present, absent = sig_field[spend_path], sig_field["key" if spend_path == "script" else "script"]
    +        psbt_input = self.nodes[0].decodepsbt(psbt)["inputs"][0]
    +        assert present in psbt_input and absent not in psbt_input
    +
    +        # Then finalize and decode
    +        tx = wallet.finalizepsbt(psbt)["hex"]
    +        return tx, wallet.decoderawtransaction(tx)["hash"]
    +
         def test_alternate_witness_tx(self):
             self.log.info("Test gettransaction when a transaction with an alternate wtxid is added")
             self.nodes[0].createwallet("altwit")
    @@ -259,42 +285,44 @@
             self.disconnect_nodes(0, 1)
             self.disconnect_nodes(0, 2)
     
    +        # Create output psbt
    +        psbt = wallet.walletcreatefundedpsbt(outputs=[{default_wallet.getnewaddress(): 0.5}])["psbt"]
    +
             # Create a script path spend
    -        psbt = wallet.walletcreatefundedpsbt(outputs=[{default_wallet.getnewaddress(): 0.5}])["psbt"]
             script_path_psbt = wallet.walletprocesspsbt(psbt=psbt, finalize=False)["psbt"]
    -        dec_psbt = self.nodes[0].decodepsbt(script_path_psbt)
    -        assert "taproot_script_path_sigs" in dec_psbt["inputs"][0]
    -        assert "taproot_key_path_sig" not in dec_psbt["inputs"][0]
    -        script_path_tx = wallet.finalizepsbt(script_path_psbt)["hex"]
    -        script_path_wtxid = self.nodes[0].decoderawtransaction(script_path_tx)["hash"]
    +        script_path_tx, script_path_wtxid = self.finalize_tx_variant(wallet, script_path_psbt, spend_path="script")
             txid = self.nodes[0].sendrawtransaction(script_path_tx)
    -        wallet.gettransaction(txid)
    +        self.check_tx_variants(wallet, txid, script_path_tx, script_path_wtxid, alternate_wtxids=[])
     
             # Make a key path spend separate from the wallet
             key_path_desc = descsum_create("tr(tprv8ZgxMBicQKsPerQj6m35no46amfKQdjY7AhLnmatHYXs8S4MTgeZYkWAn4edSGwwL3vkSiiGqSZQrmy5D3P5gBoqgvYP2fCUpBwbKTMTAkL/*,pk(tprv8ZgxMBicQKsPd3cbrKjE5GKKJLDEidhtzSSmPVtSPyoHQGL2LZw49yt9foZsN9BeiC5VqRaESUSDV2PS9w7zAVBSK6EQH3CZW9sMKxSKDwD/*))")
             key_path_psbt = self.nodes[0].descriptorprocesspsbt(psbt=psbt, descriptors=[{"desc": key_path_desc}], finalize=False)["psbt"]
    -        dec_psbt = self.nodes[0].decodepsbt(key_path_psbt)
    -        assert "taproot_script_path_sigs" not in dec_psbt["inputs"][0]
    -        assert "taproot_key_path_sig" in dec_psbt["inputs"][0]
    -        key_path_tx = wallet.finalizepsbt(key_path_psbt)["hex"]
    -        key_path_wtxid = self.nodes[0].decoderawtransaction(key_path_tx)["hash"]
    -        assert_not_equal(script_path_wtxid, key_path_wtxid)
    +        key_path_tx, key_path_wtxid = self.finalize_tx_variant(wallet, key_path_psbt, spend_path="key")
             txid2 = self.nodes[0].sendrawtransaction(key_path_tx)
    +
    +        # Ensure txs wtxids are not equal but their txid are
    +        assert_not_equal(script_path_wtxid, key_path_wtxid)
             assert_equal(txid, txid2)
    +
    +        # The key path spend has a lower weight than the script path spend, so
    +        # once both are known the key path is chosen as the canonical tx.
    +        assert_greater_than(
    +            self.nodes[0].decoderawtransaction(script_path_tx)["weight"],
    +            self.nodes[0].decoderawtransaction(key_path_tx)["weight"],
    +        )
    +
    +        # Mine and check the wallet has been properly updated
             self.generateblock(self.nodes[0], default_wallet.getnewaddress(), [key_path_tx], sync_fun=self.no_op)
    -        tx_info = wallet.gettransaction(txid)
    +        self.check_tx_variants(wallet, txid2, key_path_tx, key_path_wtxid, alternate_wtxids=[script_path_wtxid])
     
    -        # The transaction returned by gettransaction should be the key path as it has a lower weight
    -        # And the script path wtxid should be in alternate_txids
    -        assert_equal(tx_info["hex"], key_path_tx)
    -        assert script_path_wtxid in tx_info["alternate_wtxids"]
    -
    -        # Check persistence
    +        # Check persistence: both witness variants and the canonical choice survive a wallet reload
             wallet.unloadwallet()
             self.nodes[0].loadwallet("altwit")
             tx_info = wallet.gettransaction(txid)
             assert_equal(tx_info["hex"], key_path_tx)
    +        assert_equal(tx_info["wtxid"], key_path_wtxid)
             assert script_path_wtxid in tx_info["alternate_wtxids"]
    +        assert key_path_wtxid not in tx_info["alternate_wtxids"]
     
             # Reorging the script path spend to be confirmed will change the canonical tx
             self.generate(self.nodes[1], 3, sync_fun=self.no_op)
    @@ -303,9 +331,12 @@
             self.connect_nodes(0, 2)
             self.sync_all()
     
    -        tx_info = wallet.gettransaction(txid)
    -        assert_equal(tx_info["hex"], script_path_tx)
    -        assert key_path_wtxid in tx_info["alternate_wtxids"]
    +        self.check_tx_variants(wallet, txid, script_path_tx, script_path_wtxid, alternate_wtxids=[key_path_wtxid])
    +
    +        # The canonical flip must also persist across a reload
    +        wallet.unloadwallet()
    +        self.nodes[0].loadwallet("altwit")
    +        self.check_tx_variants(wallet, txid, script_path_tx, script_path_wtxid, alternate_wtxids=[key_path_wtxid])
     
     
     if __name__ == '__main__':
    

    furszy commented at 11:42 PM on June 13, 2026:

    achow101 commented at 9:34 PM on June 19, 2026:

    Taken

  13. furszy commented at 11:40 PM on June 13, 2026: member

    Concept ACK, cool we are finally doing this.

    Beyond the comments; when a reorg unconfirms a tx, its variants are all unconfirmed again, so we should re-pick the canonical one by weight rather than leaving the previously confirmed variant pinned. Because otherwise the wallet could re-broadcast the heavy one (if that one was confirmed before). All yours https://github.com/furszy/bitcoin-core/commit/bc6a4d34f6ac2ab038d235262a6aae56b8936aa3.

    Coverage for this scenario plus a good number of extra checks https://github.com/furszy/bitcoin-core/commit/79d868dc363d4dd509bdf6ee195a6a649b1ea627

  14. furszy commented at 5:58 PM on June 14, 2026: member

    Thinking further, the current storage design is not backwards compatible, any tx update by previous releases will drop all witness variants. This is because we overwrite the tx record during update, and prev releases have no knowledge of the serialization extension, so they will discard it.

    Have created a compat test exercising this scenario. Feel free to take it f3bfe42a6f41eed903983ab3b0b0f9999b4850bd.

    I think the best approach to overcome this issue is to store each variant in its own record instead, since old soft versions leave records it do not know about untouched.

    Have implemented it in f71bbaa6482e4b3937ee17d25c3e0b5cdc873b92, all yours as well. Can directly replace your 962b88d096df306ffb4a4bd4bc9ce6a761c77384 with it, the compat test will pass then.

    Complete branch with the changes is here: https://github.com/furszy/bitcoin-core/tree/wallet-tx-multiple-wtx-variants

  15. achow101 force-pushed on Jun 19, 2026
  16. achow101 commented at 9:34 PM on June 19, 2026: member

    Taken the suggested commits.

    Also renamed AddTx to Update and moved all of the state update and checks into it.

  17. rkrux commented at 8:53 AM on June 22, 2026: contributor

    Concept ACK 5376faecbbd09b04de82292127dbdd12328aa03b

    Please add a release note because of the changes in the commit eba680ef28e70f3c24ceb27b13cb487f7b8d9171 "wallet: Show alternate wtxids in gettransaction".

  18. in src/wallet/transaction.h:392 in 3555662dd2 outdated
     391 | +    const std::map<Wtxid, CTransactionRef>& GetTxs() const { return m_txs; }
     392 |  
     393 |  private:
     394 | -    CTransactionRef tx;
     395 | +    Wtxid m_canonical_wtxid;
     396 | +    std::map<Wtxid, CTransactionRef> m_txs;
    


    rkrux commented at 11:14 AM on June 22, 2026:

    In 3555662dd2300d9b487d2231dfdea7388dc51473 "wallet: Store all witness variants of a transaction"

    m_txs and the corresponding GetTxs: Can't they be called m_variants and GetVariants() respectively? Reading wtx.GetTxs() (wallet transaction dot get transactions) is a bit odd.

    On second thought: m_all_variants and GetAllVariants - because the canonical one is also stored here.


    achow101 commented at 8:22 PM on June 22, 2026:

    I prefer the current naming, leaving as is.

  19. in test/functional/wallet_listtransactions.py:279 in 82236dca7c outdated
     274 | +        self.nodes[0].createwallet("altwit")
     275 | +        default_wallet = self.nodes[0].get_wallet_rpc(self.default_wallet_name)
     276 | +        wallet = self.nodes[0].get_wallet_rpc("altwit")
     277 | +
     278 | +        # Import a taproot descriptor with script paths
     279 | +        desc = descsum_create("tr(tpubD6NzVbkrYhZ4YKSWzQhgCCiD9oBFZxvSgUJ85HdBhpLFxvK865U9jF82xCoBAn9nwNZ4uwX7ZKhZbh2iZRPa5s3UHXg3v7d1srFY44SFJVt/*,pk(tprv8ZgxMBicQKsPd3cbrKjE5GKKJLDEidhtzSSmPVtSPyoHQGL2LZw49yt9foZsN9BeiC5VqRaESUSDV2PS9w7zAVBSK6EQH3CZW9sMKxSKDwD/*))")
    


    rkrux commented at 11:36 AM on June 22, 2026:

    In 82236dca7cb49fee8247891423927ee4340eb12e "test: Test for wallet txs with alternate wtxids"

    If #35543 gets merged before this PR:

    diff --git a/test/functional/wallet_listtransactions.py b/test/functional/wallet_listtransactions.py
    index 9e6b2fa8aa..65d244987f 100755
    --- a/test/functional/wallet_listtransactions.py
    +++ b/test/functional/wallet_listtransactions.py
    @@ -275,8 +275,10 @@ class ListTransactionsTest(BitcoinTestFramework):
             default_wallet = self.nodes[0].get_wallet_rpc(self.default_wallet_name)
             wallet = self.nodes[0].get_wallet_rpc("altwit")
     
    +        xprvs = [ExtendedPrivateKey.generate() for _ in range(0, 2)]
    +        xpubs = [xprv.pubkey() for xprv in xprvs]
             # Import a taproot descriptor with script paths
    -        desc = descsum_create("tr(tpubD6NzVbkrYhZ4YKSWzQhgCCiD9oBFZxvSgUJ85HdBhpLFxvK865U9jF82xCoBAn9nwNZ4uwX7ZKhZbh2iZRPa5s3UHXg3v7d1srFY44SFJVt/*,pk(tprv8ZgxMBicQKsPd3cbrKjE5GKKJLDEidhtzSSmPVtSPyoHQGL2LZw49yt9foZsN9BeiC5VqRaESUSDV2PS9w7zAVBSK6EQH3CZW9sMKxSKDwD/*))")
    +        desc = descsum_create(f"tr({xpubs[0].to_string()}/*,pk({xprvs[1].to_string()}/*))")
             assert_equal(wallet.importdescriptors([{"desc": desc, "active": True, "timestamp": "now"}])[0]["success"], True)
             default_wallet.sendtoaddress(wallet.getnewaddress(address_type="bech32m"), 1)
             self.generate(self.nodes[0], 1, sync_fun=self.no_op)
    @@ -296,7 +298,7 @@ class ListTransactionsTest(BitcoinTestFramework):
             self.check_tx_variants(wallet, txid, script_path_tx, script_path_wtxid, alternate_wtxids=[])
     
             # Make a key path spend separate from the wallet
    -        key_path_desc = descsum_create("tr(tprv8ZgxMBicQKsPerQj6m35no46amfKQdjY7AhLnmatHYXs8S4MTgeZYkWAn4edSGwwL3vkSiiGqSZQrmy5D3P5gBoqgvYP2fCUpBwbKTMTAkL/*,pk(tprv8ZgxMBicQKsPd3cbrKjE5GKKJLDEidhtzSSmPVtSPyoHQGL2LZw49yt9foZsN9BeiC5VqRaESUSDV2PS9w7zAVBSK6EQH3CZW9sMKxSKDwD/*))")
    +        key_path_desc = descsum_create(f"tr({xprvs[0].to_string()}/*,pk({xpubs[1].to_string()}/*))")
             key_path_psbt = self.nodes[0].descriptorprocesspsbt(psbt=psbt, descriptors=[{"desc": key_path_desc}], finalize=False)["psbt"]
             key_path_tx, key_path_wtxid = self.finalize_tx_variant(wallet, key_path_psbt, spend_path="key")
     
    
    

    Similarly in 5376faecbbd09b04de82292127dbdd12328aa03b "test: compat, ensure downgrade preserves tx witness variants" as well.


    achow101 commented at 2:01 AM on June 27, 2026:

    Doen now that #35543 is merged

  20. in src/wallet/rpc/transactions.cpp:389 in eba680ef28


    rkrux commented at 1:41 PM on June 22, 2026:

    In eba680ef28e70f3c24ceb27b13cb487f7b8d9171 "wallet: Show alternate wtxids in gettransaction"

    listsinceblock is also updated because it calls TransactionDescriptionString as well. The release note would need to mention that RPC too.


    achow101 commented at 8:22 PM on June 22, 2026:

    Mentioned in the new release note.


    w0xlt commented at 12:48 AM on June 25, 2026:

    Should listsinceblock(..., include_removed=true) report the canonical wtxid when the detached block contained a different witness variant?

    This issue does not exist on master because there is no “canonical variant” concept yet.

    Currently, the removed entry reports the wallet’s current canonical wtxid, regardless of which witness variant was actually in the detached block. If it is a deliberate design choice, the assertion below can be added to make the intent clearer.

    diff --git a/test/functional/wallet_listtransactions.py b/test/functional/wallet_listtransactions.py
    index 9e6b2fa8aa..e65ecb8c1e 100755
    --- a/test/functional/wallet_listtransactions.py
    +++ b/test/functional/wallet_listtransactions.py
    @@ -352,6 +352,17 @@ class ListTransactionsTest(BitcoinTestFramework):
             assert_equal(wallet.gettransaction(txid)["confirmations"], 0)
             self.check_tx_variants(wallet, txid, key_path_tx, key_path_wtxid, alternate_wtxids=[script_path_wtxid])
     
    +        # listsinceblock's "removed" entries describe the wallet's current CWalletTx,
    +        # not a snapshot of the detached block. Even though the detached block contained
    +        # the script path variant, every field reflects live wallet state: confirmations
    +        # is 0 (not the block depth) and "wtxid" is the canonical (key path) variant, just
    +        # as gettransaction reports for this txid. The variant that was in the detached
    +        # block is still available under "alternate_wtxids".
    +        removed = next(entry for entry in wallet.listsinceblock(block)["removed"] if entry["txid"] == txid)
    +        assert_equal(removed["confirmations"], 0)
    +        assert_equal(removed["wtxid"], key_path_wtxid)
    +        assert_equal(removed["alternate_wtxids"], [script_path_wtxid])
    +
     
     if __name__ == '__main__':
         ListTransactionsTest(__file__).main()
    

    achow101 commented at 1:01 AM on June 27, 2026:

    I think this can be fixed in a followup.

  21. in src/wallet/rpc/transactions.cpp:404 in eba680ef28 outdated
     398 | @@ -393,6 +399,10 @@ static std::vector<RPCResult> TransactionDescriptionString()
     399 |             {RPCResult::Type::NUM_TIME, "blocktime", /*optional=*/true, "The block time expressed in " + UNIX_EPOCH_TIME + "."},
     400 |             {RPCResult::Type::STR_HEX, "txid", "The transaction id."},
     401 |             {RPCResult::Type::STR_HEX, "wtxid", "The hash of serialized transaction, including witness data."},
     402 | +           {RPCResult::Type::ARR, "alternate_wtxids", "The wtxids of transactions with different witness data but the same txid.",
     403 | +           {
     404 | +               {RPCResult::Type::STR_HEX, "wtxid", "The witness transaction id."},
    


    rkrux commented at 1:44 PM on June 22, 2026:

    In eba680ef28e70f3c24ceb27b13cb487f7b8d9171 "wallet: Show alternate wtxids in gettransaction"

    I don't suppose there is a way for the user to see the witnesses of the variants. Returning the full transaction hex of the variants in case verbose argument is set in gettransaction RPC can prove to be helpful in this regard.


    achow101 commented at 8:24 PM on June 22, 2026:

    I think that putting the full raw txs would end up being pretty noisy. Related to the idea I had to list raw wallet transactions, we could have a new RPC that retrieves raw wallet transactions, optionally by wtxid. Or gettransaction could be overloaded to take a wtxid. But I think that's work for a followup.

  22. in src/wallet/wallet.cpp:1072 in b7007579dc
    1070 | @@ -1071,22 +1071,7 @@ CWalletTx* CWallet::AddToWallet(CTransactionRef tx, const TxState& state, const
    1071 |  
    1072 |      if (!fInsertedNew)
    


    rkrux commented at 2:17 PM on June 22, 2026:

    In b7007579dc9d4d0e7e1f4ead1088ad329fa2cc44 "wallet: Replace CWalletTx::SetTx with Update"

    Can ignore because it's not touched by this PR but I noticed while reading, cleans up the related code a bit:

    --- a/src/wallet/wallet.cpp
    +++ b/src/wallet/wallet.cpp
    @@ -1067,10 +1067,7 @@ CWalletTx* CWallet::AddToWallet(CTransactionRef tx, const TxState& state, const
     
             // Update birth time when tx time is older than it.
             MaybeUpdateBirthTime(wtx.GetTxTime());
    -    }
    -
    -    if (!fInsertedNew)
    -    {
    +    } else {
             fUpdated = wtx.Update(tx, state);
         }
    
  23. achow101 commented at 8:25 PM on June 22, 2026: member

    Added a release note

  24. in src/wallet/wallet.cpp:1074 in b096c631bf
    1085 | -        // TODO: Store all versions of the transaction, instead of just one.
    1086 | -        if (tx->HasWitness() && !wtx.tx->HasWitness()) {
    1087 | -            wtx.SetTx(tx);
    1088 | -            fUpdated = true;
    1089 | -        }
    1090 | +        fUpdated = wtx.Update(tx, state);
    


    w0xlt commented at 10:10 PM on June 24, 2026:

    fUpdated is being overwritten here, so a true result from update_wtx can be lost. If update_wtx mutates the wallet tx but wtx.Update() returns false, WriteTx() is skipped and the metadata change is not persisted.

            fUpdated |= wtx.Update(tx, state);
    

    achow101 commented at 1:31 AM on June 27, 2026:

    Done

  25. in src/wallet/transaction.cpp:90 in b096c631bf
      85 | +            m_canonical_wtxid = wtxid;
      86 | +        }
      87 | +        ret = true;
      88 | +    } else {
      89 | +        assert(TxStateSerializedIndex(m_state) == TxStateSerializedIndex(arg_state));
      90 | +        assert(TxStateSerializedBlockHash(m_state) == TxStateSerializedBlockHash(arg_state));
    


    w0xlt commented at 11:31 PM on June 24, 2026:

    The wallet can already have a confirmed transaction stored without witness data, then later learn the full witness version of the same txid.

    If I understand correctly, it stores the new variant but keeps the old one as canonical because the state was already confirmed.

    The patch below makes a confirmed incoming variant canonical even when the wallet transaction was already confirmed. This lets the wallet return the full/newly learned confirmed transaction, assuming that is the desired behavior.

            assert(TxStateSerializedBlockHash(m_state) == TxStateSerializedBlockHash(arg_state));
            if (std::get_if<TxStateConfirmed>(&arg_state) && m_canonical_wtxid != wtxid) {
                  m_canonical_wtxid = wtxid;
                  ret = true;
            }
    

    The functional test below simulates this by importing a confirmed witness-stripped transaction first, then importing the full witness version with the same txid proof.

    <details> <summary>diff</summary>

    diff --git a/test/functional/wallet_importprunedfunds.py b/test/functional/wallet_importprunedfunds.py
    index 95e2a5b3a4..afe0c6b75b 100755
    --- a/test/functional/wallet_importprunedfunds.py
    +++ b/test/functional/wallet_importprunedfunds.py
    @@ -10,6 +10,7 @@ from test_framework.blocktools import COINBASE_MATURITY
     from test_framework.messages import (
         CMerkleBlock,
         from_hex,
    +    tx_from_hex,
     )
     from test_framework.test_framework import BitcoinTestFramework
     from test_framework.util import (
    @@ -108,6 +109,41 @@ class ImportPrunedFundsTest(BitcoinTestFramework):
             address_info = w1.getaddressinfo(address3)
             assert_equal(address_info['ismine'], True)
     
    +        self.log.info("Test importprunedfunds canonicalizes a full witness tx over a stripped variant")
    +        witness_address = self.nodes[0].getnewaddress(address_type="bech32")
    +        witness_funding_txid = self.nodes[0].sendtoaddress(witness_address, 1)
    +        self.generate(self.nodes[0], 1)
    +        witness_utxo = next(utxo for utxo in self.nodes[0].listunspent() if utxo["txid"] == witness_funding_txid and utxo["address"] == witness_address)
    +
    +        witness_import_address = self.nodes[0].getnewaddress(address_type="bech32")
    +        witness_txid = self.nodes[0].send(outputs=[{witness_import_address: 0.5}], inputs=[witness_utxo])["txid"]
    +        witness_block = self.generate(self.nodes[0], 1)[0]
    +        witness_tx_hex = self.nodes[0].gettransaction(witness_txid)["hex"]
    +        witness_tx = tx_from_hex(witness_tx_hex)
    +        stripped_tx_hex = witness_tx.serialize_without_witness().hex()
    +        stripped_tx = tx_from_hex(stripped_tx_hex)
    +        assert_equal(witness_tx.txid_hex, stripped_tx.txid_hex)
    +        assert_not_equal(witness_tx.wtxid_hex, stripped_tx.wtxid_hex)
    +        witness_proof = self.nodes[0].gettxoutproof([witness_txid], witness_block)
    +
    +        self.sync_all()
    +
    +        self.nodes[1].createwallet("wwitness", disable_private_keys=True)
    +        wwitness = self.nodes[1].get_wallet_rpc("wwitness")
    +        assert_equal(wwitness.importdescriptors([{"desc": self.nodes[0].getaddressinfo(witness_import_address)["desc"], "timestamp": "now"}])[0]["success"], True)
    +        if witness_txid in [tx["txid"] for tx in wwitness.listtransactions()]:
    +            wwitness.removeprunedfunds(witness_txid)
    +
    +        wwitness.importprunedfunds(stripped_tx_hex, witness_proof)
    +        tx_info = wwitness.gettransaction(witness_txid)
    +        assert_equal(tx_info["hex"], stripped_tx_hex)
    +        assert_equal(tx_info["wtxid"], stripped_tx.wtxid_hex)
    +
    +        wwitness.importprunedfunds(witness_tx_hex, witness_proof)
    +        tx_info = wwitness.gettransaction(witness_txid)
    +        assert_equal(tx_info["hex"], witness_tx_hex)
    +        assert_equal(tx_info["wtxid"], witness_tx.wtxid_hex)
    +
             # Remove transactions
             assert_raises_rpc_error(-4, f'Transaction {txnid1} does not belong to this wallet', w1.removeprunedfunds, txnid1)
             assert txnid1 not in [tx['txid'] for tx in w1.listtransactions()]
    
    

    </details>

    P.S.: The suggested patch above is order-dependent. Maybe something like the following would be preferable, but it would require is_better to be exposed.

      } else {
          assert(TxStateSerializedIndex(m_state) == TxStateSerializedIndex(arg_state));
          assert(TxStateSerializedBlockHash(m_state) == TxStateSerializedBlockHash(arg_state));
          // Among confirmed variants (proven only at txid level), keep the
          // witnessed/least-weight one canonical, matching RecomputeCanonical.
          if (isConfirmed() && is_better(tx, m_txs.at(m_canonical_wtxid))) {
              m_canonical_wtxid = wtxid;
              ret = true;
          }
      }
    

    achow101 commented at 12:59 AM on June 27, 2026:

    While technically true, I don't think it matters, nor are the suggestions correct. The transaction that should be canonical is the one that is confirmed, so blindly changing the canonical wtxid just because the user imported a transaction that is better can result in the canonical becoming something that it should not be. The current suggestion would allow setting a lower weight witness tx even when the canonical is a witness tx that is confirmed.

    I think we should prefer to do nothing once the transaction is confirmed.

    I also want to note that the only way for this condition to occur is if the user is doing something weird with importprunedfunds or something insane involving very old software. It is otherwise not possible for a witness stripped transaction to confirm.

  26. in src/wallet/transaction.cpp:69 in b096c631bf
      64 |  void CWalletTx::CopyFrom(const CWalletTx& _tx)
      65 |  {
      66 |      *this = _tx;
      67 |  }
      68 | +
      69 | +bool CWalletTx::Update(CTransactionRef arg, const TxState& arg_state)
    


    w0xlt commented at 1:09 AM on June 25, 2026:

    nit: parameter name can be more specific.

    bool CWalletTx::Update(CTransactionRef tx, const TxState& arg_state)
    

    achow101 commented at 1:31 AM on June 27, 2026:

    Done

  27. achow101 force-pushed on Jun 27, 2026
  28. wallet: Deserialize directly in CWalletTx's ctor
    When loading a transaction, instead of constructing a CWalletTx with no
    transaction, pass the DataStream into the constructor so that the
    CWalletTx is RAII. This allows us to ensure that the transaction is
    never a nullptr so that dereferences, especially once multiple txs are
    stored, will not cause a segfault.
    775f45c740
  29. wallet: Make CWalletTx::tx private and use CWalletTx::GetTx to access
    When CWalletTx will have multiple transactions, tx will no longer exist
    and accessing the single canonical tx should be done through an getter
    function.
    cc5caafa00
  30. wallet: Store all witness variants of a transaction
    A transaction can have several valid witnesses that share its txid but
    differ in wtxid, e.g. when a taproot output is spent via the key path
    in one variant and the script path in another.
    
    CWalletTx now keeps all of them in a map indexed by wtxid (m_txs) and
    marks one as canonical (m_canonical_wtxid): a confirmed variant if there
    is one, otherwise the one with a witness and the lowest weight. GetTx()
    and serialization return the canonical variant, so existing callers
    don't need to change.
    
    The other variants are stored in their own wtxvariant records keyed by
    (txid, wtxid) and merged back into the CWalletTx at load. The tx record
    keeps its old format, holding the canonical transaction, so older soft
    versions can still read and rewrite it without dropping those records.
    bb6cadf091
  31. wallet: Replace CWalletTx::SetTx with Update
    Instead of replacing the tx when a witness alternative appears, add it
    to the set of wtxid alternates.
    
    In order to determine whether the added transaction is the canonical
    transaction, Update also needs to know how the state is changing, so it
    will also update the state if it is being changed.
    5443e886b1
  32. wallet: Show alternate wtxids in gettransaction
    If a wallet tranasction has alterate witness versions, list those wtxids
    in gettransaction's output.
    0b9473e0cd
  33. test: Test for wallet txs with alternate wtxids bc681855f8
  34. test: compat, ensure downgrade preserves tx witness variants 73a371c0ec
  35. doc: release note for alternate_wtxids in gettransaction fb03b63601
  36. achow101 force-pushed on Jun 27, 2026
  37. DrahtBot added the label CI failed on Jun 27, 2026
  38. DrahtBot removed the label CI failed on Jun 27, 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-07-07 07:51 UTC

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