From 07d8c0224426a18a3d26916d142c65c51f41ca65 Mon Sep 17 00:00:00 2001 From: UdjinM6 Date: Thu, 2 Apr 2026 18:36:17 +0300 Subject: [PATCH 1/5] feat: add -dustprotectionthreshold CLI option and unify GUI settings Add a command-line option (-dustprotectionthreshold=) to automatically lock UTXOs from external transactions at or below duffs, protecting against dust attacks. Move dust detection logic into CWallet (IsDustProtectionTarget, CheckAndLockDustOutputs, LockExistingDustOutputs) so it works for both CLI (dashd) and GUI (dash-qt) without duplication. Convert GUI dust protection from standalone QSettings to the CLI-shared settings framework (Prune pattern), so CLI flags take precedence and the Options dialog shows an override label. The -prev suffix remembers the user's threshold when disabling, restoring it on re-enable. Includes QSettings migration for existing users upgrading from the old GUI-only implementation, with clamping to the 1,000,000 duff maximum. Co-Authored-By: Claude Opus 4.6 --- src/interfaces/wallet.h | 6 +++ src/qt/optionsdialog.cpp | 7 +++ src/qt/optionsmodel.cpp | 86 +++++++++++++++++++++++++------- src/qt/optionsmodel.h | 11 +++-- src/qt/walletmodel.cpp | 100 +++++--------------------------------- src/qt/walletmodel.h | 2 - src/wallet/init.cpp | 6 +++ src/wallet/interfaces.cpp | 10 ++++ src/wallet/wallet.cpp | 92 +++++++++++++++++++++++++++++++++++ src/wallet/wallet.h | 16 ++++++ 10 files changed, 223 insertions(+), 113 deletions(-) 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..2e5202bf1531 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_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_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..00c84abd299d 100644 --- a/src/qt/optionsmodel.h +++ b/src/qt/optionsmodel.h @@ -26,6 +26,8 @@ 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; +/** 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..032439bd415a 100644 --- a/src/wallet/interfaces.cpp +++ b/src/wallet/interfaces.cpp @@ -344,6 +344,16 @@ class WalletImpl : public Wallet } return true; } + void setDustProtectionThreshold(CAmount threshold) override + { + LOCK(m_wallet->cs_wallet); + m_wallet->m_dust_protection_threshold = 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); From 5fd0e8879ec56a9fd71f28c5e42d7e087bb07471 Mon Sep 17 00:00:00 2001 From: UdjinM6 Date: Thu, 2 Apr 2026 23:54:48 +0300 Subject: [PATCH 2/5] test: add functional test for -dustprotectionthreshold CLI option Cover external dust locking, self-send exclusion, above-threshold exclusion, disabled threshold, restart persistence, multi-wallet dust protection, and invalid argument rejection. Co-Authored-By: Claude Opus 4.6 --- test/functional/test_runner.py | 2 + test/functional/wallet_dust_protection.py | 248 ++++++++++++++++++++++ 2 files changed, 250 insertions(+) create mode 100755 test/functional/wallet_dust_protection.py 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..82ad19a9ecd7 --- /dev/null +++ b/test/functional/wallet_dust_protection.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 +# Copyright (c) 2024 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) + num_dust = len(node3.listunspent()) + + # Restart node3 WITH dust protection — all existing dust should get locked + self.restart_node(3, ["-dustrelayfee=0", "-dustprotectionthreshold=10000"]) + self.connect_nodes(0, 3) + + locked = node3.listlockunspent() + assert_equal(len(locked), num_dust) + + # Restart again WITHOUT protection — locks should persist (written to DB) + self.restart_node(3, ["-dustrelayfee=0"]) + self.connect_nodes(0, 3) + + locked = node3.listlockunspent() + assert_equal(len(locked), num_dust) + + # 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() From 03f4ed025b4bce495104c137795085f8ec79e201 Mon Sep 17 00:00:00 2001 From: UdjinM6 Date: Fri, 3 Apr 2026 17:17:55 +0300 Subject: [PATCH 3/5] test: use exact outpoint matching in restart persistence test Compare the exact set of (txid, vout) pairs instead of just a count, making the assertion more precise and resilient to test-order changes. Co-Authored-By: Claude Opus 4.6 --- test/functional/wallet_dust_protection.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/test/functional/wallet_dust_protection.py b/test/functional/wallet_dust_protection.py index 82ad19a9ecd7..11190af370ad 100755 --- a/test/functional/wallet_dust_protection.py +++ b/test/functional/wallet_dust_protection.py @@ -156,21 +156,30 @@ def test_existing_utxos_locked_on_restart(self): self.sync_all() assert_equal(len(node3.listlockunspent()), 0) - num_dust = len(node3.listunspent()) + + # 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=10000"]) + self.restart_node(3, ["-dustrelayfee=0", "-dustprotectionthreshold=%d" % THRESHOLD]) self.connect_nodes(0, 3) locked = node3.listlockunspent() - assert_equal(len(locked), num_dust) + 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() - assert_equal(len(locked), num_dust) + locked_outpoints = {(entry['txid'], entry['vout']) for entry in locked} + assert_equal(locked_outpoints, expected_outpoints) # Cleanup node3.lockunspent(True, locked) From 30b132ecd62afc5d1eacf8b3a8b9df0ca2104e44 Mon Sep 17 00:00:00 2001 From: UdjinM6 Date: Fri, 3 Apr 2026 21:57:49 +0300 Subject: [PATCH 4/5] fix: apply review suggestions for dust protection - Rename GUI constant to DEFAULT_GUI_DUST_PROTECTION_THRESHOLD to avoid confusion with the CLI default (0) in wallet.h - Add range validation (std::clamp) in wallet interface setter to guard against corrupted settings or programmatic callers - Fix copyright year to 2026 Co-Authored-By: Claude Opus 4.6 --- src/qt/optionsmodel.cpp | 4 ++-- src/qt/optionsmodel.h | 2 +- src/wallet/interfaces.cpp | 3 ++- test/functional/wallet_dust_protection.py | 2 +- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/qt/optionsmodel.cpp b/src/qt/optionsmodel.cpp index 2e5202bf1531..057751997f17 100644 --- a/src/qt/optionsmodel.cpp +++ b/src/qt/optionsmodel.cpp @@ -746,7 +746,7 @@ QVariant OptionsModel::getOption(OptionID option, const std::string& suffix) con int64_t val = SettingToInt(setting(), 0); if (val > 0) return qlonglong(val); return suffix.empty() ? getOption(option, "-prev") : - qlonglong(DEFAULT_DUST_PROTECTION_THRESHOLD); + qlonglong(DEFAULT_GUI_DUST_PROTECTION_THRESHOLD); } #endif // ENABLE_WALLET case Prune: @@ -1243,7 +1243,7 @@ void OptionsModel::checkAndMigrate() bool was_enabled = settings.value("fDustProtection", false).toBool(); qint64 threshold = std::min( settings.value("nDustProtectionThreshold", - qlonglong(DEFAULT_DUST_PROTECTION_THRESHOLD)).toLongLong(), + qlonglong(DEFAULT_GUI_DUST_PROTECTION_THRESHOLD)).toLongLong(), MAX_GUI_DUST_PROTECTION_THRESHOLD); if (was_enabled && threshold > 0) { setOption(DustProtection, true); diff --git a/src/qt/optionsmodel.h b/src/qt/optionsmodel.h index 00c84abd299d..ac157c2e4fce 100644 --- a/src/qt/optionsmodel.h +++ b/src/qt/optionsmodel.h @@ -25,7 +25,7 @@ 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; diff --git a/src/wallet/interfaces.cpp b/src/wallet/interfaces.cpp index 032439bd415a..4d446f8214d0 100644 --- a/src/wallet/interfaces.cpp +++ b/src/wallet/interfaces.cpp @@ -39,6 +39,7 @@ #include #include +#include #include #include #include @@ -347,7 +348,7 @@ class WalletImpl : public Wallet void setDustProtectionThreshold(CAmount threshold) override { LOCK(m_wallet->cs_wallet); - m_wallet->m_dust_protection_threshold = threshold; + m_wallet->m_dust_protection_threshold = std::clamp(threshold, CAmount{0}, MAX_DUST_PROTECTION_THRESHOLD); } void lockExistingDustOutputs() override { diff --git a/test/functional/wallet_dust_protection.py b/test/functional/wallet_dust_protection.py index 11190af370ad..83798f5d7651 100755 --- a/test/functional/wallet_dust_protection.py +++ b/test/functional/wallet_dust_protection.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Copyright (c) 2024 The Dash Core developers +# 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. From 4af9d4a3ae70d38f1487825dec3f9a8b10589e5d Mon Sep 17 00:00:00 2001 From: UdjinM6 Date: Mon, 20 Apr 2026 00:36:35 +0300 Subject: [PATCH 5/5] test: drop redundant sync_all() after generate() The test framework's generate() helper already syncs nodes internally, so explicit sync_all() calls are redundant. Also reduce the initial block generation to COINBASE_MATURITY + 1 (the +10 was unnecessary). Co-Authored-By: Claude Opus 4.6 --- test/functional/wallet_dust_protection.py | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/test/functional/wallet_dust_protection.py b/test/functional/wallet_dust_protection.py index 83798f5d7651..b4a8fadfa497 100755 --- a/test/functional/wallet_dust_protection.py +++ b/test/functional/wallet_dust_protection.py @@ -41,9 +41,7 @@ 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.generate(self.nodes[0], COINBASE_MATURITY + 1) self.test_external_dust_locked() self.test_self_send_not_locked() @@ -72,7 +70,6 @@ def test_external_dust_locked(self): # 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) @@ -89,7 +86,6 @@ def test_self_send_not_locked(self): 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() @@ -106,7 +102,6 @@ def test_self_send_not_locked(self): 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.""" @@ -127,7 +122,6 @@ def test_above_threshold_not_locked(self): 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.""" @@ -142,7 +136,6 @@ def test_disabled_threshold(self): 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.""" @@ -153,7 +146,6 @@ def test_existing_utxos_locked_on_restart(self): 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) @@ -202,7 +194,6 @@ def test_multi_wallet(self): 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() @@ -214,7 +205,6 @@ def test_multi_wallet(self): 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()