diff --git a/src/qt/forms/optionsdialog.ui b/src/qt/forms/optionsdialog.ui index eda0ce5cdd69..8e10cfb21bb7 100644 --- a/src/qt/forms/optionsdialog.ui +++ b/src/qt/forms/optionsdialog.ui @@ -407,6 +407,66 @@ + + + + Automatically lock small incoming transactions from external sources that may be dust attacks. Locked UTXOs will be excluded from coin selection. + + + Enable &dust attack protection + + + + + + + + + Dust threshold: + + + + + + + Transactions with outputs at or below this amount will be considered dust when received from external sources. + + + 1 + + + 1000000 + + + 10000 + + + + + + + duffs + + + Qt::PlainText + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + diff --git a/src/qt/optionsdialog.cpp b/src/qt/optionsdialog.cpp index 034ae4f908ef..c1b9bde9c07c 100644 --- a/src/qt/optionsdialog.cpp +++ b/src/qt/optionsdialog.cpp @@ -73,6 +73,11 @@ OptionsDialog::OptionsDialog(QWidget *parent, bool enableWallet) : ui->pruneSize->setEnabled(false); connect(ui->prune, &QPushButton::toggled, ui->pruneSize, &QWidget::setEnabled); + /* Dust protection */ + ui->dustProtectionThreshold->setEnabled(false); + ui->dustProtectionThresholdUnitLabel->setText(BitcoinUnits::name(BitcoinUnits::Unit::duffs)); + connect(ui->dustProtection, &QCheckBox::toggled, ui->dustProtectionThreshold, &QWidget::setEnabled); + /* Wallet */ ui->coinJoinEnabled->setText(tr("Enable %1 features").arg(QString::fromStdString(gCoinJoinName))); @@ -344,6 +349,8 @@ void OptionsDialog::setMapper() mapper->addMapping(ui->subFeeFromAmount, OptionsModel::SubFeeFromAmount); mapper->addMapping(ui->m_enable_psbt_controls, OptionsModel::EnablePSBTControls); mapper->addMapping(ui->keepChangeAddress, OptionsModel::KeepChangeAddress); + mapper->addMapping(ui->dustProtection, OptionsModel::DustProtection); + mapper->addMapping(ui->dustProtectionThreshold, OptionsModel::DustProtectionThreshold); mapper->addMapping(ui->showMasternodesTab, OptionsModel::ShowMasternodesTab); mapper->addMapping(ui->showGovernanceTab, OptionsModel::ShowGovernanceTab); mapper->addMapping(ui->showAdvancedCJUI, OptionsModel::ShowAdvancedCJUI); diff --git a/src/qt/optionsmodel.cpp b/src/qt/optionsmodel.cpp index ce4579ca5814..497978ee67bd 100644 --- a/src/qt/optionsmodel.cpp +++ b/src/qt/optionsmodel.cpp @@ -357,6 +357,15 @@ 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(); #endif // ENABLE_WALLET // These are shared with the core or have a command-line parameter @@ -705,6 +714,10 @@ QVariant OptionsModel::getOption(OptionID option, const std::string& suffix) con return settings.value("enable_psbt_controls"); case KeepChangeAddress: return fKeepChangeAddress; + case DustProtection: + return fDustProtection; + case DustProtectionThreshold: + return qlonglong(nDustProtectionThreshold); #endif // ENABLE_WALLET case Prune: return PruneEnabled(setting()); @@ -987,6 +1000,16 @@ bool OptionsModel::setOption(OptionID option, const QVariant& value, const std:: settings.setValue("fKeepChangeAddress", fKeepChangeAddress); Q_EMIT keepChangeAddressChanged(fKeepChangeAddress); break; + case DustProtection: + fDustProtection = value.toBool(); + settings.setValue("fDustProtection", fDustProtection); + Q_EMIT dustProtectionChanged(); + break; + case DustProtectionThreshold: + nDustProtectionThreshold = value.toLongLong(); + settings.setValue("nDustProtectionThreshold", qlonglong(nDustProtectionThreshold)); + Q_EMIT dustProtectionChanged(); + break; #endif // ENABLE_WALLET case Prune: if (changed()) { diff --git a/src/qt/optionsmodel.h b/src/qt/optionsmodel.h index 1bf20c7b0efc..e598b12d2128 100644 --- a/src/qt/optionsmodel.h +++ b/src/qt/optionsmodel.h @@ -24,6 +24,9 @@ class Node; 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; + /** * Convert configured prune target MiB to displayed GB. Round up to avoid underestimating max disk usage. */ @@ -95,6 +98,8 @@ class OptionsModel : public QAbstractListModel Server, // bool EnablePSBTControls, // bool MaskValues, // bool + DustProtection, // bool + DustProtectionThreshold, // CAmount (in duffs) OptionIDRowCount, }; @@ -130,6 +135,8 @@ class OptionsModel : public QAbstractListModel bool getEnablePSBTControls() const { return m_enable_psbt_controls; } bool getKeepChangeAddress() const { return fKeepChangeAddress; } bool getShowAdvancedCJUI() { return fShowAdvancedCJUI; } + bool getDustProtection() const { return fDustProtection; } + qint64 getDustProtectionThreshold() const { return nDustProtectionThreshold; } const QString& getOverriddenByCommandLine() { return strOverriddenByCommandLine; } bool isOptionOverridden(const QString& option) const { return strOverriddenByCommandLine.contains(option); } void emitCoinJoinEnabledChanged(); @@ -160,6 +167,8 @@ class OptionsModel : public QAbstractListModel bool m_mask_values; bool fKeepChangeAddress; bool fShowAdvancedCJUI; + bool fDustProtection{false}; + qint64 nDustProtectionThreshold{DEFAULT_DUST_PROTECTION_THRESHOLD}; /* settings that were overridden by command-line */ QString strOverriddenByCommandLine; @@ -183,6 +192,7 @@ class OptionsModel : public QAbstractListModel void keepChangeAddressChanged(bool); void showTrayIconChanged(bool); void fontForMoneyChanged(const QFont&); + void dustProtectionChanged(); }; Q_DECLARE_METATYPE(OptionsModel::FontChoice) diff --git a/src/qt/overviewpage.cpp b/src/qt/overviewpage.cpp index 8ed12617a19f..91fc3f1aafd1 100644 --- a/src/qt/overviewpage.cpp +++ b/src/qt/overviewpage.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -759,6 +760,8 @@ void OverviewPage::SetupTransactionList(int nNumItems) filter->setDynamicSortFilter(true); filter->setSortRole(Qt::EditRole); filter->setShowInactive(false); + // Exclude dust receive transactions from overview + filter->setTypeFilter(TransactionFilterProxy::ALL_TYPES & ~TransactionFilterProxy::TYPE(TransactionRecord::DustReceive)); filter->sort(TransactionTableModel::Date, Qt::DescendingOrder); ui->listTransactions->setModel(filter.get()); } diff --git a/src/qt/transactionrecord.cpp b/src/qt/transactionrecord.cpp index 3814cd9c1999..d80fe24ad661 100644 --- a/src/qt/transactionrecord.cpp +++ b/src/qt/transactionrecord.cpp @@ -29,7 +29,8 @@ bool TransactionRecord::showTransaction() /* * Decompose CWallet transaction to model transaction records. */ -QList TransactionRecord::decomposeTransaction(interfaces::Node& node, interfaces::Wallet& wallet, const interfaces::WalletTx& wtx) +QList TransactionRecord::decomposeTransaction(interfaces::Node& node, interfaces::Wallet& wallet, const interfaces::WalletTx& wtx, + bool dustProtectionEnabled, CAmount dustThreshold) { QList parts; int64_t nTime = wtx.time; @@ -40,6 +41,15 @@ QList TransactionRecord::decomposeTransaction(interfaces::Nod std::map mapValue = wtx.value_map; auto& coinJoinOptions = node.coinJoinOptions(); + // Check if any inputs belong to this wallet (for dust detection) + bool isFromMe = false; + for (const isminetype mine : wtx.txin_is_mine) { + if (mine) { + isFromMe = true; + break; + } + } + if (nNet > 0 || wtx.is_coinbase || wtx.is_platform_transfer) { // @@ -81,6 +91,15 @@ QList TransactionRecord::decomposeTransaction(interfaces::Nod sub.type = TransactionRecord::PlatformTransfer; } + // Check for dust attack: external receive with small amount + // Only override if not already a special type (coinbase, platform transfer) + if (dustProtectionEnabled && !isFromMe && !wtx.is_coinbase && !wtx.is_platform_transfer && + sub.credit > 0 && sub.credit <= dustThreshold && + (sub.type == TransactionRecord::RecvWithAddress || sub.type == TransactionRecord::RecvFromOther)) + { + sub.type = TransactionRecord::DustReceive; + } + parts.append(sub); } } diff --git a/src/qt/transactionrecord.h b/src/qt/transactionrecord.h index 1c1a5bcdfdf4..6180d8967b6d 100644 --- a/src/qt/transactionrecord.h +++ b/src/qt/transactionrecord.h @@ -86,6 +86,7 @@ class TransactionRecord CoinJoinCreateDenominations, CoinJoinSend, PlatformTransfer, + DustReceive, }; /** Number of confirmation recommended for accepting a transaction */ @@ -116,7 +117,8 @@ class TransactionRecord /** Decompose CWallet transaction to model transaction records. */ static bool showTransaction(); - static QList decomposeTransaction(interfaces::Node& node, interfaces::Wallet& wallet, const interfaces::WalletTx& wtx); + static QList decomposeTransaction(interfaces::Node& node, interfaces::Wallet& wallet, const interfaces::WalletTx& wtx, + bool dustProtectionEnabled = false, CAmount dustThreshold = 0); /** @name Immutable transaction attributes @{*/ diff --git a/src/qt/transactiontablemodel.cpp b/src/qt/transactiontablemodel.cpp index 296a9e88622d..f8fefcf4a8b9 100644 --- a/src/qt/transactiontablemodel.cpp +++ b/src/qt/transactiontablemodel.cpp @@ -113,9 +113,11 @@ class TransactionTablePriv assert(!m_loaded || force); cachedWallet.clear(); try { + bool dustProtection = parent->walletModel->getOptionsModel()->getDustProtection(); + qint64 dustThreshold = parent->walletModel->getOptionsModel()->getDustProtectionThreshold(); for (const auto& wtx : wallet.getWalletTxs()) { if (TransactionRecord::showTransaction()) { - cachedWallet.append(TransactionRecord::decomposeTransaction(parent->walletModel->node(), wallet, wtx)); + cachedWallet.append(TransactionRecord::decomposeTransaction(parent->walletModel->node(), wallet, wtx, dustProtection, dustThreshold)); } } } catch(const std::exception& e) { @@ -174,8 +176,10 @@ class TransactionTablePriv break; } // Added -- insert at the right position + bool dustProtection = parent->walletModel->getOptionsModel()->getDustProtection(); + qint64 dustThreshold = parent->walletModel->getOptionsModel()->getDustProtectionThreshold(); QList toInsert = - TransactionRecord::decomposeTransaction(parent->walletModel->node(), wallet, wtx); + TransactionRecord::decomposeTransaction(parent->walletModel->node(), wallet, wtx, dustProtection, dustThreshold); if(!toInsert.isEmpty()) /* only if something to insert */ { parent->beginInsertRows(QModelIndex(), lowerIndex, lowerIndex+toInsert.size()-1); @@ -278,6 +282,8 @@ TransactionTableModel::TransactionTableModel(WalletModel *parent): priv->refreshWallet(walletModel->wallet()); connect(walletModel->getOptionsModel(), &OptionsModel::displayUnitChanged, this, &TransactionTableModel::updateDisplayUnit); + // Refresh wallet when dust protection settings change to re-evaluate transaction types + connect(walletModel->getOptionsModel(), &OptionsModel::dustProtectionChanged, this, [this]() { refreshWallet(true); }); } TransactionTableModel::~TransactionTableModel() @@ -429,6 +435,8 @@ QString TransactionTableModel::formatTxType(const TransactionRecord *wtx) const return tr("Mined"); case TransactionRecord::PlatformTransfer: return tr("Platform Transfer"); + case TransactionRecord::DustReceive: + return tr("Dust Receive"); case TransactionRecord::CoinJoinMixing: return tr("%1 Mixing").arg(QString::fromStdString(gCoinJoinName)); @@ -473,6 +481,7 @@ QString TransactionTableModel::formatTxToAddress(const TransactionRecord *wtx, b case TransactionRecord::Generated: case TransactionRecord::CoinJoinSend: case TransactionRecord::PlatformTransfer: + case TransactionRecord::DustReceive: return formatAddressLabel(wtx->strAddress, wtx->label, tooltip) + watchAddress; case TransactionRecord::SendToOther: return QString::fromStdString(wtx->strAddress) + watchAddress; @@ -499,6 +508,7 @@ QVariant TransactionTableModel::addressColor(const TransactionRecord *wtx) const case TransactionRecord::PlatformTransfer: case TransactionRecord::CoinJoinSend: case TransactionRecord::RecvWithCoinJoin: + case TransactionRecord::DustReceive: { if (wtx->label.isEmpty()) { return GUIUtil::getThemedQColor(GUIUtil::ThemedColor::BAREADDRESS); @@ -550,6 +560,7 @@ QVariant TransactionTableModel::amountColor(const TransactionRecord *rec) const case TransactionRecord::CoinJoinCollateralPayment: case TransactionRecord::CoinJoinMakeCollaterals: case TransactionRecord::CoinJoinCreateDenominations: + case TransactionRecord::DustReceive: return GUIUtil::getThemedQColor(GUIUtil::ThemedColor::ORANGE); } return GUIUtil::getThemedQColor(GUIUtil::ThemedColor::DEFAULT); @@ -737,6 +748,8 @@ QVariant TransactionTableModel::data(const QModelIndex &index, int role) const return formatTxAmount(rec, false, BitcoinUnits::SeparatorStyle::NEVER); case StatusRole: return rec->status.status; + case OutputIndexRole: + return rec->idx; } return QVariant(); } diff --git a/src/qt/transactiontablemodel.h b/src/qt/transactiontablemodel.h index 630fec039f73..fdd06de91833 100644 --- a/src/qt/transactiontablemodel.h +++ b/src/qt/transactiontablemodel.h @@ -75,6 +75,8 @@ class TransactionTableModel : public QAbstractTableModel StatusRole, /** Unprocessed icon */ RawDecorationRole, + /** Output index within transaction (for UTXO identification) */ + OutputIndexRole, }; int rowCount(const QModelIndex &parent) const override; diff --git a/src/qt/transactionview.cpp b/src/qt/transactionview.cpp index b8054e4522f4..2c3a240f7a0c 100644 --- a/src/qt/transactionview.cpp +++ b/src/qt/transactionview.cpp @@ -19,6 +19,7 @@ #include #include +#include #include #include @@ -95,6 +96,7 @@ TransactionView::TransactionView(QWidget* parent) : typeWidget->addItem(tr("To yourself"), TransactionFilterProxy::TYPE(TransactionRecord::SendToSelf)); typeWidget->addItem(tr("Mined"), TransactionFilterProxy::TYPE(TransactionRecord::Generated)); typeWidget->addItem(tr("Platform Transfer"), TransactionFilterProxy::TYPE(TransactionRecord::PlatformTransfer)); + typeWidget->addItem(tr("Dust Receive"), TransactionFilterProxy::TYPE(TransactionRecord::DustReceive)); typeWidget->addItem(tr("Other"), TransactionFilterProxy::TYPE(TransactionRecord::Other)); typeWidget->setCurrentIndex(settings.value("transactionType").toInt()); @@ -162,6 +164,7 @@ TransactionView::TransactionView(QWidget* parent) : contextMenu->addSeparator(); abandonAction = contextMenu->addAction(tr("A&bandon transaction"), this, &TransactionView::abandonTx); resendAction = contextMenu->addAction(tr("Rese&nd transaction"), this, &TransactionView::resendTx); + unlockDustAction = contextMenu->addAction(tr("&Unlock dust UTXO"), this, &TransactionView::unlockDust); contextMenu->addAction(tr("&Edit address label"), this, &TransactionView::editLabel); [[maybe_unused]] QAction* showAddressQRCodeAction = contextMenu->addAction(tr("Show address &QR code"), this, &TransactionView::showAddressQRCode); #ifndef USE_QRCODE @@ -419,6 +422,10 @@ void TransactionView::contextualMenu(const QPoint &point) copyAddressAction->setEnabled(GUIUtil::hasEntryData(transactionView, 0, TransactionTableModel::AddressRole)); copyLabelAction->setEnabled(GUIUtil::hasEntryData(transactionView, 0, TransactionTableModel::LabelRole)); + // Show unlock dust action only for dust receive transactions + int txType = selection.at(0).data(TransactionTableModel::TypeRole).toInt(); + unlockDustAction->setVisible(txType == TransactionRecord::DustReceive); + if (index.isValid()) { GUIUtil::PopupMenu(contextMenu, transactionView->viewport()->mapToGlobal(point)); } @@ -455,6 +462,39 @@ void TransactionView::resendTx() model->wallet().resendTransaction(hash); } +void TransactionView::unlockDust() +{ + if(!transactionView || !transactionView->selectionModel() || !model) { + return; + } + QModelIndexList selection = transactionView->selectionModel()->selectedRows(0); + if (selection.isEmpty()) { + return; + } + + // Get the transaction hash + QVariant hashVar = selection.at(0).data(TransactionTableModel::TxHashRole); + if (!hashVar.isValid()) { + return; + } + uint256 hash; + hash.SetHex(hashVar.toString().toStdString()); + + // Get the output index + QVariant idxVar = selection.at(0).data(TransactionTableModel::OutputIndexRole); + if (!idxVar.isValid()) { + return; + } + int outputIdx = idxVar.toInt(); + + // Create the outpoint and unlock + COutPoint outpoint(hash, outputIdx); + model->wallet().unlockCoin(outpoint); + + // Refresh the transaction view to update the display + model->getTransactionTableModel()->refreshWallet(true); +} + void TransactionView::copyAddress() { GUIUtil::copyEntryData(transactionView, 0, TransactionTableModel::AddressRole); diff --git a/src/qt/transactionview.h b/src/qt/transactionview.h index 0e43845ef6a9..bc76f2dbe869 100644 --- a/src/qt/transactionview.h +++ b/src/qt/transactionview.h @@ -77,6 +77,7 @@ class TransactionView : public QWidget QAction *resendAction; QAction *copyAddressAction{nullptr}; QAction *copyLabelAction{nullptr}; + QAction *unlockDustAction{nullptr}; QWidget *createDateRangeWidget(); void updateCalendarWidgets(); @@ -102,6 +103,7 @@ private Q_SLOTS: void updateCoinJoinVisibility(); void abandonTx(); void resendTx(); + void unlockDust(); Q_SIGNALS: void doubleClicked(const QModelIndex&); diff --git a/src/qt/walletmodel.cpp b/src/qt/walletmodel.cpp index 0a9a196cfc04..d1a9bf0e260a 100644 --- a/src/qt/walletmodel.cpp +++ b/src/qt/walletmodel.cpp @@ -23,6 +23,7 @@ #include #include #include +#include #include #include // for GetBoolArg #include @@ -64,6 +65,13 @@ WalletModel::WalletModel(std::unique_ptr wallet, ClientModel recentRequestsTableModel = new RecentRequestsTableModel(this); subscribeToCoreSignals(); + + // Connect dust protection settings change to lock existing dust + if (optionsModel) { + connect(optionsModel, &OptionsModel::dustProtectionChanged, this, &WalletModel::lockExistingDustOutputs); + // Lock existing dust on startup if dust protection is enabled + lockExistingDustOutputs(); + } } WalletModel::~WalletModel() @@ -149,6 +157,94 @@ 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; + } + + // 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; + + // 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); + } + } +} + void WalletModel::updateNumISLocks() { cachedNumISLocks++; @@ -456,10 +552,16 @@ static void NotifyAddressBookChanged(WalletModel *walletmodel, static void NotifyTransactionChanged(WalletModel *walletmodel, const uint256 &hash, ChangeType status) { - Q_UNUSED(hash); - Q_UNUSED(status); 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 b33e74a43ada..1113f9b5e33d 100644 --- a/src/qt/walletmodel.h +++ b/src/qt/walletmodel.h @@ -241,6 +241,10 @@ 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 */ void updateNumISLocks(); /* ChainLock received */