Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/interfaces/wallet.h
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,12 @@ class Wallet
//! Unlock the provided coins in a single batch.
virtual bool unlockCoins(const std::vector<COutPoint>& 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<COutPoint> listProTxCoins() = 0;

Expand Down
7 changes: 7 additions & 0 deletions src/qt/optionsdialog.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
86 changes: 68 additions & 18 deletions src/qt/optionsmodel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
Expand Down Expand Up @@ -370,22 +372,16 @@ 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
// and we want command-line parameters to overwrite the GUI settings.
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 {
Expand Down Expand Up @@ -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<int64_t>(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); };
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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<int64_t>(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<int64_t>(value.toLongLong(), 1));
}
Q_EMIT dustProtectionChanged();
}
break;
#endif // ENABLE_WALLET
case Prune:
Expand Down Expand Up @@ -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<qint64>(
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");
Expand Down
13 changes: 8 additions & 5 deletions src/qt/optionsmodel.h
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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); }

Expand Down Expand Up @@ -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;
Expand Down
100 changes: 11 additions & 89 deletions src/qt/walletmodel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}

Expand Down Expand Up @@ -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)
Expand Down
2 changes: 0 additions & 2 deletions src/qt/walletmodel.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
6 changes: 6 additions & 0 deletions src/wallet/init.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ void WalletInit::AddWalletOptions(ArgsManager& argsman) const
argsman.AddArg("-signer=<cmd>", "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=<n>",
strprintf("Automatically lock UTXOs from incoming external transactions at or below <n> 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=<path>", "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 <walletdir>. 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 <walletdir>.", ArgsManager::ALLOW_ANY | ArgsManager::NETWORK_ONLY, OptionsCategory::WALLET);
argsman.AddArg("-walletbackupsdir=<dir>", "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);
Expand Down
11 changes: 11 additions & 0 deletions src/wallet/interfaces.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
#include <txdb.h>
#include <node/context.h>

#include <algorithm>
#include <memory>
#include <string>
#include <utility>
Expand Down Expand Up @@ -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<COutPoint> listProTxCoins() override
{
LOCK(m_wallet->cs_wallet);
Expand Down
Loading
Loading