wallet: don't include unconfirmed v3 txs with children in available coins
What changed, and why it matters
This Bitcoin Core wallet patch prevents the wallet from trying to spend coins from an unconfirmed v3 (TRUC) transaction that already has a child transaction in the mempool. Because v3 transactions are only allowed a strict one-parent-one-child chain in the mempool, attempting to create a second child would be rejected by network policy. Without this fix, the wallet could waste time and fees building transactions that the network will never accept, and in some cases might temporarily lock up funds or produce confusing behavior. It is a correctness and usability fix rather than a theft or remote-code-execution vulnerability.
Treat as a routine but important wallet correctness fix. Users running versions with v3/TRUC support should upgrade or apply the patch to avoid constructing invalid chained v3 transactions. No emergency response is indicated for theft or consensus safety.
Security signals we found
Denial-of-service-like usability issue: wallet could construct transactions doomed to mempool rejection
Policy-rule enforcement gap between wallet and mempool
Potential fee waste or transaction broadcast failure
No direct funds theft or remote code execution signal in diff
Evidence from the diff
The commit adds tracking of an in-mempool child for unconfirmed v3 (TRUC) wallet transactions. A new optional truc_child_in_mempool field is added to CWalletTx. When a v3 transaction enters the mempool, the wallet records its txid on any unconfirmed parent wallet transaction. When the child leaves the mempool, the field is cleared. AvailableCoins then skips outputs from unconfirmed v3 parents that already have a child, and asserts that non-v3 coin selection never sees such a field. This enforces the v3 topology rule (1-parent-1-child for unconfirmed v3 txs) inside coin selection.
Changed components
src/wallet/spend.cppsrc/wallet/transaction.hsrc/wallet/wallet.cppBitcoin Core wallet coin selectionv3/TRUC transaction handlingInspect captured patch +40 / −0
diff --git a/src/wallet/spend.cpp b/src/wallet/spend.cpp
index 8e24bb95..a44cc409 100644
--- a/src/wallet/spend.cpp
+++ b/src/wallet/spend.cpp
@@ -390,8 +390,11 @@ CoinsResult AvailableCoins(const CWallet& wallet,
if (nDepth == 0 && params.check_version_trucness) {
if (coinControl->m_version == TRUC_VERSION) {
if (wtx.tx->version != TRUC_VERSION) continue;
+ // this unconfirmed v3 transaction already has a child
+ if (wtx.truc_child_in_mempool.has_value()) continue;
} else {
if (wtx.tx->version == TRUC_VERSION) continue;
+ Assume(!wtx.truc_child_in_mempool.has_value());
}
}
diff --git a/src/wallet/transaction.h b/src/wallet/transaction.h
index b44f0c7d..83c3c660 100644
--- a/src/wallet/transaction.h
+++ b/src/wallet/transaction.h
@@ -258,6 +258,10 @@ public:
// BlockConflicted.
std::set<Txid> mempool_conflicts;
+ // Track v3 mempool tx that spends from this tx
+ // so that we don't try to create another unconfirmed child
+ std::optional<Txid> truc_child_in_mempool;
+
template<typename Stream>
void Serialize(Stream& s) const
{
diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp
index 0b265e41..0814d672 100644
--- a/src/wallet/wallet.cpp
+++ b/src/wallet/wallet.cpp
@@ -30,6 +30,7 @@
#include <node/types.h>
#include <outputtype.h>
#include <policy/feerate.h>
+#include <policy/truc_policy.h>
#include <primitives/block.h>
#include <primitives/transaction.h>
#include <psbt.h>
@@ -1388,6 +1389,22 @@ void CWallet::transactionAddedToMempool(const CTransactionRef& tx) {
return wtx.mempool_conflicts.insert(txid).second ? TxUpdate::CHANGED : TxUpdate::UNCHANGED;
});
}
+
+ }
+
+ if (tx->version == TRUC_VERSION) {
+ // Unconfirmed TRUC transactions are only allowed a 1-parent-1-child topology.
+ // For any unconfirmed v3 parents (there should be a maximum of 1 except in reorgs),
+ // record this child so the wallet doesn't try to spend any other outputs
+ for (const CTxIn& tx_in : tx->vin) {
+ auto parent_it = mapWallet.find(tx_in.prevout.hash);
+ if (parent_it != mapWallet.end()) {
+ CWalletTx& parent_wtx = parent_it->second;
+ if (parent_wtx.isUnconfirmed()) {
+ parent_wtx.truc_child_in_mempool = tx->GetHash();
+ }
+ }
+ }
}
}
@@ -1441,6 +1458,22 @@ void CWallet::transactionRemovedFromMempool(const CTransactionRef& tx, MemPoolRe
});
}
}
+
+ if (tx->version == TRUC_VERSION) {
+ // If this tx has a parent, unset its truc_child_in_mempool to make it possible
+ // to spend from the parent again. If this tx was replaced by another
+ // child of the same parent, transactionAddedToMempool
+ // will update truc_child_in_mempool
+ for (const CTxIn& tx_in : tx->vin) {
+ auto parent_it = mapWallet.find(tx_in.prevout.hash);
+ if (parent_it != mapWallet.end()) {
+ CWalletTx& parent_wtx = parent_it->second;
+ if (parent_wtx.truc_child_in_mempool == tx->GetHash()) {
+ parent_wtx.truc_child_in_mempool = std::nullopt;
+ }
+ }
+ }
+ }
}
void CWallet::blockConnected(ChainstateRole role, const interfaces::BlockInfo& block)
Why this scored 44/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.