diff --git a/src/interfaces/wallet.h b/src/interfaces/wallet.h index 161b148ea81d..134180f3728a 100644 --- a/src/interfaces/wallet.h +++ b/src/interfaces/wallet.h @@ -179,6 +179,12 @@ class Wallet //! Unlock the provided coins in a single batch. virtual bool unlockCoins(const std::vector& outputs) = 0; + //! Set dust protection threshold (does not lock anything by itself). + virtual void setDustProtectionThreshold(CAmount threshold) = 0; + + //! Lock all existing dust UTXOs that match the current threshold. + virtual void lockExistingDustOutputs() = 0; + //! List protx coins. virtual std::vector listProTxCoins() = 0; diff --git a/src/qt/optionsdialog.cpp b/src/qt/optionsdialog.cpp index 7be7120d9f70..39c71a1649ac 100644 --- a/src/qt/optionsdialog.cpp +++ b/src/qt/optionsdialog.cpp @@ -266,6 +266,13 @@ void OptionsDialog::setModel(OptionsModel *_model) setMapper(); mapper->toFirst(); + // Must be AFTER mapper->toFirst() because toFirst() triggers + // toggled signals that would re-enable the spinbox. + if (strLabel.contains("-dustprotectionthreshold")) { + ui->dustProtection->setEnabled(false); + ui->dustProtectionThreshold->setEnabled(false); + } + // If governance is disabled at the node level, force-disable governance checkboxes. if (m_client_model && !m_client_model->node().gov().isEnabled()) { ui->showGovernanceTab->setChecked(false); diff --git a/src/qt/optionsmodel.cpp b/src/qt/optionsmodel.cpp index 5a066a94bd36..057751997f17 100644 --- a/src/qt/optionsmodel.cpp +++ b/src/qt/optionsmodel.cpp @@ -72,6 +72,8 @@ static const char* SettingName(OptionsModel::OptionID option) case OptionsModel::FontScale: return "font-scale"; case OptionsModel::FontWeightBold: return "font-weight-bold"; case OptionsModel::FontWeightNormal: return "font-weight-normal"; + case OptionsModel::DustProtection: return "dustprotectionthreshold"; + case OptionsModel::DustProtectionThreshold: return "dustprotectionthreshold"; default: throw std::logic_error(strprintf("GUI option %i has no corresponding node setting.", option)); } } @@ -370,14 +372,8 @@ bool OptionsModel::Init(bilingual_str& error) if (!settings.contains("fLowKeysWarning")) settings.setValue("fLowKeysWarning", true); - // Dust protection - if (!settings.contains("fDustProtection")) - settings.setValue("fDustProtection", false); - fDustProtection = settings.value("fDustProtection", false).toBool(); - - if (!settings.contains("nDustProtectionThreshold")) - settings.setValue("nDustProtectionThreshold", (qlonglong)DEFAULT_DUST_PROTECTION_THRESHOLD); - nDustProtectionThreshold = settings.value("nDustProtectionThreshold", (qlonglong)DEFAULT_DUST_PROTECTION_THRESHOLD).toLongLong(); + // Dust protection - now managed through the CLI-shared settings framework + // (see SettingName mapping for DustProtection/DustProtectionThreshold) #endif // ENABLE_WALLET // These are shared with the core or have a command-line parameter @@ -385,7 +381,7 @@ bool OptionsModel::Init(bilingual_str& error) for (OptionID option : {DatabaseCache, ThreadsScriptVerif, SpendZeroConfChange, ExternalSignerPath, MapPortUPnP, MapPortNatpmp, Listen, Server, Prune, ProxyUse, ProxyUseTor, Language, CoinJoinAmount, CoinJoinDenomsGoal, CoinJoinDenomsHardCap, CoinJoinEnabled, CoinJoinMultiSession, - CoinJoinRounds, CoinJoinSessions}) { + CoinJoinRounds, CoinJoinSessions, DustProtection}) { std::string setting = SettingName(option); if (node().isSettingIgnored(setting)) addOverriddenOption("-" + setting); try { @@ -611,6 +607,22 @@ bool OptionsModel::setData(const QModelIndex & index, const QVariant & value, in return successful; } +bool OptionsModel::getDustProtection() const +{ + if (gArgs.IsArgSet("-dustprotectionthreshold")) { + return gArgs.GetIntArg("-dustprotectionthreshold", 0) > 0; + } + return getOption(DustProtection).toBool(); +} + +qint64 OptionsModel::getDustProtectionThreshold() const +{ + if (gArgs.IsArgSet("-dustprotectionthreshold")) { + return std::max(gArgs.GetIntArg("-dustprotectionthreshold", 0), 0); + } + return getOption(DustProtectionThreshold).toLongLong(); +} + QVariant OptionsModel::getOption(OptionID option, const std::string& suffix) const { auto setting = [&]{ return node().getPersistentSetting(SettingName(option) + suffix); }; @@ -729,9 +741,13 @@ QVariant OptionsModel::getOption(OptionID option, const std::string& suffix) con case KeepChangeAddress: return fKeepChangeAddress; case DustProtection: - return fDustProtection; - case DustProtectionThreshold: - return qlonglong(nDustProtectionThreshold); + return SettingToInt(setting(), 0) > 0; + case DustProtectionThreshold: { + int64_t val = SettingToInt(setting(), 0); + if (val > 0) return qlonglong(val); + return suffix.empty() ? getOption(option, "-prev") : + qlonglong(DEFAULT_GUI_DUST_PROTECTION_THRESHOLD); + } #endif // ENABLE_WALLET case Prune: return PruneEnabled(setting()); @@ -1025,14 +1041,26 @@ bool OptionsModel::setOption(OptionID option, const QVariant& value, const std:: Q_EMIT keepChangeAddressChanged(fKeepChangeAddress); break; case DustProtection: - fDustProtection = value.toBool(); - settings.setValue("fDustProtection", fDustProtection); - Q_EMIT dustProtectionChanged(); + if (changed()) { + if (suffix.empty() && !value.toBool()) setOption(option, true, "-prev"); + if (value.toBool()) { + update(std::max(getOption(DustProtectionThreshold).toLongLong(), 1)); + } else { + update(0); + } + if (suffix.empty() && value.toBool()) UpdateRwSetting(node(), option, "-prev", {}); + Q_EMIT dustProtectionChanged(); + } break; case DustProtectionThreshold: - nDustProtectionThreshold = value.toLongLong(); - settings.setValue("nDustProtectionThreshold", qlonglong(nDustProtectionThreshold)); - Q_EMIT dustProtectionChanged(); + if (changed()) { + if (suffix.empty() && !getOption(DustProtection).toBool()) { + setOption(option, value, "-prev"); + } else { + update(std::max(value.toLongLong(), 1)); + } + Q_EMIT dustProtectionChanged(); + } break; #endif // ENABLE_WALLET case Prune: @@ -1207,6 +1235,28 @@ void OptionsModel::checkAndMigrate() migrate_setting(FontWeightNormal, "fontWeightNormal"); } #ifdef ENABLE_WALLET + // Custom migration for dust protection: two old QSettings keys → one settings.json value. + // If enabled, migrate the threshold as the active value. If disabled but a custom threshold + // was set, save it to -prev so re-enabling restores the user's preference. + if (settings.contains("fDustProtection") || settings.contains("nDustProtectionThreshold")) { + if (node().getPersistentSetting(SettingName(DustProtection)).isNull()) { + bool was_enabled = settings.value("fDustProtection", false).toBool(); + qint64 threshold = std::min( + settings.value("nDustProtectionThreshold", + qlonglong(DEFAULT_GUI_DUST_PROTECTION_THRESHOLD)).toLongLong(), + MAX_GUI_DUST_PROTECTION_THRESHOLD); + if (was_enabled && threshold > 0) { + setOption(DustProtection, true); + setOption(DustProtectionThreshold, qlonglong(threshold)); + } else if (!was_enabled && threshold > 0) { + // Remember the custom threshold so re-enabling restores it. + setOption(DustProtectionThreshold, qlonglong(threshold), "-prev"); + } + } + settings.remove("fDustProtection"); + settings.remove("nDustProtectionThreshold"); + } + migrate_setting(CoinJoinAmount, "nCoinJoinAmount"); migrate_setting(CoinJoinDenomsGoal, "nCoinJoinDenomsGoal"); migrate_setting(CoinJoinDenomsHardCap, "nCoinJoinDenomsHardCap"); diff --git a/src/qt/optionsmodel.h b/src/qt/optionsmodel.h index 1e41c0b279ca..ac157c2e4fce 100644 --- a/src/qt/optionsmodel.h +++ b/src/qt/optionsmodel.h @@ -25,7 +25,9 @@ extern const char *DEFAULT_GUI_PROXY_HOST; static constexpr uint16_t DEFAULT_GUI_PROXY_PORT = 9050; /** Default threshold for dust attack protection (in duffs) */ -static constexpr qint64 DEFAULT_DUST_PROTECTION_THRESHOLD = 10000; +static constexpr qint64 DEFAULT_GUI_DUST_PROTECTION_THRESHOLD = 10000; +/** Maximum threshold for dust attack protection (in duffs), matches GUI spinbox and CLI cap */ +static constexpr qint64 MAX_GUI_DUST_PROTECTION_THRESHOLD = 1000000; /** * Convert configured prune target MiB to displayed GB. Round up to avoid underestimating max disk usage. @@ -139,8 +141,11 @@ class OptionsModel : public QAbstractListModel bool getShowGovernanceClock() const { return m_show_governance_clock; } bool getShowGovernanceTab() const { return m_enable_governance; } bool getShowAdvancedCJUI() { return fShowAdvancedCJUI; } - bool getDustProtection() const { return fDustProtection; } - qint64 getDustProtectionThreshold() const { return nDustProtectionThreshold; } + /* Effective dust protection state: CLI arg takes precedence over GUI setting. + * Unlike getOption() (which only reads persistent settings for the Options + * dialog / mapper), these return what the core wallet is actually using. */ + bool getDustProtection() const; + qint64 getDustProtectionThreshold() const; const QString& getOverriddenByCommandLine() { return strOverriddenByCommandLine; } bool isOptionOverridden(const QString& option) const { return strOverriddenByCommandLine.contains(option); } @@ -173,8 +178,6 @@ class OptionsModel : public QAbstractListModel bool m_enable_governance; bool m_show_governance_clock; bool fShowAdvancedCJUI; - bool fDustProtection{false}; - qint64 nDustProtectionThreshold{DEFAULT_DUST_PROTECTION_THRESHOLD}; /* settings that were overridden by command-line */ QString strOverriddenByCommandLine; diff --git a/src/qt/walletmodel.cpp b/src/qt/walletmodel.cpp index f83760c862da..4349b3b9a964 100644 --- a/src/qt/walletmodel.cpp +++ b/src/qt/walletmodel.cpp @@ -149,91 +149,21 @@ void WalletModel::updateTransaction() fForceCheckBalanceChanged = true; } -void WalletModel::checkAndLockDustOutputs(const QString& hashStr) -{ - // Check if dust protection is enabled - if (!optionsModel || !optionsModel->getDustProtection()) { - return; - } - - CAmount dustThreshold = optionsModel->getDustProtectionThreshold(); - if (dustThreshold <= 0) { - return; - } - - uint256 hash; - hash.SetHex(hashStr.toStdString()); - - // Get the transaction (lighter than getWalletTx) - CTransactionRef tx = m_wallet->getTx(hash); - if (!tx) { - return; - } - - // Skip coinbase and special transactions - not dust attacks - if (tx->IsCoinBase() || tx->nType != TRANSACTION_NORMAL) { - return; - } - - // Check if any input belongs to this wallet (isFromMe check) - // Early exit on first match - for (const auto& txin : tx->vin) { - if (m_wallet->txinIsMine(txin)) { - return; - } - } - - // Check each output - threshold first (cheap), then ownership (more expensive) - for (size_t i = 0; i < tx->vout.size(); i++) { - const CTxOut& txout = tx->vout[i]; - if (txout.nValue > 0 && txout.nValue <= dustThreshold) { - if (m_wallet->txoutIsMine(txout)) { - m_wallet->lockCoin(COutPoint(hash, i), /*write_to_db=*/true); - } - } - } -} - void WalletModel::lockExistingDustOutputs() { - if (!optionsModel || !optionsModel->getDustProtection()) { - return; - } - - CAmount dustThreshold = optionsModel->getDustProtectionThreshold(); - if (dustThreshold <= 0) { - return; - } + if (!optionsModel) return; - // Iterate UTXOs (much smaller set than all transactions) - for (const auto& [dest, coins] : m_wallet->listCoins()) { - for (const auto& [outpoint, wtxout] : coins) { - // Skip if already locked - if (m_wallet->isLockedCoin(outpoint)) continue; + // When the CLI arg is set, CWallet::Create already configured the core + // threshold — don't overwrite it with the GUI-only persistent setting. + if (optionsModel->isOptionOverridden("-dustprotectionthreshold")) return; - // Skip if above threshold - if (wtxout.txout.nValue > dustThreshold) continue; - - // Get the transaction to check for coinbase/special tx and isFromMe - CTransactionRef tx = m_wallet->getTx(outpoint.hash); - if (!tx) continue; - - // Skip coinbase and special transactions - if (tx->IsCoinBase() || tx->nType != TRANSACTION_NORMAL) continue; - - // Check if any input is ours (skip self-sends) - bool isFromMe = false; - for (const auto& txin : tx->vin) { - if (m_wallet->txinIsMine(txin)) { - isFromMe = true; - break; - } - } - if (isFromMe) continue; - - // External dust - lock it - m_wallet->lockCoin(outpoint, /*write_to_db=*/true); - } + // getDustProtection() checks the resolved enabled/disabled state. + // getDustProtectionThreshold() may return a remembered "-prev" value + // even when protection is off, so we must gate on the bool first. + CAmount threshold = optionsModel->getDustProtection() ? optionsModel->getDustProtectionThreshold() : 0; + m_wallet->setDustProtectionThreshold(threshold); + if (threshold > 0) { + m_wallet->lockExistingDustOutputs(); } } @@ -546,14 +476,6 @@ static void NotifyTransactionChanged(WalletModel *walletmodel, const uint256 &ha { bool invoked = QMetaObject::invokeMethod(walletmodel, "updateTransaction", Qt::QueuedConnection); assert(invoked); - - // For new transactions, check if dust protection should lock UTXOs - if (status == CT_NEW) { - QString hashStr = QString::fromStdString(hash.ToString()); - invoked = QMetaObject::invokeMethod(walletmodel, "checkAndLockDustOutputs", Qt::QueuedConnection, - Q_ARG(QString, hashStr)); - assert(invoked); - } } static void NotifyISLockReceived(WalletModel *walletmodel) diff --git a/src/qt/walletmodel.h b/src/qt/walletmodel.h index 9249282f13e7..df34439da804 100644 --- a/src/qt/walletmodel.h +++ b/src/qt/walletmodel.h @@ -241,8 +241,6 @@ public Q_SLOTS: void updateStatus(); /* New transaction, or transaction changed status */ void updateTransaction(); - /* Check and lock dust outputs for a new transaction */ - void checkAndLockDustOutputs(const QString& hash); /* Lock existing dust outputs (called on startup and settings change) */ void lockExistingDustOutputs(); /* IS-Lock received */ diff --git a/src/wallet/init.cpp b/src/wallet/init.cpp index 7bb836c40083..90fef2542e9f 100644 --- a/src/wallet/init.cpp +++ b/src/wallet/init.cpp @@ -71,6 +71,12 @@ void WalletInit::AddWalletOptions(ArgsManager& argsman) const argsman.AddArg("-signer=", "External signing tool, see doc/external-signer.md", ArgsManager::ALLOW_ANY, OptionsCategory::WALLET); #endif argsman.AddArg("-spendzeroconfchange", strprintf("Spend unconfirmed change when sending transactions (default: %u)", DEFAULT_SPEND_ZEROCONF_CHANGE), ArgsManager::ALLOW_ANY, OptionsCategory::WALLET); + argsman.AddArg("-dustprotectionthreshold=", + strprintf("Automatically lock UTXOs from incoming external transactions at or below duffs " + "to protect against dust attacks. Locked UTXOs persist across restarts and are not " + "automatically unlocked when threshold changes; use lockunspent RPC to unlock manually " + "(0 = disabled, default: %d, max: %d)", DEFAULT_DUST_PROTECTION_THRESHOLD, MAX_DUST_PROTECTION_THRESHOLD), + ArgsManager::ALLOW_ANY, OptionsCategory::WALLET); argsman.AddArg("-wallet=", "Specify wallet path to load at startup. Can be used multiple times to load multiple wallets. Path is to a directory containing wallet data and log files. If the path is not absolute, it is interpreted relative to . This only loads existing wallets and does not create new ones. For backwards compatibility this also accepts names of existing top-level data files in .", ArgsManager::ALLOW_ANY | ArgsManager::NETWORK_ONLY, OptionsCategory::WALLET); argsman.AddArg("-walletbackupsdir=", "Specify full path to directory for automatic wallet backups (must exist)", ArgsManager::ALLOW_ANY, OptionsCategory::WALLET); argsman.AddArg("-walletbroadcast", strprintf("Make the wallet broadcast transactions (default: %u)", DEFAULT_WALLETBROADCAST), ArgsManager::ALLOW_ANY, OptionsCategory::WALLET); diff --git a/src/wallet/interfaces.cpp b/src/wallet/interfaces.cpp index f6dd6f7a0a8b..4d446f8214d0 100644 --- a/src/wallet/interfaces.cpp +++ b/src/wallet/interfaces.cpp @@ -39,6 +39,7 @@ #include #include +#include #include #include #include @@ -344,6 +345,16 @@ class WalletImpl : public Wallet } return true; } + void setDustProtectionThreshold(CAmount threshold) override + { + LOCK(m_wallet->cs_wallet); + m_wallet->m_dust_protection_threshold = std::clamp(threshold, CAmount{0}, MAX_DUST_PROTECTION_THRESHOLD); + } + void lockExistingDustOutputs() override + { + LOCK(m_wallet->cs_wallet); + m_wallet->LockExistingDustOutputs(); + } std::vector listProTxCoins() override { LOCK(m_wallet->cs_wallet); diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp index 773d1dd6cc7d..d0c18237e648 100644 --- a/src/wallet/wallet.cpp +++ b/src/wallet/wallet.cpp @@ -56,6 +56,16 @@ using interfaces::FoundBlock; namespace wallet { +static isminetype InputIsMine(const CWallet& wallet, const CTxIn& txin) EXCLUSIVE_LOCKS_REQUIRED(wallet.cs_wallet) +{ + AssertLockHeld(wallet.cs_wallet); + const CWalletTx* prev = wallet.GetWalletTx(txin.prevout.hash); + if (prev && txin.prevout.n < prev->tx->vout.size()) { + return wallet.IsMine(prev->tx->vout[txin.prevout.n]); + } + return ISMINE_NO; +} + const std::map WALLET_FLAG_CAVEATS{ {WALLET_FLAG_AVOID_REUSE, "You need to rescan the blockchain in order to correctly mark used " @@ -1066,6 +1076,10 @@ CWalletTx* CWallet::AddToWallet(CTransactionRef tx, const TxState& state, const LockProTxCoins(candidates, &batch); + if (fInsertedNew) { + CheckAndLockDustOutputs(hash, batch); + } + //// debug print WalletLogPrintf("AddToWallet %s %s%s %s\n", hash.ToString(), (fInsertedNew ? "new" : ""), (fUpdated ? "update" : ""), TxStateString(state)); @@ -2789,6 +2803,73 @@ void CWallet::LockProTxCoins(const std::set& utxos, WalletBatch* batc } } +bool CWallet::IsDustProtectionTarget(const CWalletTx& wtx, unsigned int output_index) const +{ + AssertLockHeld(cs_wallet); + + if (m_dust_protection_threshold <= 0) return false; + + const CTransactionRef& tx = wtx.tx; + if (tx->IsCoinBase() || tx->nType != TRANSACTION_NORMAL) return false; + + if (output_index >= tx->vout.size()) return false; + const CTxOut& txout = tx->vout[output_index]; + + if (txout.nValue <= 0 || txout.nValue > m_dust_protection_threshold) return false; + if (IsMine(txout) == ISMINE_NO) return false; + + // Skip self-sends: if any input is ours, this is not an external dust attack. + for (const auto& txin : tx->vin) { + if (InputIsMine(*this, txin) != ISMINE_NO) return false; + } + + return true; +} + +void CWallet::CheckAndLockDustOutputs(const uint256& txHash, WalletBatch& batch) +{ + AssertLockHeld(cs_wallet); + + if (m_dust_protection_threshold <= 0) return; + + auto it = mapWallet.find(txHash); + if (it == mapWallet.end()) return; + + const CWalletTx& wtx = it->second; + for (unsigned int i = 0; i < wtx.tx->vout.size(); ++i) { + if (IsDustProtectionTarget(wtx, i)) { + LockCoin(COutPoint(txHash, i), &batch); + } + } +} + +void CWallet::LockExistingDustOutputs() +{ + AssertLockHeld(cs_wallet); + + if (m_dust_protection_threshold <= 0) return; + + WalletBatch batch(GetDatabase()); + for (const auto* pwtx : GetSpendableTXs()) { + const CWalletTx& wtx = *pwtx; + + if (IsTxImmatureCoinBase(wtx)) continue; + + const int depth = GetTxDepthInMainChain(wtx); + if (depth < 0) continue; + if (depth == 0 && !wtx.InMempool()) continue; + + for (unsigned int i = 0; i < wtx.tx->vout.size(); ++i) { + const COutPoint outpoint(wtx.GetHash(), i); + if (IsLockedCoin(outpoint) || IsSpent(outpoint)) continue; + + if (IsDustProtectionTarget(wtx, i)) { + LockCoin(outpoint, &batch); + } + } + } +} + /** @} */ // end of Actions void CWallet::GetKeyBirthTimes(std::map& mapKeyBirth) const { @@ -3292,6 +3373,16 @@ std::shared_ptr CWallet::Create(WalletContext& context, const std::stri walletInstance->m_confirm_target = args.GetIntArg("-txconfirmtarget", DEFAULT_TX_CONFIRM_TARGET); walletInstance->m_spend_zero_conf_change = args.GetBoolArg("-spendzeroconfchange", DEFAULT_SPEND_ZEROCONF_CHANGE); + walletInstance->m_dust_protection_threshold = args.GetIntArg("-dustprotectionthreshold", DEFAULT_DUST_PROTECTION_THRESHOLD); + if (walletInstance->m_dust_protection_threshold < 0) { + error = strprintf(_("Invalid value for %s: must be >= 0"), "-dustprotectionthreshold"); + return nullptr; + } + if (walletInstance->m_dust_protection_threshold > MAX_DUST_PROTECTION_THRESHOLD) { + error = strprintf(_("Invalid value for %s: exceeds maximum (%d)"), "-dustprotectionthreshold", MAX_DUST_PROTECTION_THRESHOLD); + return nullptr; + } + walletInstance->WalletLogPrintf("Wallet completed loading in %15dms\n", Ticks(SteadyClock::now() - start)); // Try to top up keypool. No-op if the wallet is locked. @@ -3311,6 +3402,7 @@ std::shared_ptr CWallet::Create(WalletContext& context, const std::stri { LOCK(walletInstance->cs_wallet); walletInstance->SetBroadcastTransactions(args.GetBoolArg("-walletbroadcast", DEFAULT_WALLETBROADCAST)); + walletInstance->LockExistingDustOutputs(); walletInstance->WalletLogPrintf("setExternalKeyPool.size() = %u\n", walletInstance->KeypoolCountExternalKeys()); walletInstance->WalletLogPrintf("GetKeyPoolSize() = %u\n", walletInstance->GetKeyPoolSize()); walletInstance->WalletLogPrintf("mapWallet.size() = %u\n", walletInstance->mapWallet.size()); diff --git a/src/wallet/wallet.h b/src/wallet/wallet.h index 0544840047ee..54bffc5d9842 100644 --- a/src/wallet/wallet.h +++ b/src/wallet/wallet.h @@ -111,6 +111,10 @@ static const unsigned int DEFAULT_TX_CONFIRM_TARGET = 6; static const bool DEFAULT_WALLETBROADCAST = true; static const bool DEFAULT_DISABLE_WALLET = false; static const bool DEFAULT_WALLETCROSSCHAIN = false; +//! -dustprotectionthreshold default (0 = disabled) +static constexpr CAmount DEFAULT_DUST_PROTECTION_THRESHOLD{0}; +//! -dustprotectionthreshold maximum (matches GUI spinbox cap) +static constexpr CAmount MAX_DUST_PROTECTION_THRESHOLD{1000000}; //! -maxtxfee default static const CAmount DEFAULT_TRANSACTION_MAXFEE = COIN / 10; //! Discourage users to set fees higher than this amount (in satoshis) per kB @@ -589,6 +593,14 @@ class CWallet final : public WalletStorage, public interfaces::Chain::Notificati std::vector ListProTxCoins(const std::set& utxos) const EXCLUSIVE_LOCKS_REQUIRED(cs_wallet); void LockProTxCoins(const std::set& utxos, WalletBatch* batch = nullptr) EXCLUSIVE_LOCKS_REQUIRED(cs_wallet); + /** Returns true if the given output of a wallet transaction is a dust protection target: + * value is in (0, threshold], tx is normal type, not coinbase, and not from this wallet. */ + bool IsDustProtectionTarget(const CWalletTx& wtx, unsigned int output_index) const EXCLUSIVE_LOCKS_REQUIRED(cs_wallet); + /** Lock dust outputs in a specific transaction if dust protection is enabled. */ + void CheckAndLockDustOutputs(const uint256& txHash, WalletBatch& batch) EXCLUSIVE_LOCKS_REQUIRED(cs_wallet); + /** Lock all existing dust UTXOs if dust protection is enabled. Called on wallet load. */ + void LockExistingDustOutputs() EXCLUSIVE_LOCKS_REQUIRED(cs_wallet); + /* * Rescan abort properties */ @@ -775,6 +787,10 @@ class CWallet final : public WalletStorage, public interfaces::Chain::Notificati /** Absolute maximum transaction fee (in satoshis) used by default for the wallet */ CAmount m_default_max_tx_fee{DEFAULT_TRANSACTION_MAXFEE}; + /** Dust protection threshold in duffs. UTXOs from external transactions at or below this value + * are automatically locked to prevent dust attacks. 0 = disabled. Override with -dustprotectionthreshold. */ + CAmount m_dust_protection_threshold{DEFAULT_DUST_PROTECTION_THRESHOLD}; + size_t KeypoolCountExternalKeys() const EXCLUSIVE_LOCKS_REQUIRED(cs_wallet); bool TopUpKeyPool(unsigned int kpSize = 0); diff --git a/test/functional/test_runner.py b/test/functional/test_runner.py index cb9a19637420..c888863f7ef9 100755 --- a/test/functional/test_runner.py +++ b/test/functional/test_runner.py @@ -198,6 +198,8 @@ 'wallet_createwallet.py --legacy-wallet', 'wallet_createwallet.py --usecli', 'wallet_createwallet.py --descriptors', + 'wallet_dust_protection.py --legacy-wallet', + 'wallet_dust_protection.py --descriptors', 'wallet_reorgsrestore.py', 'wallet_listtransactions.py --legacy-wallet', 'wallet_listtransactions.py --descriptors', diff --git a/test/functional/wallet_dust_protection.py b/test/functional/wallet_dust_protection.py new file mode 100755 index 000000000000..83798f5d7651 --- /dev/null +++ b/test/functional/wallet_dust_protection.py @@ -0,0 +1,257 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 The Dash Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. +"""Test -dustprotectionthreshold CLI option. + +Verify that UTXOs from external transactions at or below the threshold +are automatically locked to protect against dust attacks. +""" +from decimal import Decimal + +from test_framework.blocktools import COINBASE_MATURITY +from test_framework.test_framework import BitcoinTestFramework +from test_framework.util import ( + assert_equal, +) + +# 1 DASH = 100_000_000 duffs +DUFFS = Decimal('0.00000001') + + +class WalletDustProtectionTest(BitcoinTestFramework): + def add_options(self, parser): + self.add_wallet_options(parser) + + def set_test_params(self): + self.setup_clean_chain = True + self.num_nodes = 4 + # node0: sender (no dust protection) + # node1: receiver with dust protection at 10000 duffs + # node2: multi-wallet node with dust protection + # node3: receiver with no dust protection (threshold=0, the default) + self.extra_args = [ + ["-dustrelayfee=0"], + ["-dustrelayfee=0", "-dustprotectionthreshold=10000"], + ["-dustrelayfee=0", "-dustprotectionthreshold=10000", "-nowallet"], + ["-dustrelayfee=0"], + ] + + def skip_test_if_missing_module(self): + self.skip_if_no_wallet() + + def run_test(self): + self.log.info("Generate coins for the sender (node0)") + self.generate(self.nodes[0], COINBASE_MATURITY + 10) + self.sync_all() + + self.test_external_dust_locked() + self.test_self_send_not_locked() + self.test_above_threshold_not_locked() + self.test_disabled_threshold() + self.test_existing_utxos_locked_on_restart() + self.test_multi_wallet() + self.test_invalid_args() + + def test_external_dust_locked(self): + """External dust at or below threshold should be locked automatically.""" + self.log.info("Test: external dust gets locked") + node0 = self.nodes[0] + node1 = self.nodes[1] + + addr = node1.getnewaddress() + + # Send exactly 10000 duffs (at threshold) + txid = node0.sendtoaddress(addr, 10000 * DUFFS) + self.sync_mempools() + + # Should be locked immediately (before confirmation) + locked = node1.listlockunspent() + assert_equal(len(locked), 1) + assert_equal(locked[0]['txid'], txid) + + # Confirm and verify still locked + self.generate(self.nodes[0], 1) + self.sync_all() + locked = node1.listlockunspent() + assert_equal(len(locked), 1) + assert_equal(locked[0]['txid'], txid) + + # Cleanup: unlock for further tests + node1.lockunspent(True, locked) + + def test_self_send_not_locked(self): + """Self-sends should NOT be locked even if below threshold.""" + self.log.info("Test: self-send dust is not locked") + node1 = self.nodes[1] + + # Fund node1 with a larger amount first + addr_fund = node1.getnewaddress() + self.nodes[0].sendtoaddress(addr_fund, 1) + self.generate(self.nodes[0], 1) + self.sync_all() + + # Unlock everything so node1 can spend + locked = node1.listlockunspent() + if locked: + node1.lockunspent(True, locked) + + # Self-send a dust amount + addr_self = node1.getnewaddress() + node1.sendtoaddress(addr_self, 5000 * DUFFS) + self.sync_mempools() + + # Self-send should not create any new locks + locked = node1.listlockunspent() + assert_equal(len(locked), 0) + + self.generate(self.nodes[0], 1) + self.sync_all() + + def test_above_threshold_not_locked(self): + """UTXOs above the threshold should NOT be locked.""" + self.log.info("Test: above-threshold UTXO is not locked") + node1 = self.nodes[1] + + # Clear any existing locks + locked = node1.listlockunspent() + if locked: + node1.lockunspent(True, locked) + + addr = node1.getnewaddress() + # Send 10001 duffs (just above 10000 threshold) + self.nodes[0].sendtoaddress(addr, 10001 * DUFFS) + self.sync_mempools() + + locked = node1.listlockunspent() + assert_equal(len(locked), 0) + + self.generate(self.nodes[0], 1) + self.sync_all() + + def test_disabled_threshold(self): + """With default threshold (0), nothing should be locked.""" + self.log.info("Test: threshold=0 disables dust protection") + node3 = self.nodes[3] + + addr = node3.getnewaddress() + self.nodes[0].sendtoaddress(addr, 5000 * DUFFS) + self.sync_mempools() + + locked = node3.listlockunspent() + assert_equal(len(locked), 0) + + self.generate(self.nodes[0], 1) + self.sync_all() + + def test_existing_utxos_locked_on_restart(self): + """Pre-existing dust UTXOs should be locked when node starts with -dustprotectionthreshold.""" + self.log.info("Test: existing UTXOs locked on restart") + node3 = self.nodes[3] # no dust protection + + # Send dust to node3 while protection is off + addr = node3.getnewaddress() + self.nodes[0].sendtoaddress(addr, 8000 * DUFFS) + self.generate(self.nodes[0], 1) + self.sync_all() + + assert_equal(len(node3.listlockunspent()), 0) + + # Capture exact dust outpoints before restart + THRESHOLD = 10000 + expected_outpoints = set() + for utxo in node3.listunspent(): + if utxo['amount'] <= THRESHOLD * DUFFS: + expected_outpoints.add((utxo['txid'], utxo['vout'])) + assert len(expected_outpoints) > 0, "Test requires at least one dust UTXO on node3" + + # Restart node3 WITH dust protection — all existing dust should get locked + self.restart_node(3, ["-dustrelayfee=0", "-dustprotectionthreshold=%d" % THRESHOLD]) + self.connect_nodes(0, 3) + + locked = node3.listlockunspent() + locked_outpoints = {(entry['txid'], entry['vout']) for entry in locked} + assert_equal(locked_outpoints, expected_outpoints) + + # Restart again WITHOUT protection — locks should persist (written to DB) + self.restart_node(3, ["-dustrelayfee=0"]) + self.connect_nodes(0, 3) + + locked = node3.listlockunspent() + locked_outpoints = {(entry['txid'], entry['vout']) for entry in locked} + assert_equal(locked_outpoints, expected_outpoints) + + # Cleanup + node3.lockunspent(True, locked) + + def test_multi_wallet(self): + """Dust protection should work across multiple wallets on the same node.""" + self.log.info("Test: multi-wallet dust protection") + node2 = self.nodes[2] + + # Create two wallets on node2 + node2.createwallet(wallet_name='wallet_a') + node2.createwallet(wallet_name='wallet_b') + wallet_a = node2.get_wallet_rpc('wallet_a') + wallet_b = node2.get_wallet_rpc('wallet_b') + + addr_a = wallet_a.getnewaddress() + addr_b = wallet_b.getnewaddress() + + # Send dust to both wallets + self.nodes[0].sendtoaddress(addr_a, 5000 * DUFFS) + self.nodes[0].sendtoaddress(addr_b, 7000 * DUFFS) + self.generate(self.nodes[0], 1) + self.sync_all() + + # Both wallets should have their dust locked + locked_a = wallet_a.listlockunspent() + locked_b = wallet_b.listlockunspent() + assert_equal(len(locked_a), 1) + assert_equal(len(locked_b), 1) + + # Send an above-threshold amount — should NOT be locked + addr_a2 = wallet_a.getnewaddress() + self.nodes[0].sendtoaddress(addr_a2, 20000 * DUFFS) + self.generate(self.nodes[0], 1) + self.sync_all() + + # wallet_a still has only 1 locked UTXO (the dust one) + locked_a = wallet_a.listlockunspent() + assert_equal(len(locked_a), 1) + + # Restart and verify locks persist across wallets + self.restart_node(2, ["-dustrelayfee=0", "-dustprotectionthreshold=10000", + "-wallet=wallet_a", "-wallet=wallet_b"]) + self.connect_nodes(0, 2) + wallet_a = node2.get_wallet_rpc('wallet_a') + wallet_b = node2.get_wallet_rpc('wallet_b') + + locked_a = wallet_a.listlockunspent() + locked_b = wallet_b.listlockunspent() + assert_equal(len(locked_a), 1) + assert_equal(len(locked_b), 1) + + def test_invalid_args(self): + """Invalid -dustprotectionthreshold values should be rejected.""" + self.log.info("Test: invalid CLI args rejected") + + # Negative value + self.stop_node(3) + self.nodes[3].assert_start_raises_init_error( + ["-dustprotectionthreshold=-1"], + "Error: Invalid value for -dustprotectionthreshold: must be >= 0", + ) + + # Above maximum (1000000) + self.nodes[3].assert_start_raises_init_error( + ["-dustprotectionthreshold=1000001"], + "Error: Invalid value for -dustprotectionthreshold: exceeds maximum (1000000)", + ) + + # Restart node3 normally for clean state + self.start_node(3, ["-dustrelayfee=0"]) + + +if __name__ == '__main__': + WalletDustProtectionTest().main()