wallet: skip APS when no partial spend exists #34405

pull 8144225309 wants to merge 2 commits into bitcoin:master from 8144225309:aps-skip-no-partial-spend changing 4 files +108 −15
  1. 8144225309 commented at 4:36 AM on January 26, 2026: contributor

    Fixes #25150

    APS (Avoid Partial Spends) runs a second coin selection pass that spends many (possibly all) or none of the UTXOs sharing a scriptPubKey. The second pass runs even when the first selection left no partial spend for it to avoid, and aps_create_tx_internal then reports APS as used whenever the grouped result is taken, overreporting how often a partial spend was avoided.

    Detect partial spends by comparing selected vs available UTXO counts per scriptPubKey, reusing the coins already fetched for selection and counting manually selected inputs towards their scriptPubKey. Skip APS if none found.

    Functional tests cover APS being skipped and a manually selected input making the spend partial.


    Supersedes #34362.

  2. DrahtBot added the label Wallet on Jan 26, 2026
  3. DrahtBot commented at 4:37 AM on January 26, 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/34405.

    <!--021abf342d371248e50ceaed478a90ca-->

    Reviews

    See the guideline and AI policy for information on the review process.

    Type Reviewers
    Concept ACK polespinasa

    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

    No conflicts as of last run.

    <!--5faf32d7da4f0f540f40219e4f7537a3-->

  4. 8144225309 force-pushed on Jan 26, 2026
  5. 8144225309 force-pushed on Jan 26, 2026
  6. 8144225309 force-pushed on Jan 26, 2026
  7. 8144225309 force-pushed on Jan 26, 2026
  8. in src/wallet/spend.cpp:1459 in 53de94fb82
    1453 | @@ -1432,8 +1454,11 @@ util::Result<CreatedTransactionResult> CreateTransaction(
    1454 |             res && res->change_pos.has_value() ? int32_t(*res->change_pos) : -1);
    1455 |      if (!res) return res;
    1456 |      const auto& txr_ungrouped = *res;
    1457 | -    // try with avoidpartialspends unless it's enabled already
    1458 | +    // try with avoidpartialspends unless it's enabled already, or if there's no partial spend to avoid
    1459 |      if (txr_ungrouped.fee > 0 /* 0 means non-functional fee rate estimation */ && wallet.m_max_aps_fee > -1 && !coin_control.m_avoid_partial_spends) {
    1460 | +        if (!txr_ungrouped.has_partial_spend) {
    


    achow101 commented at 9:25 PM on February 2, 2026:

    This condition can be grouped with the others in the line above, there's no need to separate it with it's own return statement.


    8144225309 commented at 2:54 AM on February 3, 2026:

    Done, combined.

  9. in test/functional/interface_usdt_coinselection.py:122 in 53de94fb82 outdated
     118 | @@ -119,12 +119,14 @@ def skip_test_if_missing_module(self):
     119 |          self.skip_if_no_wallet()
     120 |          self.skip_if_running_under_valgrind()
     121 |  
     122 | -    def get_tracepoints(self, expected_types):
     123 | +    def get_tracepoints(self, expected_types, wallet_name=None):
    


    achow101 commented at 9:36 PM on February 2, 2026:

    The default for wallet_name is essentially self.default_wallet_name, so set it to that instead of using None.


    8144225309 commented at 2:55 AM on February 3, 2026:

    self isn't available at function definition time in Python. The =None with internal check matches the pattern used elsewhere in the test framework (e.g., extra_args in add_nodes()).

  10. 8144225309 force-pushed on Feb 3, 2026
  11. DrahtBot added the label Needs rebase on Feb 10, 2026
  12. 8144225309 force-pushed on Feb 11, 2026
  13. DrahtBot removed the label Needs rebase on Feb 11, 2026
  14. DrahtBot added the label CI failed on Feb 14, 2026
  15. 8144225309 force-pushed on Feb 15, 2026
  16. DrahtBot removed the label CI failed on Feb 22, 2026
  17. sedited requested review from polespinasa on Jun 7, 2026
  18. sedited requested review from achow101 on Aug 10, 2026
  19. DrahtBot added the label Needs rebase on Aug 21, 2026
  20. sedited commented at 7:55 AM on September 22, 2026: contributor

    @8144225309 this has been in need of a rebase for a while. Are you still interested in working on this change?

  21. 8144225309 commented at 7:07 PM on September 22, 2026: contributor

    Thank you for pinging me, resolving conflicts now.

  22. 8144225309 force-pushed on Sep 27, 2026
  23. wallet: skip APS when no partial spend exists
    APS runs a second coin selection that spends many (possibly all) or none
    of the UTXOs sharing a scriptPubKey. It runs even when the first
    selection left no partial spend for it to avoid.
    
    Detect partial spends by comparing selected vs available UTXO counts
    per scriptPubKey inside CreateTransactionInternal. The result is
    returned in CreatedTransactionResult as has_partial_spend, and APS
    now runs only when it is set.
    
    Fixes #25150
    e382330b92
  24. test: add coverage for partial spend detection
    APS now runs only when the first selection leaves a scriptPubKey
    partially spent.
    
    Cover the two cases that gate creates in wallet_groups.py: a wallet
    whose scriptPubKeys hold one UTXO each never runs the second
    selection, and a manually preselected input counts towards its
    scriptPubKey, so the grouped selection still runs.
    8a30292d59
  25. 8144225309 force-pushed on Sep 27, 2026
  26. DrahtBot added the label CI failed on Sep 27, 2026
  27. 8144225309 commented at 7:07 AM on September 27, 2026: contributor

    Rebased onto master. The only conflict was CreatedTransactionResult moving from FeeCalculation to FeeReason; no other code changed, and the rest of the src/ diff is comments.

    The new wallet_groups.py coverage is a separate commit; the tracepoint test changes stay with the code commit. That test needs bcc and does not currently run in CI, so both new scenarios assert on the debug log instead: APS skipped when nothing is partially spent, and a manually selected input counted towards its scriptPubKey. The tracepoint test also gains a case asserting use_aps with no change output.

    <details> <summary>range-diff</summary>

    1:  b6319c62db ! 1:  e382330b92 wallet: skip APS when no partial spend exists
        @@
          ## Metadata ##
        -Author: 8144225309 <248271067+8144225309@users.noreply.github.com>
        +Author: 8144225309 <8144225309@users.noreply.github.com>
         
          ## Commit message ##
             wallet: skip APS when no partial spend exists
         
        -    APS runs a second coin selection to fully spend UTXOs sharing a
        -    scriptPubKey. Currently runs unconditionally, even when the first
        -    selection has no partial spend and APS cannot help.
        +    APS runs a second coin selection that spends many (possibly all) or none
        +    of the UTXOs sharing a scriptPubKey. It runs even when the first
        +    selection left no partial spend for it to avoid.
         
             Detect partial spends by comparing selected vs available UTXO counts
        -    per scriptPubKey inside CreateTransactionInternal, reusing available_coins.
        -    Add has_partial_spend to CreatedTransactionResult. Skip APS if false.
        -
        -    Update interface_usdt_coinselection test to create partial spend scenarios
        -    so tracepoints fire as expected.
        +    per scriptPubKey inside CreateTransactionInternal. The result is
        +    returned in CreatedTransactionResult as has_partial_spend, and APS
        +    now runs only when it is set.
         
             Fixes [#25150](/bitcoin-bitcoin/25150/)
         
        @@ src/wallet/spend.cpp: static util::Result<CreatedTransactionResult> CreateTransa
                     result.GetWaste(),
                     result.GetSelectedValue());
          
        -+    // Check for partial spend: spending some but not all UTXOs from any scriptPubKey
        ++    // Check for partial spend: spending some but not all available UTXOs from any scriptPubKey
         +    bool has_partial_spend{false};
         +    if (coin_control.m_allow_other_inputs) {
         +        std::map<CScript, std::pair<size_t, size_t>> spk_counts; // {available, selected}
         +        for (const auto& coin : available_coins.All()) {
         +            spk_counts[coin.txout.scriptPubKey].first++;
         +        }
        -+        // Also count preset inputs as available (they're wallet UTXOs too)
        ++        // AvailableCoins skips manually selected coins, so count them here too.
         +        for (const auto& coin : preset_inputs.All()) {
         +            spk_counts[coin.txout.scriptPubKey].first++;
         +        }
        @@ src/wallet/spend.cpp: static util::Result<CreatedTransactionResult> CreateTransa
              txNew.vout.reserve(vecSend.size() + 1); // + 1 because of possible later insert
              for (const auto& recipient : vecSend)
         @@ src/wallet/spend.cpp: static util::Result<CreatedTransactionResult> CreateTransactionInternal(
        -               feeCalc.est.fail.start, feeCalc.est.fail.end,
        -               (feeCalc.est.fail.totalConfirmed + feeCalc.est.fail.inMempool + feeCalc.est.fail.leftMempool) > 0.0 ? 100 * feeCalc.est.fail.withinTarget / (feeCalc.est.fail.totalConfirmed + feeCalc.est.fail.inMempool + feeCalc.est.fail.leftMempool) : 0.0,
        -               feeCalc.est.fail.withinTarget, feeCalc.est.fail.totalConfirmed, feeCalc.est.fail.inMempool, feeCalc.est.fail.leftMempool);
        --    return CreatedTransactionResult(tx, current_fee, change_pos, feeCalc);
        -+    return CreatedTransactionResult(tx, current_fee, change_pos, feeCalc, has_partial_spend);
        +     wallet.WalletLogPrintf("Coin Selection: Algorithm:%s, Waste Metric Score:%d\n", GetAlgorithmName(result.GetAlgo()), result.GetWaste());
        +     wallet.WalletLogPrintf("Fee Calculation: Fee:%d Bytes:%u, Source: %s\n",
        +                            current_fee, nBytes, StringForFeeReason(min_fee_rate.fee_reason));
        +-    return CreatedTransactionResult(tx, current_fee, change_pos, min_fee_rate.fee_reason);
        ++    return CreatedTransactionResult(tx, current_fee, change_pos, min_fee_rate.fee_reason, has_partial_spend);
          }
          
          util::Result<CreatedTransactionResult> CreateTransaction(
        @@ src/wallet/spend.cpp: util::Result<CreatedTransactionResult> CreateTransaction(
          ## src/wallet/types.h ##
         @@ src/wallet/types.h: struct CreatedTransactionResult
              CAmount fee;
        -     FeeCalculation fee_calc;
        +     FeeReason fee_reason;
              std::optional<unsigned int> change_pos;
        -+    /** Whether the selection spends only some UTXOs from any scriptPubKey (relevant for APS) */
        ++    //! Whether the selection spends only some of the available UTXOs at any scriptPubKey (only populated if the wallet may add inputs)
         +    bool has_partial_spend{false};
          
        --    CreatedTransactionResult(CTransactionRef _tx, CAmount _fee, std::optional<unsigned int> _change_pos, const FeeCalculation& _fee_calc)
        --            : tx(_tx), fee(_fee), fee_calc(_fee_calc), change_pos(_change_pos) {}
        -+    CreatedTransactionResult(CTransactionRef _tx, CAmount _fee, std::optional<unsigned int> _change_pos, const FeeCalculation& _fee_calc, bool _has_partial_spend = false)
        -+            : tx(_tx), fee(_fee), fee_calc(_fee_calc), change_pos(_change_pos), has_partial_spend(_has_partial_spend) {}
        +-    CreatedTransactionResult(CTransactionRef _tx, CAmount _fee, std::optional<unsigned int> _change_pos, FeeReason _fee_reason)
        +-        : tx(_tx), fee(_fee), fee_reason(_fee_reason), change_pos(_change_pos) {}
        ++    CreatedTransactionResult(CTransactionRef _tx, CAmount _fee, std::optional<unsigned int> _change_pos, FeeReason _fee_reason, bool _has_partial_spend = false)
        ++        : tx(_tx), fee(_fee), fee_reason(_fee_reason), change_pos(_change_pos), has_partial_spend(_has_partial_spend) {}
          };
          
        - } // namespace wallet
        + //! Machine-readable wallet error codes.
         
          ## test/functional/interface_usdt_coinselection.py ##
         @@ test/functional/interface_usdt_coinselection.py: class CoinSelectionTracepointTest(BitcoinTestFramework):
        @@ test/functional/interface_usdt_coinselection.py: class CoinSelectionTracepointTe
          
         -        self.log.info("Sending a transaction should result in all tracepoints")
         +        self.log.info("Sending a transaction skips APS when no partial spend exists")
        -+        # Default wallet has UTXOs at different addresses (from mining), so no
        -+        # partial spend exists. APS is skipped. We should have 2 tracepoints:
        ++        # Only one coinbase is mature, so the wallet has a single available UTXO.
        ++        # We should have 2 tracepoints in the order:
         +        # 1. selected_coins (type 1)
         +        # 2. normal_create_tx_internal (type 2)
         +        wallet.sendtoaddress(wallet.getnewaddress(), 10)
        @@ test/functional/interface_usdt_coinselection.py: class CoinSelectionTracepointTe
         +        self.nodes[0].createwallet("aps_test")
         +        aps_wallet = self.nodes[0].get_wallet_rpc("aps_test")
         +        addr = aps_wallet.getnewaddress()
        -+        wallet.sendtoaddress(addr, 5)
        -+        self.get_tracepoints([1, 2])  # Consume tracepoints from funding tx
        -+        wallet.sendtoaddress(addr, 5)
        -+        self.get_tracepoints([1, 2])  # Consume tracepoints from funding tx
        ++        for _ in range(3):
        ++            wallet.sendtoaddress(addr, 5)
        ++            self.get_tracepoints([1, 2])  # Consume tracepoints from funding tx
         +        self.generate(self.nodes[0], 1)
        -+        # Spending less than the total creates a partial spend (using one of two
        -+        # UTXOs at the same address). APS should be attempted.
        ++        # The payment needs only one of the three UTXOs at addr, leaving a partial spend.
                  # We should have 5 tracepoints in the order:
                  # 1. selected_coins (type 1)
                  # 2. normal_create_tx_internal (type 2)
        @@ test/functional/interface_usdt_coinselection.py: class CoinSelectionTracepointTe
                  success, use_aps, _algo, _waste, change_pos = self.determine_selection_from_usdt(events)
                  assert_equal(success, True)
                  assert_greater_than(change_pos, -1)
        + 
        ++        self.log.info("Change position is -1 if no change is created with APS")
        ++        # The fee comes out of the output, so the preselected UTXO alone covers the
        ++        # target: both passes select just it, pay the same fee and create no change,
        ++        # so the APS result is used. The other UTXO left at addr is the partial spend.
        ++        # We should have 5 tracepoints in the order:
        ++        # 1. selected_coins (type 1)
        ++        # 2. normal_create_tx_internal (type 2)
        ++        # 3. attempting_aps_create_tx (type 3)
        ++        # 4. selected_coins (type 1)
        ++        # 5. aps_create_tx_internal (type 4)
        ++        preset = next(utxo for utxo in aps_wallet.listunspent() if utxo["address"] == addr)
        ++        raw_tx = aps_wallet.createrawtransaction(
        ++            [{"txid": preset["txid"], "vout": preset["vout"]}],
        ++            [{wallet.getnewaddress(): preset["amount"]}])
        ++        aps_wallet.fundrawtransaction(raw_tx, options={"add_inputs": True, "subtractFeeFromOutputs": [0]})
        ++        events = self.get_tracepoints([1, 2, 3, 1, 4], "aps_test")
        ++        success, use_aps, _algo, _waste, change_pos = self.determine_selection_from_usdt(events)
        ++        assert_equal(success, True)
        ++        assert_equal(use_aps, True)
        ++        assert_equal(change_pos, -1)
        ++
        +         self.log.info("Failing to fund results in 1 tracepoint")
        +         # We should have 1 tracepoints in the order
        +         # 1. normal_create_tx_internal (type 2)
         @@ test/functional/interface_usdt_coinselection.py: class CoinSelectionTracepointTest(BitcoinTestFramework):
                  assert_equal(success, True)
                  assert_equal(use_aps, None)
          
         -        self.log.info("Change position is -1 if no change is created with APS when APS was initially not used")
        --        # We should have 2 tracepoints in the order:
         +        self.log.info("Sending entire balance skips APS (no partial spend)")
        -+        # Sending entire balance spends all UTXOs, so no partial spend exists.
        -+        # APS is skipped. We should have 2 tracepoints:
        +         # We should have 2 tracepoints in the order:
                  # 1. selected_coins (type 1)
                  # 2. normal_create_tx_internal (type 2)
         -        # 3. attempting_aps_create_tx (type 3)
    -:  ---------- > 2:  8a30292d59 test: add coverage for partial spend detection
    

    </details>

  28. DrahtBot removed the label Needs rebase on Sep 27, 2026
  29. DrahtBot removed the label CI failed on Sep 27, 2026
  30. in src/wallet/spend.cpp:1263 in e382330b92
    1258 | +            spk_counts[coin.txout.scriptPubKey].first++;
    1259 | +        }
    1260 | +        // AvailableCoins skips manually selected coins, so count them here too.
    1261 | +        for (const auto& coin : preset_inputs.All()) {
    1262 | +            spk_counts[coin.txout.scriptPubKey].first++;
    1263 | +        }
    


    polespinasa commented at 3:14 PM on October 1, 2026:

    in e382330b925c33b29ade9a943aea8db8142b0d12 wallet: skip APS when no partial spend exists

    By calling available_coins.All() and preset_inputs.All() (both are CoinsResult::All()) you are creating two new temporary vectors of COutput.

    You can just iterate the original vectors, with something like:

    $ git diff
    diff --git a/src/wallet/spend.cpp b/src/wallet/spend.cpp
    index 3dd562ba98..e09c741538 100644
    --- a/src/wallet/spend.cpp
    +++ b/src/wallet/spend.cpp
    @@ -1254,13 +1254,16 @@ static util::Result<CreatedTransactionResult> CreateTransactionInternal(
         bool has_partial_spend{false};
         if (coin_control.m_allow_other_inputs) {
             std::map<CScript, std::pair<size_t, size_t>> spk_counts; // {available, selected}
    -        for (const auto& coin : available_coins.All()) {
    -            spk_counts[coin.txout.scriptPubKey].first++;
    -        }
    +        auto count_available_outputs = [&spk_counts](const CoinsResult& coins) {
    +            for (const auto& [_, outputs] : coins.coins) {
    +                for (const auto& coin : outputs) {
    +                    spk_counts[coin.txout.scriptPubKey].first++;
    +                }
    +            }
    +        };
    +        count_available_outputs(available_coins);
             // AvailableCoins skips manually selected coins, so count them here too.
    -        for (const auto& coin : preset_inputs.All()) {
    -            spk_counts[coin.txout.scriptPubKey].first++;
    -        }
    +        count_available_outputs(preset_inputs);
             for (const auto& coin : result.GetInputSet()) {
                 spk_counts[coin->txout.scriptPubKey].second++;
             }
    
    

    You can ignore the lamda, it is just a personal preference, it is fine with me to have two loops :)

  31. in test/functional/wallet_groups.py:186 in 8a30292d59
     181 | +        self.nodes[3].createwallet("no_partial_spend")
     182 | +        no_partial = self.nodes[3].get_wallet_rpc("no_partial_spend")
     183 | +        for _ in range(2):
     184 | +            self.nodes[0].sendtoaddress(no_partial.getnewaddress(), 1.0, fee_rate=self.fee_rate)
     185 | +        self.generate(self.nodes[0], 1)
     186 | +        with self.nodes[3].assert_debug_log(expected_msgs=[], unexpected_msgs=['Fee non-grouped']):
    


    polespinasa commented at 3:18 PM on October 1, 2026:

    in 8a30292d59630653499d8d967508038b4ec2072d test: add coverage for partial spend detection

    I would probably assert also here the len of vin similar to what you did in line 206

  32. in test/functional/wallet_groups.py:187 in 8a30292d59
     182 | +        no_partial = self.nodes[3].get_wallet_rpc("no_partial_spend")
     183 | +        for _ in range(2):
     184 | +            self.nodes[0].sendtoaddress(no_partial.getnewaddress(), 1.0, fee_rate=self.fee_rate)
     185 | +        self.generate(self.nodes[0], 1)
     186 | +        with self.nodes[3].assert_debug_log(expected_msgs=[], unexpected_msgs=['Fee non-grouped']):
     187 | +            no_partial.sendtoaddress(self.nodes[0].getnewaddress(), 0.5, fee_rate=self.fee_rate)
    


    polespinasa commented at 3:21 PM on October 1, 2026:

    in 8a30292 test: add coverage for partial spend detection

    The test has a premise and is that the two UTXOs that the wallet has are owned by different scriptpubkey, maybe asserting it in the code would serve as documentation.

  33. polespinasa commented at 3:23 PM on October 1, 2026: member

    concept ACK 8a30292d59630653499d8d967508038b4ec2072d

    I am not really familiar with this part of the wallet codebase but it looks pretty good to me.

    Left a few suggestions before ACKing it :)


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-10-01 20:51 UTC

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