wallet: don't return utxos from multiple truc txs in AvailableCoins
What changed, and why it matters
This change adjusts how Bitcoin Core's wallet picks which unconfirmed coins it can spend. Specifically, when a new type of transaction called 'TRUC' (a v3 transaction) is involved, the wallet now only returns coins from one TRUC transaction at a time—the one with the highest total value—rather than returning coins from multiple unconfirmed TRUC transactions together. This is a defensive fix to avoid creating new transactions that would violate TRUC rules, which could otherwise cause the wallet's own follow-up transactions to be rejected by the network. It is more of a correctness/safety improvement than a direct theft or remote-exploitation bug.
Treat as a wallet correctness/safety hardening patch. Users relying on TRUC/v3 transactions should upgrade, but no emergency action is indicated. Reviewers should verify that the highest-value-tx heuristic does not introduce unintended fee, privacy, or usability issues, and should plan the follow-up improvement mentioned in the comment.
Security signals we found
Defensive correctness fix for TRUC/v3 transaction handling
Prevents wallet from constructing invalid or network-rejected follow-up transactions
Temporary workaround rather than root-cause redesign of coin selection constraints
No explicit CVE, advisory, or security disclosure language in commit
Evidence from the diff
AvailableCoins() in src/wallet/spend.cpp now segregates unconfirmed TRUC (version 3) outputs when params.check_version_trucness is enabled. It accumulates them in unconfirmed_truc_coins and sums per-tx value in truc_txid_by_value, then only adds outputs from the single highest-value TRUC transaction to the final CoinsResult. The commit message says this prevents returning UTXOs from multiple TRUC transactions. The inline comment notes this is a temporary workaround and that a better long-term fix would encode the restriction in coin selection itself. This is a partial, localized guard rather than a full redesign.
Changed components
src/wallet/spend.cppCWallet::AvailableCoins()TRUC/v3 transaction coin selectionInspect captured patch +29 / −2
diff --git a/src/wallet/spend.cpp b/src/wallet/spend.cpp
index d76e6118..e29ca045 100644
--- a/src/wallet/spend.cpp
+++ b/src/wallet/spend.cpp
@@ -325,6 +325,9 @@ CoinsResult AvailableCoins(const CWallet& wallet,
AssertLockHeld(wallet.cs_wallet);
CoinsResult result;
+ // track unconfirmed truc outputs separately if we are tracking trucness
+ std::vector<std::pair<OutputType, COutput>> unconfirmed_truc_coins;
+ std::unordered_map<Txid, CAmount, SaltedTxidHasher> truc_txid_by_value;
// Either the WALLET_FLAG_AVOID_REUSE flag is not set (in which case we always allow), or we default to avoiding, and only in the case where
// a coin control object is provided, and has the avoid address reuse flag set to false, do we allow already used addresses
bool allow_used_addresses = !wallet.IsWalletFlagSet(WALLET_FLAG_AVOID_REUSE) || (coinControl && !coinControl->m_avoid_address_reuse);
@@ -470,8 +473,15 @@ CoinsResult AvailableCoins(const CWallet& wallet,
is_from_p2sh = true;
}
- result.Add(GetOutputType(type, is_from_p2sh),
- COutput(outpoint, output, nDepth, input_bytes, spendable, solvable, tx_safe, wtx.GetTxTime(), tx_from_me, feerate));
+ auto available_output_type = GetOutputType(type, is_from_p2sh);
+ auto available_output = COutput(outpoint, output, nDepth, input_bytes, spendable, solvable, tx_safe, wtx.GetTxTime(), tx_from_me, feerate);
+ if (wtx.tx->version == TRUC_VERSION && nDepth == 0 && params.check_version_trucness) {
+ unconfirmed_truc_coins.emplace_back(available_output_type, available_output);
+ auto [it, _] = truc_txid_by_value.try_emplace(wtx.tx->GetHash(), 0);
+ it->second += output.nValue;
+ } else {
+ result.Add(available_output_type, available_output);
+ }
outpoints.push_back(outpoint);
@@ -488,6 +498,23 @@ CoinsResult AvailableCoins(const CWallet& wallet,
}
}
+ // Return all the coins from one TRUC transaction, that have the highest value.
+ // This could be improved in the future by encoding these restrictions in
+ // the coin selection itself so that we don't have to filter out
+ // other unconfirmed TRUC coins beforehand.
+ if (params.check_version_trucness && unconfirmed_truc_coins.size() > 0) {
+ auto highest_value_truc_tx = std::max_element(truc_txid_by_value.begin(), truc_txid_by_value.end(), [](const auto& tx1, const auto& tx2){
+ return tx1.second < tx2.second;
+ });
+
+ const Txid& truc_txid = highest_value_truc_tx->first;
+ for (const auto& [type, output] : unconfirmed_truc_coins) {
+ if (output.outpoint.hash == truc_txid) {
+ result.Add(type, output);
+ }
+ }
+ }
+
if (feerate.has_value()) {
std::map<COutPoint, CAmount> map_of_bump_fees = wallet.chain().calculateIndividualBumpFees(outpoints, feerate.value());
Why this scored 46/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.