Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this comment without a timeline for removing will be a confusing in the far future; isn't a commit text message enough?

// (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);
Comment thread
UdjinM6 marked this conversation as resolved.
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));
Comment thread
UdjinM6 marked this conversation as resolved.
} 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