wallet: simplify and restrict SyncMetaData to malleated txs
What changed, and why it matters
This Bitcoin Core wallet commit refactors how the wallet copies user metadata (labels, comments, etc.) between transactions that are 'malleated variants' of each other—transactions that spend the same inputs but have different signatures/transaction IDs. Previously, metadata could be copied from any transaction sharing an input, even if it wasn't a true malleated variant. The new code restricts copying to genuine malleated variants only. The commit also fixes a benchmark test that was using unrealistic dummy inputs. The change is described as a cleanup and correctness improvement, not a security fix, but it does close a window where wallet metadata could be incorrectly propagated between unrelated transactions.
Review the change as a wallet correctness/hardening improvement. Ensure downstream tests cover metadata propagation for malleated transactions and that no regression exists for transactions sharing inputs without being malleated variants. No emergency action is indicated absent additional vulnerability reports.
Security signals we found
Metadata propagation logic narrowed from 'any transaction sharing an input' to 'malleated variants only'
Old code skipped non-equivalent transactions only after selecting a copy source, potentially copying from a non-malleated conflict
New code explicitly excludes coinbase and witness-only transactions from malleation detection
Function renamed and behavior restricted, indicating prior scope was broader than intended
No explicit security advisory, CVE, or bug bounty attribution in commit message
Evidence from the diff
The commit replaces CWallet::SyncMetaData, which operated on a range of mapTxSpends entries for a single outpoint, with two new methods: GetMalleatedVariants and SyncMalleatedTxMetadata. GetMalleatedVariants finds all wallet transactions that share wtx’s first input and are equivalent to wtx (same outputs and non-witness data, differing only in scriptSig/witness). SyncMalleatedTxMetadata then copies m_from, m_message, m_comment, and other metadata fields from the oldest such variant to the newer ones. The old SyncMetaData was called inside AddToSpends for every input and could copy metadata from any transaction sharing an input, even if it was not a malleation. The new code is only called after AddToWallet/LoadToWallet and only propagates metadata among true malleated variants. The benchmark change makes test transactions use distinct COutPoints instead of empty vin slots, so they no longer all appear to spend the same null outpoint.
Changed components
src/wallet/wallet.cppsrc/wallet/wallet.hsrc/bench/wallet_migration.cppInspect captured patch +46 / −26
### src/bench/wallet_migration.cpp
@@ -14,6 +14,7 @@
#include <sync.h>
#include <test/util/setup_common.h>
#include <tinyformat.h>
+#include <uint256.h>
#include <util/check.h>
#include <util/result.h>
#include <wallet/db.h>
@@ -24,6 +25,7 @@
#include <wallet/walletdb.h>
#include <algorithm>
+#include <cstdint>
#include <cstddef>
#include <memory>
#include <optional>
@@ -88,7 +90,9 @@ static void WalletMigration(benchmark::Bench& bench)
CMutableTransaction mtx;
mtx.vout.emplace_back(COIN, GetScriptForDestination(dest));
mtx.vout.emplace_back(COIN, scripts_watch_only.at(j % NUM_WATCH_ONLY_ADDR).first);
- mtx.vin.resize(2);
+ // Use distinct dummy prevouts so all txs don't appear to spend the same null outpoint
+ mtx.vin.emplace_back(COutPoint(Txid::FromUint256(uint256{uint8_t(j + 1)}), 0));
+ mtx.vin.emplace_back(COutPoint(Txid::FromUint256(uint256{uint8_t(j + 1)}), 1));
wallet->AddToWallet(MakeTransactionRef(mtx), TxStateInactive{}, /*update_wtx=*/nullptr, /*rescanning_old_block=*/true);
batch.WriteKey(pubkey, key.GetPrivKey(), CKeyMetadata());
}
### src/wallet/wallet.cpp
@@ -732,34 +732,48 @@ void CWallet::Close()
GetDatabase().Close();
}
-void CWallet::SyncMetaData(std::pair<TxSpends::iterator, TxSpends::iterator> range)
+std::set<CWalletTx*, WalletTxOrderComparator> CWallet::GetMalleatedVariants(const CWalletTx& wtx)
{
- // We want all the wallet transactions in range to have the same metadata as
- // the oldest (smallest nOrderPos).
- // So: find smallest nOrderPos:
+ AssertLockHeld(cs_wallet);
+ std::set<CWalletTx*, WalletTxOrderComparator> txs;
- int nMinOrderPos = std::numeric_limits<int>::max();
- const CWalletTx* copyFrom = nullptr;
- for (TxSpends::iterator it = range.first; it != range.second; ++it) {
- const CWalletTx* wtx = &mapWallet.at(it->second);
- if (wtx->nOrderPos < nMinOrderPos) {
- nMinOrderPos = wtx->nOrderPos;
- copyFrom = wtx;
- }
+ // Coinbases cannot be malleated
+ if (wtx.IsCoinBase()) return txs;
+
+ // Only transactions that have non-witness inputs can be malleated
+ if (std::ranges::none_of(wtx.GetTx()->vin, [](const CTxIn& in) { return in.scriptWitness.IsNull(); })) {
+ return txs;
}
- if (!copyFrom) {
- return;
+ // All variants spend wtx's first input, so a single lookup finds every candidate
+ bool found_self = false;
+ const auto [begin, end] = mapTxSpends.equal_range(wtx.GetTx()->vin.front().prevout);
+ for (auto it = begin; it != end; ++it) {
+ auto entry = mapWallet.find(it->second);
+ if (!Assume(entry != mapWallet.end())) continue; // sanity-check: mapTxSpends has txs that are in mapWallet
+ const bool is_self = &entry->second == &wtx;
+ found_self |= is_self;
+ if (is_self || wtx.IsEquivalentTo(entry->second)) {
+ Assume(txs.insert(&entry->second).second);
+ }
}
+ // wtx should always be found as this function is always called after AddToSpends
+ Assert(found_self);
+ return txs;
+}
+
+void CWallet::SyncMalleatedTxMetadata(const CWalletTx& wtx)
+{
+ const auto txs = GetMalleatedVariants(wtx);
+ if (txs.size() <= 1) return; // no variants, nothing to do
+
+ // First tx is the oldest one (smallest nOrderPos)
+ const CWalletTx* copyFrom = *txs.begin();
// Now copy data from copyFrom to rest:
- for (TxSpends::iterator it = range.first; it != range.second; ++it)
+ for (auto it = ++txs.begin(); it != txs.end(); ++it)
{
- const Txid& hash = it->second;
- CWalletTx* copyTo = &mapWallet.at(hash);
- if (copyFrom == copyTo) continue;
- assert(copyFrom && "Oldest wallet transaction in range assumed to have been found.");
- if (!copyFrom->IsEquivalentTo(*copyTo)) continue;
+ CWalletTx* copyTo = *it;
copyTo->m_from = copyFrom->m_from;
copyTo->m_message = copyFrom->m_message;
copyTo->m_comment = copyFrom->m_comment;
@@ -824,10 +838,6 @@ void CWallet::AddToSpends(const COutPoint& outpoint, const Txid& txid)
mapTxSpends.insert(std::make_pair(outpoint, txid));
UnlockCoin(outpoint);
-
- std::pair<TxSpends::iterator, TxSpends::iterator> range;
- range = mapTxSpends.equal_range(outpoint);
- SyncMetaData(range);
}
@@ -1088,6 +1098,7 @@ CWalletTx* CWallet::AddToWallet(CTransactionRef tx, const TxState& state, const
wtx.m_it_wtxOrdered = wtxOrdered.insert(std::make_pair(wtx.nOrderPos, &wtx));
wtx.nTimeSmart = ComputeTimeSmart(wtx, rescanning_old_block);
AddToSpends(wtx);
+ SyncMalleatedTxMetadata(wtx);
// Update birth time when tx time is older than it.
MaybeUpdateBirthTime(wtx.GetTxTime());
@@ -1195,6 +1206,7 @@ bool CWallet::LoadToWallet(CWalletTx&& wtx_in)
}
wtx.m_it_wtxOrdered = wtxOrdered.insert(std::make_pair(wtx.nOrderPos, &wtx));
AddToSpends(wtx);
+ SyncMalleatedTxMetadata(wtx);
for (const CTxIn& txin : wtx.GetTx()->vin) {
auto it = mapWallet.find(txin.prevout.hash);
if (it != mapWallet.end()) {
### src/wallet/wallet.h
@@ -373,7 +373,11 @@ class CWallet final : public WalletStorage, public interfaces::Chain::Notificati
/** Mark a transaction's inputs dirty, thus forcing the outputs to be recomputed */
void MarkInputsDirty(const CTransactionRef& tx) EXCLUSIVE_LOCKS_REQUIRED(cs_wallet);
- void SyncMetaData(std::pair<TxSpends::iterator, TxSpends::iterator>) EXCLUSIVE_LOCKS_REQUIRED(cs_wallet);
+ /** Collects all wallet txs that differ from wtx only in their scriptSigs (i.e. different tx id malleated variants)
+ * plus wtx itself. Sorted by the order in which they were inserted in the wallet (CWalletTx::nOrderPos) */
+ std::set<CWalletTx*, WalletTxOrderComparator> GetMalleatedVariants(const CWalletTx& wtx) EXCLUSIVE_LOCKS_REQUIRED(cs_wallet);
+
+ void SyncMalleatedTxMetadata(const CWalletTx& wtx) EXCLUSIVE_LOCKS_REQUIRED(cs_wallet);
bool SyncTransaction(const CTransactionRef& tx, const SyncTxState& state, bool rescanning_old_block = false) EXCLUSIVE_LOCKS_REQUIRED(cs_wallet);
Why this scored 35/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.