wallet: store all witness variants of a transaction #35501

pull achow101 wants to merge 10 commits into bitcoin:master from achow101:wallet-tx-multiple-wtx changing 24 files +505 −183
  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 and AI policy for information on the review process.

    Type Reviewers
    ACK furszy
    Concept ACK polespinasa, rkrux, arejula27

    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:

    • #35786 (wallet: drop spent parents redundant cache invalidation and notification by furszy)
    • #35760 (wallet: make corrupted transaction records fail wallet loading instead of forcing a rescan by achow101)
    • #35716 (wallet: Replace mapWallet and wtxOrdered with a boost::multi_index by achow101)
    • #35662 (script: make txdata non-default-constructible by l0rinc)
    • #35302 (Silent Payments: Sending (take 2) by Eunovo)
    • #35294 (wallet: Update tx chain state during loading during AttachChain instead of before by achow101)
    • #34909 (wallet, refactor: modularise wallet by extracting out legacy wallet migration by rkrux)
    • #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)
    • #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; likely intended RPC name]

    <sup>2026-07-18 02:02:59</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. achow101 force-pushed on Jun 27, 2026
  29. DrahtBot added the label CI failed on Jun 27, 2026
  30. DrahtBot removed the label CI failed on Jun 27, 2026
  31. in src/wallet/transaction.h:405 in fb03b63601
     402 | @@ -377,6 +403,9 @@ class CWalletTx
     403 |  public:
     404 |      // Instead have an explicit copy function
     405 |      void CopyFrom(const CWalletTx&);
    


    furszy commented at 7:21 PM on July 8, 2026:

    This method CopyFrom is no longer used, can remove it in the first commit.


    achow101 commented at 7:46 PM on July 13, 2026:

    Inserted a commit to do that and delete the copy constructors.

  32. in src/wallet/transaction.h:386 in bb6cadf091
     380 | @@ -371,12 +381,15 @@ class CWalletTx
     381 |      bool isInactive() const { return state<TxStateInactive>(); }
     382 |      bool isUnconfirmed() const { return !isAbandoned() && !isBlockConflicted() && !isMempoolConflicted() && !isConfirmed(); }
     383 |      bool isConfirmed() const { return state<TxStateConfirmed>(); }
     384 | -    const Txid& GetHash() const LIFETIMEBOUND { return tx->GetHash(); }
     385 | -    const Wtxid& GetWitnessHash() const LIFETIMEBOUND { return tx->GetWitnessHash(); }
     386 | -    bool IsCoinBase() const { return tx->IsCoinBase(); }
     387 | +    const Txid& GetHash() const LIFETIMEBOUND { return m_txs.at(m_canonical_wtxid)->GetHash(); }
     388 | +    const Wtxid& GetWitnessHash() const LIFETIMEBOUND { return m_txs.at(m_canonical_wtxid)->GetWitnessHash(); }
     389 | +    bool IsCoinBase() const { return m_txs.at(m_canonical_wtxid)->IsCoinBase(); }
    


    furszy commented at 7:29 PM on July 8, 2026:

    All this three can use GetTx() instead of doing m_txs.at(m_canonical_wtxid)


    achow101 commented at 7:46 PM on July 13, 2026:

    Done

  33. furszy commented at 3:01 AM on July 9, 2026: member

    Code review ACK. Left two small findings.

  34. achow101 force-pushed on Jul 13, 2026
  35. DrahtBot added the label CI failed on Jul 13, 2026
  36. DrahtBot commented at 8:05 PM on July 13, 2026: contributor

    <!--85328a0da195eb286784d51f73fa0af9-->

    🚧 At least one of the CI tasks failed. <sub>Task tidy: https://github.com/bitcoin/bitcoin/actions/runs/29279743187/job/86917733454</sub> <sub>LLM reason (✨ experimental): CI failed because the build stopped at compile time in src/wallet/export.cpp due to mismatched CWalletTx API members (SetTx, tx, CopyFrom) / wrong LoadToWallet lambda signature.</sub>

    <details><summary>Hints</summary>

    Try to run the tests locally, according to the documentation. However, a CI failure may still happen due to a number of reasons, for example:

    • Possibly due to a silent merge conflict (the changes in this pull request being incompatible with the current code in the target branch). If so, make sure to rebase on the latest commit of the target branch.

    • A sanitizer issue, which can only be found by compiling with the sanitizer and running the affected test.

    • An intermittent issue.

    Leave a comment here, if you need help tracking down a confusing failure.

    </details>

  37. achow101 force-pushed on Jul 13, 2026
  38. DrahtBot removed the label CI failed on Jul 13, 2026
  39. DrahtBot added the label Needs rebase on Jul 13, 2026
  40. achow101 force-pushed on Jul 14, 2026
  41. DrahtBot removed the label Needs rebase on Jul 14, 2026
  42. DrahtBot added the label CI failed on Jul 14, 2026
  43. DrahtBot commented at 1:24 AM on July 14, 2026: contributor

    <!--85328a0da195eb286784d51f73fa0af9-->

    🚧 At least one of the CI tasks failed. <sub>Task 32 bit ARM: https://github.com/bitcoin/bitcoin/actions/runs/29295868530/job/86969112888</sub> <sub>LLM reason (✨ experimental): CI failed due to a build error treated as fatal (-Werror=stringop-overread) in uint256.h where std::memcmp was called with an out-of-bounds length (WIDTH 32 vs source size 0).</sub>

    <details><summary>Hints</summary>

    Try to run the tests locally, according to the documentation. However, a CI failure may still happen due to a number of reasons, for example:

    • Possibly due to a silent merge conflict (the changes in this pull request being incompatible with the current code in the target branch). If so, make sure to rebase on the latest commit of the target branch.

    • A sanitizer issue, which can only be found by compiling with the sanitizer and running the affected test.

    • An intermittent issue.

    Leave a comment here, if you need help tracking down a confusing failure.

    </details>

  44. in test/functional/wallet_migration.py:187 in 37efd9dcad outdated
     183 | @@ -184,13 +184,13 @@ def check_last_hardened(conn):
     184 |          return migrate_info, wallet
     185 |  
     186 |      def test_basic(self):
     187 | -        # Remove the deprecated response fields that'd be present in the RPC responses
     188 | -        # sent by the old node(s).
     189 | -        def remove_deprecated_keys(list):
     190 | +        # Update listtransctions' output from old nodes to be compatible
    


    maflcko commented at 5:31 AM on July 14, 2026:

    llm linter:

    • listtransctions -> listtransactions [misspelled word in the comment # Update listtransctions' output from old nodes to be compatible]

    maflcko commented at 5:35 AM on July 14, 2026:

    Also, the GCC-14 bug looks unrelated:

    [31](https://github.com/bitcoin/bitcoin/actions/runs/29295868530/job/86969112888#step:10:3432)
    /home/runner/work/_temp/src/uint256.h:65:77: error: ‘int memcmp(const void*, const void*, size_t)’ specified bound 32 exceeds source size 0 [-Werror=stringop-overread]
       65 |     constexpr int Compare(const base_blob& other) const { return std::memcmp(m_data.data(), other.m_data.data(), WIDTH); }
          |                                                                  ~~~~~~~~~~~^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    cc1plus: all warnings being treated as errors
    

    Maybe a -Wno-error=... can be added to the GCC-14 CI tasks, or maybe Compare can be rewritten to something like:

    #include <array>
    #include <compare>
    
    constexpr int Compare(const base_blob& other) const {
        auto cmp = m_data <=> other.m_data;
        if (cmp < 0) return -1;
        if (cmp > 0) return 1;
        return 0;
    }
    

    (haven't tried this, though)

  45. in src/wallet/transaction.h:409 in 37efd9dcad outdated
     413 | +    // Enable the default move constructor
     414 | +    CWalletTx(CWalletTx&&) = default;
     415 | +
     416 | +private:
     417 | +    Wtxid m_canonical_wtxid;
     418 | +    std::map<Wtxid, CTransactionRef> m_txs;
    


    arejula27 commented at 5:00 PM on July 14, 2026:

    I don't think using a map is correct here. std::map<Wtxid, CTransactionRef> heap-allocates a node per entry, even for the ~99.9% of txs that have just one variant. A flat_map / sorted std::vector<pair<Wtxid, CTransactionRef>> would be free for N=1, and faster for small N in general, which I expect is the common case.


    achow101 commented at 7:42 PM on July 14, 2026:

    I think any performance improvement here is negligible.


    arejula27 commented at 10:17 PM on July 15, 2026:

    I evaluated deeper a few alternatives here, to see if there is any option that make sense:

    • flat_map: not available, it's C++23 and this codebase targets C++20.
    • Sorted std::vector<pair<Wtxid, CTransactionRef>>: would avoid the per-node allocation, but with N almost always 1 (rarely more than 2-3), the lookup cost is a couple of comparisons either way, not measurable. Not worth changing all the PR for that.
    • unordered_map: would actually be worse, not just neutral. The hashing + bucket array overhead doesn't pay off at this size, and it loses the deterministic iteration order map gives for free.

    Agreed, doesn't make sense to change the data type here, withdrawing this comment.

  46. in src/wallet/feebumper.cpp:215 in 37efd9dcad
     209 | @@ -210,9 +210,9 @@ Result CreateRateBumpTransaction(CWallet& wallet, const Txid& txid, const CCoinC
     210 |  
     211 |      // Figure out if we need to compute the input weight, and do so if necessary
     212 |      PrecomputedTransactionData txdata;
     213 | -    txdata.Init(*wtx.tx, std::move(spent_outputs), /* force=*/ true);
     214 | -    for (unsigned int i = 0; i < wtx.tx->vin.size(); ++i) {
     215 | -        const CTxIn& txin = wtx.tx->vin.at(i);
     216 | +    txdata.Init(*wtx.GetTx(), std::move(spent_outputs), /* force=*/ true);
     217 | +    for (unsigned int i = 0; i < wtx.GetTx()->vin.size(); ++i) {
     218 | +        const CTxIn& txin = wtx.GetTx()->vin.at(i);
    


    arejula27 commented at 5:07 PM on July 14, 2026:

    GetTx() is called three times and each call does a map lookup + shared_ptr copy. This can be avoided by hoisting it once: const auto& tx = wtx.GetTx(); and using tx->vin after that.


    achow101 commented at 7:42 PM on July 14, 2026:

    If I need to retouch.


    achow101 commented at 6:01 PM on July 16, 2026:

    Done

  47. in src/wallet/feebumper.cpp:52 in 37efd9dcad outdated
      48 | @@ -49,7 +49,7 @@ static feebumper::Result PreconditionChecks(const CWallet& wallet, const CWallet
      49 |      if (require_mine) {
      50 |          // check that original tx consists entirely of our inputs
      51 |          // if not, we can't bump the fee, because the wallet has no way of knowing the value of the other inputs (thus the fee)
      52 | -        if (!AllInputsMine(wallet, *wtx.tx)) {
      53 | +        if (!AllInputsMine(wallet, *wtx.GetTx())) {
    


    arejula27 commented at 5:29 PM on July 14, 2026:

    This line itself is just a rename (ftom wtx.tx to wtx.GetTx()), not a behavior change: before this PR there was only ever one known variant, so GetTx()'s vsize always matched the one actually relayed, by definition. Now that a CWalletTx can hold several variants, GetTx() returns the canonical one (least-weight when unconfirmed). Are we sure this is always the variant we want here, or can there be scenarios where the non-canonical one is actually the one that needs to be looked at?

    Specific example: a 2-of-3 P2WSH multisig UTXO. It gets signed and relayed with k1+k2, landing in the mempool. Independently, without knowing that already happened, it gets signed and relayed from another node with k2+k3, same txid, different witness. Say the wallet's canonical ends up being the k2+k3 variant, but the one actually sitting in the mempool, the one a fee bump would need to beat, is k1+k2. Is this scenario actually reachable today, and if so, doesn't EstimateFeeRate need to fetch the specific variant that's in the mempool (by wtxid) instead of always using GetTx()'s canonical?

    (Disclaimer: I might be misunderstanding something in the code flow here.)


    achow101 commented at 7:40 PM on July 14, 2026:

    Are we sure this is always the variant we want here, or can there be scenarios where the non-canonical one is actually the one that needs to be looked at?

    For the purposes of fee bumping, we should always choose the transaction with the highest feerate, i.e. the least weight, which is also the canonical tx. If we know about it, it's very likely that that transaction was broadcast, so there is a high chance that other nodes will know about it and we should therefore always base the feerate bump off of that to guarantee a replacement.


    arejula27 commented at 8:14 PM on July 14, 2026:

    I forgot rbf es the default behaviour sorry 🙃

  48. in src/wallet/interfaces.cpp:68 in 37efd9dcad
      70 | -    result.txout_address_is_mine.reserve(wtx.tx->vout.size());
      71 | -    for (const auto& txout : wtx.tx->vout) {
      72 | +    result.txout_is_mine.reserve(wtx.GetTx()->vout.size());
      73 | +    result.txout_address.reserve(wtx.GetTx()->vout.size());
      74 | +    result.txout_address_is_mine.reserve(wtx.GetTx()->vout.size());
      75 | +    for (const auto& txout : wtx.GetTx()->vout) {
    


    arejula27 commented at 5:32 PM on July 14, 2026:

    You just rename retriving an atribute to call a function, the cost is not the same. I would suggest using a single const auto& tx = wtx.GetTx(); at the top avoids the repeated lookups.


    achow101 commented at 7:40 PM on July 14, 2026:

    If I need to retouch


    achow101 commented at 6:01 PM on July 16, 2026:

    Done

  49. arejula27 commented at 5:42 PM on July 14, 2026: none

    Concept ACK. Reviewing this one since it's picked for the next Bitcoin Core Spanish review club session.

    GetTx() now returns CTransactionRef by value via m_txs.at(m_canonical_wtxid) (a std::map lookup), where before it was a plain member access. It's called all over the wallet's hot paths (GetHash(), GetConflicts, AddToSpends, etc.), so this adds a map lookup + shared_ptr refcount increase on every call. It's worth returning const CTransactionRef& instead, caching the canonical pointer and refreshing it whenever m_canonical_wtxid changes. This can be implemented for example returning the first element of an ordered vector if it is decided as data structure as I suggested on an inline comment. Another option can be just a new attribute.

    I also pointed out several call sites that repeat GetTx() in a loop, assuming this overhead. Once GetTx() returns a const&, those become purely stylistic cleanups rather than real performance fixes.

  50. in src/wallet/walletdb.cpp:995 in c317c4b51d
    1002 | -                return false;
    1003 | -            }
    1004 | -            value >> wtx;
    1005 | -            if (wtx.GetHash() != hash)
    1006 | -                return false;
    1007 | +        CWalletTx wtx{deserialize, value};
    


    furszy commented at 3:21 PM on July 16, 2026:

    We should probably try-catch this, so we can return a proper error message when unserialization fails (corrupted record), instead of throwing here and catching it at the top-level try-catch.

    Remember we have the err arg to return a better message.


    achow101 commented at 6:01 PM on July 16, 2026:

    Done

  51. in src/wallet/walletdb.cpp:1005 in c317c4b51d
    1019 | -        };
    1020 | -        if (!pwallet->LoadToWallet(hash, fill_wtx)) {
    1021 | -            // Use std::max as fill_wtx may have already set result to CORRUPT
    1022 | -            result = std::max(result, DBErrors::NEED_RESCAN);
    1023 | +        if (!pwallet->LoadToWallet(std::move(wtx))) {
    1024 | +            result = std::max(result, DBErrors::CORRUPT);
    


    furszy commented at 3:23 PM on July 16, 2026:

    The only way LoadToWallet returns false is when the tx is duplicated, which shouldn't happen. It wouldn't hurt to add a descriptive error message here. err = "Error: Duplicated transaction found. This can be fixed by removing transactions from wallet and rescanning."


    achow101 commented at 6:01 PM on July 16, 2026:

    Done

  52. furszy commented at 3:30 PM on July 16, 2026: member

    Code review ACK 37efd9dcad3eb817adb408f9df4add390b24703d

  53. DrahtBot requested review from arejula27 on Jul 16, 2026
  54. DrahtBot requested review from polespinasa on Jul 16, 2026
  55. DrahtBot requested review from rkrux on Jul 16, 2026
  56. 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.
    19af439bdf
  57. wallet: Remove unused CWalletTx CopyFrom and copy constructor 72ebdd6364
  58. 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.
    798ba6d04f
  59. 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.
    56cf27db4d
  60. 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.
    0b1af01bd4
  61. wallet: Show alternate wtxids in gettransaction
    If a wallet tranasction has alterate witness versions, list those wtxids
    in gettransaction's output.
    2d55c7a74d
  62. test: Test for wallet txs with alternate wtxids ef2afc6a0a
  63. test: compat, ensure downgrade preserves tx witness variants 99bdcb064c
  64. doc: release note for alternate_wtxids in gettransaction 6c9d76d589
  65. achow101 force-pushed on Jul 16, 2026
  66. furszy commented at 6:08 PM on July 16, 2026: member

    utACK 6c9d76d589427d8a575c94891fbcc7a19c91cc8d

  67. uint256: Workaround GCC-14 stringop-overread bug in Compare fa5cbb8909
  68. achow101 commented at 2:11 AM on July 18, 2026: member

    Pushed a commit for the ci issue hopefully.

  69. DrahtBot removed the label CI failed on Jul 18, 2026
  70. furszy commented at 3:26 AM on July 18, 2026: member

    ACK fa5cbb8909713cf27b1000c43a1871b4d4b0ab9c

  71. in src/wallet/walletdb.cpp:1042 in fa5cbb8909
    1046 | -                result = DBErrors::CORRUPT;
    1047 | -                return false;
    1048 | +        try {
    1049 | +            CWalletTx wtx{deserialize, value, ReadWtxVariants(batch, hash)};
    1050 | +            if (wtx.GetHash() != hash) {
    1051 | +                result = std::max(result, DBErrors::NEED_RESCAN);
    


    arejula27 commented at 3:15 PM on July 18, 2026:

    Shouldn't we return result here instead of falling through to LoadToWallet? I think on master, a hash mismatch itavoids calling LoadToWallet regardless, so a mismatched tx now gets fully integrated into the wallet despite being marked NEED_RESCAN. Is that a deliberate behavior change?


    achow101 commented at 9:49 PM on July 20, 2026:

    This was intentional, and the previous behavior is actually more incorrect.

    NEED_RESCAN does not prevent the wallet from being used, so the previous behavior is that we would load the transaction into mapWallet, but not update anything else - mapTxSpends was not updated, conflicts were not marked, birth time wasn't updated, and txos aren't cached. I think this is more incorrect because the wallet will be usable as normal even in this failure case. This PR makes it so that we still get the same error code and therefore the same warnings are presented to the user, but all of the relevant transaction things are actually being done so that the loaded tx is useful.

    But arguably this can only occur if the wallet is corrupted, so this probably should just cause loading failure. I think that can be done in a separate PR.

    Edit: Done in #35760


    arejula27 commented at 10:45 PM on July 20, 2026:

    Agreed, that's the better behaviour, and thanks for already opening #35760 for the load-failure case.

    One last thing, if you don't mind: could you mention the behaviour change in 19af439bd 's commit message? I think that's where it actually changes, and right now it reads as a simple refactor, so it's not obvious that corrupt-record handling changes too. Thanks!


    achow101 commented at 4:38 PM on July 22, 2026:

    could you mention the behaviour change in https://github.com/bitcoin/bitcoin/commit/19af439bdf3630bace7b8ff0a3b8e533d62b4035 's commit message? I think that's where it actually changes, and right now it reads as a simple refactor, so it's not obvious that corrupt-record handling changes too. Thanks!

    If I need to retouch

  72. arejula27 commented at 3:33 PM on July 18, 2026: none

    ,I found a possible behavioural change in the hash-mismatch path in LoadTxRecords. It's not a blocker, so we could just discuss it in a follow-up PR.

  73. DrahtBot requested review from arejula27 on Jul 18, 2026
  74. in src/wallet/feebumper.cpp:214 in 798ba6d04f
     210 | @@ -210,9 +211,9 @@ Result CreateRateBumpTransaction(CWallet& wallet, const Txid& txid, const CCoinC
     211 |  
     212 |      // Figure out if we need to compute the input weight, and do so if necessary
     213 |      PrecomputedTransactionData txdata;
     214 | -    txdata.Init(*wtx.tx, std::move(spent_outputs), /* force=*/ true);
     215 | -    for (unsigned int i = 0; i < wtx.tx->vin.size(); ++i) {
     216 | -        const CTxIn& txin = wtx.tx->vin.at(i);
     217 | +    txdata.Init(*tx, std::move(spent_outputs), /* force=*/ true);
    


    polespinasa commented at 3:39 PM on July 22, 2026:

    in 798ba6d04faad848376eb6ca758ee2c2c5e5751f wallet: Make CWalletTx::tx private and use CWalletTx::GetTx to access

    nit: if retouching maybe you can fixe the extra space in /* force=*/

  75. in src/wallet/walletdb.cpp:103 in 56cf27db4d
      97 | @@ -97,12 +98,24 @@ bool WalletBatch::ErasePurpose(const std::string& strAddress)
      98 |  
      99 |  bool WalletBatch::WriteTx(const CWalletTx& wtx)
     100 |  {
     101 | -    return WriteIC(std::make_pair(DBKeys::TX, wtx.GetHash()), wtx);
     102 | +    const Txid txid = wtx.GetHash();
     103 | +    // Persist all witness variants. Including the canonical one
     104 | +    for (const auto& [wtxid, tx] : wtx.GetTxs()) {
    


    polespinasa commented at 3:55 PM on July 22, 2026:

    In 56cf27db4dc005fa49cf960999096a13a4ddd862 wallet: Store all witness variants of a transaction

    Here we are writing all variants including the canonical one and then in the return line we are storing the canonical one again under the TX record.

    Can't we skip the canonical in the loop?


    arejula27 commented at 5:07 PM on July 22, 2026:

    I thought the same at first, but ended up dismissing it: the saving is small (a ~75 byte key plus the canonical tx bytes) and it adds a special case to the write path. Today WriteTx just writes all variants + the TX record, the same way every time. Skipping the canonical means we must handle updates differently, a couple scenarios that come to mind:

    • new canonical never seen before (e.g. a fresh variant): just write the old canonical back as a variant, nothing to erase.
    • new canonical already known (it was stored as a variant because it weighed more, and a new block makes it canonical): now its variant record is redundant and has to be erased, and the old canonical written back as a variant.

    I'd rather keep it simple to avoid possible bugs


    achow101 commented at 5:26 PM on July 22, 2026:

    We can, but I wanted to write all variants separately for consistency. This unfortunately will have the side effect of approximately doubling the size of wallets with many transactions.

    Ideally, the tx record would have just stored the wtxid of the canonical, rather than the entire thing. But it has to have the full tx there for backwards compatibility.


    arejula27 commented at 6:13 PM on July 22, 2026:

    If doubling the size is a concern, a middle ground could be deduplicating only for confirmed txs: once a tx is confirmed the canonical is pinned, so it can't change anymore, and dropping its redundant WTX_VARIANT record avoids all the write-path branching we'd otherwise hit when the canonical moves around. All variants are still kept, we'd only remove the copy that's already in the TX record.

    This could run automatically on confirmation, or be offered as a manual wallet call (something like clearduplicatevariants) that only touches confirmed txs. What do u think?


    achow101 commented at 6:18 PM on July 22, 2026:

    a middle ground could be deduplicating only for confirmed txs

    No, that's complicated and does not survive reorgs.

  76. in src/wallet/walletdb.cpp:113 in 56cf27db4d
     110 |  bool WalletBatch::EraseTx(Txid hash)
     111 |  {
     112 | -    return EraseIC(std::make_pair(DBKeys::TX, hash.ToUint256()));
     113 | +    if (!EraseIC(std::make_pair(DBKeys::TX, hash.ToUint256()))) return false;
     114 | +    // Drop all witness variant records too, so none are left dangling
     115 | +    return m_batch->ErasePrefix(DataStream() << DBKeys::WTX_VARIANT << hash);
    


    polespinasa commented at 3:59 PM on July 22, 2026:

    in 56cf27db4dc005fa49cf960999096a13a4ddd862 wallet: Store all witness variants of a transaction

    Idk if it has a solution, but maybe worth adding a comment in the WriteTx function. After this commit EraseTx removes the variants too, but if a wallet adds variants to the DB and the wallet is then loaded into an older software and the tx is removed with EraseTx the variants will remain orphaned. Even if the wallet is then loaded again into a software version with this commit. Maybe worth to add a clean-up step when loading the wallet?


    achow101 commented at 4:47 PM on July 22, 2026:

    I don't think that's necessary. Having dangling records doesn't materially affect the wallet, and they aren't actually loaded if the tx record is missing. EraseTx removes them for completeness, but it's not strictly necessary.

  77. in src/wallet/walletdb.cpp:1018 in 56cf27db4d
    1013 | +            throw std::runtime_error(strprintf("Error reading '%s' record", DBKeys::WTX_VARIANT));
    1014 | +        }
    1015 | +        CTransactionRef tx;
    1016 | +        value >> TX_WITH_WITNESS(tx);
    1017 | +        if (tx->GetHash() != txid) {
    1018 | +            throw std::runtime_error(strprintf("Corrupted witness variant, tx hash differs"));
    


    polespinasa commented at 4:03 PM on July 22, 2026:

    in 56cf27d wallet: Store all witness variants of a transaction

    nit: maybe worth to add the has in the error message?


    arejula27 commented at 4:46 PM on July 22, 2026:

    I think this would be better discussed in #35760

  78. in src/wallet/walletdb.h:10 in 56cf27db4d
       6 | @@ -7,6 +7,7 @@
       7 |  #define BITCOIN_WALLET_WALLETDB_H
       8 |  
       9 |  #include <key.h>
      10 | +#include <primitives/transaction.h>
    


    polespinasa commented at 4:15 PM on July 22, 2026:

    in 56cf27d wallet: Store all witness variants of a transaction

    nit: I think #include <primitives/transaction_identifier.h> can be removed because of this include.


    achow101 commented at 4:45 PM on July 22, 2026:

    No, we don't drop includes that we use explicitly. Even if something else includes the same header, we should always include what we use.

  79. in doc/release-notes-35501.md:4 in 6c9d76d589
       0 | @@ -0,0 +1,4 @@
       1 | +RPC
       2 | +---
       3 | +
       4 | +- `gettransaction`, `listtransactions`, and `listsinceblock` now have an `alternate_wtxids` field which lists the wtxids of all transactions that have the same txid.
    


    polespinasa commented at 4:34 PM on July 22, 2026:

    in release note

    worth to mention that the field exists even if there is no variants?


    achow101 commented at 4:45 PM on July 22, 2026:

    I think that is already implied. All variants includes the case where there is a single variant.


    polespinasa commented at 4:48 PM on July 22, 2026:

    I meant mentioning that the field is an empty array similar to walletconflicts or mempoolconflicts if there is only a single variant.

  80. polespinasa commented at 4:36 PM on July 22, 2026: member

    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.

    Why the least weight and not the highest feerate one?

    2d55c7a74ddef1d20e65564de9019744f5a466ab wallet: Show alternate wtxids in gettransaction -> has a typo in the commit message tranasction

  81. DrahtBot requested review from polespinasa on Jul 22, 2026
  82. achow101 commented at 4:41 PM on July 22, 2026: member

    Why the least weight and not the highest feerate one?

    They are equivalent here. For 2 txs to have the same txids, they must spend the same inputs and create the same outputs, so the fee cannot be different. All that can change is the weight because of witness data. We say and use weight because we can always calculate weight; calculating fee would require us to know the inputs, which we may not have.

    https://github.com/bitcoin/bitcoin/commit/2d55c7a74ddef1d20e65564de9019744f5a466ab wallet: Show alternate wtxids in gettransaction -> has a typo in the commit message tranasction

    If I need to retouch.


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-27 14:51 UTC

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