diff --git a/src/interfaces/wallet.h b/src/interfaces/wallet.h index f26ac866dcf..572c9eeeff5 100644 --- a/src/interfaces/wallet.h +++ b/src/interfaces/wallet.h @@ -125,6 +125,9 @@ class Wallet //! Display address on external signer virtual bool displayAddress(const CTxDestination& dest) = 0; + virtual bool checkAddressForUsage(const std::vector& addresses) const = 0; + virtual bool findAddressUsage(const std::vector& addresses, std::function callback) const = 0; + //! Lock coin. virtual bool lockCoin(const COutPoint& output, const bool write_to_db) = 0; diff --git a/src/qt/guiconstants.h b/src/qt/guiconstants.h index fcdf6056c95..329601ed974 100644 --- a/src/qt/guiconstants.h +++ b/src/qt/guiconstants.h @@ -26,6 +26,8 @@ static const bool DEFAULT_SPLASHSCREEN = true; /* Invalid field background style */ #define STYLE_INVALID "background:#FF8080" +/* "Warning" field background style */ +#define STYLE_INCORRECT "background:#FFFF80" /* Transaction list -- unconfirmed transaction */ #define COLOR_UNCONFIRMED QColor(128, 128, 128) diff --git a/src/qt/guiutil.cpp b/src/qt/guiutil.cpp index 9565fa508f1..41b5ba2323d 100644 --- a/src/qt/guiutil.cpp +++ b/src/qt/guiutil.cpp @@ -81,9 +81,19 @@ void ForceActivation(); namespace GUIUtil { +QString dateStr(const QDate &date) +{ + return QLocale::system().toString(date, QLocale::ShortFormat); +} + +QString dateStr(qint64 nTime) +{ + return dateStr(QDateTime::fromSecsSinceEpoch(nTime).date()); +} + QString dateTimeStr(const QDateTime &date) { - return QLocale::system().toString(date.date(), QLocale::ShortFormat) + QString(" ") + date.toString("hh:mm"); + return dateStr(date.date()) + QString(" ") + date.toString("hh:mm"); } QString dateTimeStr(qint64 nTime) diff --git a/src/qt/guiutil.h b/src/qt/guiutil.h index 0224b18b4ee..69258972413 100644 --- a/src/qt/guiutil.h +++ b/src/qt/guiutil.h @@ -60,6 +60,8 @@ namespace GUIUtil constexpr auto dialog_flags = Qt::WindowTitleHint | Qt::WindowSystemMenuHint | Qt::WindowCloseButtonHint; // Create human-readable string from date + QString dateStr(const QDate &datetime); + QString dateStr(qint64 nTime); QString dateTimeStr(const QDateTime &datetime); QString dateTimeStr(qint64 nTime); diff --git a/src/qt/qvalidatedlineedit.cpp b/src/qt/qvalidatedlineedit.cpp index bd4df75d23f..57b8265e8e7 100644 --- a/src/qt/qvalidatedlineedit.cpp +++ b/src/qt/qvalidatedlineedit.cpp @@ -15,22 +15,34 @@ QValidatedLineEdit::QValidatedLineEdit(QWidget *parent) : connect(this, &QValidatedLineEdit::textChanged, this, &QValidatedLineEdit::markValid); } +QValidatedLineEdit::~QValidatedLineEdit() +{ + delete m_warning_validator; +} + void QValidatedLineEdit::setText(const QString& text) { QLineEdit::setText(text); checkValidity(); } -void QValidatedLineEdit::setValid(bool _valid) +void QValidatedLineEdit::setValid(bool _valid, bool with_warning) { if(_valid == this->valid) { - return; + if (with_warning == m_has_warning || !valid) { + return; + } } if(_valid) { - setStyleSheet(""); + m_has_warning = with_warning; + if (with_warning) { + setStyleSheet("QValidatedLineEdit { " STYLE_INCORRECT "}"); + } else { + setStyleSheet(""); + } } else { @@ -84,13 +96,14 @@ void QValidatedLineEdit::setEnabled(bool enabled) void QValidatedLineEdit::checkValidity() { + const bool has_warning = checkWarning(); if (text().isEmpty()) { setValid(true); } else if (hasAcceptableInput()) { - setValid(true); + setValid(true, has_warning); // Check contents on focus out if (checkValidator) @@ -98,7 +111,7 @@ void QValidatedLineEdit::checkValidity() QString address = text(); int pos = 0; if (checkValidator->validate(address, pos) == QValidator::Acceptable) - setValid(true); + setValid(true, has_warning); else setValid(false); } @@ -128,3 +141,28 @@ bool QValidatedLineEdit::isValid() return valid; } + +void QValidatedLineEdit::setWarningValidator(const QValidator *v) +{ + delete m_warning_validator; + m_warning_validator = v; + checkValidity(); +} + +bool QValidatedLineEdit::checkWarning() const +{ + if (m_warning_validator && !text().isEmpty()) { + QString address = text(); + int pos = 0; + if (m_warning_validator->validate(address, pos) != QValidator::Acceptable) { + return true; + } + } + + return false; +} + +bool QValidatedLineEdit::hasWarning() const +{ + return m_has_warning; +} diff --git a/src/qt/qvalidatedlineedit.h b/src/qt/qvalidatedlineedit.h index 12d35aa2645..a9154a10629 100644 --- a/src/qt/qvalidatedlineedit.h +++ b/src/qt/qvalidatedlineedit.h @@ -16,9 +16,12 @@ class QValidatedLineEdit : public QLineEdit public: explicit QValidatedLineEdit(QWidget *parent); + ~QValidatedLineEdit(); void clear(); void setCheckValidator(const QValidator *v); bool isValid(); + void setWarningValidator(const QValidator *); + bool hasWarning() const; protected: void focusInEvent(QFocusEvent *evt) override; @@ -27,10 +30,12 @@ class QValidatedLineEdit : public QLineEdit private: bool valid; const QValidator *checkValidator; + bool m_has_warning{false}; + const QValidator *m_warning_validator{nullptr}; public Q_SLOTS: void setText(const QString&); - void setValid(bool valid); + void setValid(bool valid, bool with_warning=false); void setEnabled(bool enabled); Q_SIGNALS: @@ -39,6 +44,7 @@ public Q_SLOTS: private Q_SLOTS: void markValid(); void checkValidity(); + bool checkWarning() const; }; #endif // BITCOIN_QT_QVALIDATEDLINEEDIT_H diff --git a/src/qt/sendcoinsdialog.cpp b/src/qt/sendcoinsdialog.cpp index 579ef0c3fd5..c0273980658 100644 --- a/src/qt/sendcoinsdialog.cpp +++ b/src/qt/sendcoinsdialog.cpp @@ -410,9 +410,80 @@ void SendCoinsDialog::sendButtonClicked([[maybe_unused]] bool checked) if (!PrepareSendText(question_string, informative_text, detailed_text)) return; assert(m_current_transaction); + bool have_warning = false; + for (int i = 0; i < ui->entries->count(); ++i) { + SendCoinsEntry *entry = qobject_cast(ui->entries->itemAt(i)->widget()); + if (entry && entry->hasPaytoWarning()) { + have_warning = true; + break; + } + } + if (have_warning) { + auto recipients = m_current_transaction->getRecipients(); + struct prior_usage_info_t { + CAmount total_amount{0}; + int num_txs{0}; + qint64 tx_time_oldest; + qint64 tx_time_newest; + }; + QMap prior_usage_info; + { + QStringList addresses; + for (const auto& recipient : recipients) { + addresses.append(recipient.address); + } + model->findAddressUsage(addresses, [&prior_usage_info](const QString& address, const interfaces::WalletTx& wtx, uint32_t output_index){ + auto& info = prior_usage_info[address]; + info.total_amount += wtx.tx->vout[output_index].nValue; + ++info.num_txs; + if (info.num_txs == 1 || wtx.time < info.tx_time_oldest) { + info.tx_time_oldest = wtx.time; + } + if (info.num_txs == 1 || wtx.time > info.tx_time_newest) { + info.tx_time_newest = wtx.time; + } + }); + } + + QString reuse_question, reuse_details; + if (recipients.size() > 1) { + reuse_question = tr("You've already paid some of these addresses."); + } else { + reuse_question = tr("You've already paid this address."); + } + + for (const auto& rcp : recipients) { + if (!prior_usage_info.contains(rcp.address)) continue; + if (!reuse_details.isEmpty()) reuse_details.append("\n\n"); + const auto& rcp_prior_usage_info = prior_usage_info.value(rcp.address); + const QString label_and_address = rcp.label.isEmpty() ? rcp.address : (QString("'") + rcp.label + "' (" + rcp.address + ")"); + if (rcp_prior_usage_info.num_txs == 1) { + //: %1 is an amount (eg, "1 BTC"); %2 is a Bitcoin address and its label; %3 is a date (eg, "2019-05-08") + reuse_details.append(tr("Sent %1 to %2 on %3").arg(BitcoinUnits::formatWithUnit(model->getOptionsModel()->getDisplayUnit(), rcp_prior_usage_info.total_amount), label_and_address, GUIUtil::dateStr(rcp_prior_usage_info.tx_time_newest))); + } else { + //: %1 is an amount (eg, "1 BTC"); %2 is a Bitcoin address and its label; %3 is the number of transactions; %4 and %5 are dates (eg, "2019-05-08"), earlier first + reuse_details.append(tr("Sent %1 to %2 across %3 transactions from %4 through %5").arg(BitcoinUnits::formatWithUnit(model->getOptionsModel()->getDisplayUnit(), rcp_prior_usage_info.total_amount), label_and_address, QString::number(rcp_prior_usage_info.num_txs), GUIUtil::dateStr(rcp_prior_usage_info.tx_time_oldest), GUIUtil::dateStr(rcp_prior_usage_info.tx_time_newest))); + } + } + + reuse_question.append("

"); + reuse_question.append(tr("Bitcoin addresses are intended to only be used once, for a single payment. Sending to the same address again will harm the recipient's security, as well as the privacy of all Bitcoin users!")); + reuse_question.append(""); + + SendConfirmationDialog confirmation_dialog(tr("Already paid"), reuse_question, "", reuse_details, ADDRESS_REUSE_OVERRIDE_DELAY, /*enable_send=*/true, /*always_show_unsigned=*/false, this); + confirmation_dialog.setIcon(QMessageBox::Warning); + confirmation_dialog.confirmButtonText = tr("Override"); + confirmation_dialog.m_yes_button = QMessageBox::Ignore; + confirmation_dialog.m_cancel_button = QMessageBox::Ok; + if (static_cast(confirmation_dialog.exec()) == QMessageBox::Cancel) { + fNewRecipientAllowed = true; + return; + } + } + const QString confirmation = tr("Confirm send coins"); auto confirmationDialog = new SendConfirmationDialog(confirmation, question_string, informative_text, detailed_text, SEND_CONFIRM_DELAY, !model->wallet().privateKeysDisabled(), model->getOptionsModel()->getEnablePSBTControls(), this); - confirmationDialog->setAttribute(Qt::WA_DeleteOnClose); + confirmationDialog->m_delete_on_close = true; // TODO: Replace QDialog::exec() with safer QDialog::show(). const auto retval = static_cast(confirmationDialog->exec()); @@ -1045,30 +1116,61 @@ void SendCoinsDialog::coinControlUpdateLabels() } SendConfirmationDialog::SendConfirmationDialog(const QString& title, const QString& text, const QString& informative_text, const QString& detailed_text, int _secDelay, bool enable_send, bool always_show_unsigned, QWidget* parent) - : QMessageBox(parent), secDelay(_secDelay), m_enable_send(enable_send) + : QMessageBox(parent), secDelay(_secDelay), m_enable_save(always_show_unsigned || !enable_send), m_enable_send(enable_send) { setIcon(QMessageBox::Question); setWindowTitle(title); // On macOS, the window title is ignored (as required by the macOS Guidelines). setText(text); setInformativeText(informative_text); setDetailedText(detailed_text); - setStandardButtons(QMessageBox::Yes | QMessageBox::Cancel); - if (always_show_unsigned || !enable_send) addButton(QMessageBox::Save); - setDefaultButton(QMessageBox::Cancel); - yesButton = button(QMessageBox::Yes); +} + +int SendConfirmationDialog::exec() +{ + setStandardButtons(m_yes_button | m_cancel_button); + + yesButton = button(m_yes_button); + QAbstractButton * const cancel_button_obj = button(m_cancel_button); + + if (m_yes_button != QMessageBox::Yes || m_cancel_button != QMessageBox::Cancel) { + // We need to ensure the buttons have Yes/No roles, or they'll get ordered weird + // But only do it for customised yes/cancel buttons, so simple code can check results simply too + removeButton(cancel_button_obj); + addButton(cancel_button_obj, QMessageBox::NoRole); + setEscapeButton(cancel_button_obj); + + removeButton(yesButton); + addButton(yesButton, QMessageBox::YesRole); + } + + if (m_enable_save) addButton(QMessageBox::Save); + + setDefaultButton(m_cancel_button); + if (confirmButtonText.isEmpty()) { confirmButtonText = yesButton->text(); } m_psbt_button = button(QMessageBox::Save); updateButtons(); - connect(&countDownTimer, &QTimer::timeout, this, &SendConfirmationDialog::countDown); -} -int SendConfirmationDialog::exec() -{ - updateButtons(); + connect(&countDownTimer, &QTimer::timeout, this, &SendConfirmationDialog::countDown); countDownTimer.start(1s); - return QMessageBox::exec(); + + QMessageBox::exec(); + + int rv; + const auto clicked_button = clickedButton(); + if (clicked_button == m_psbt_button) { + rv = QMessageBox::Save; + } else if (clicked_button == yesButton) { + rv = QMessageBox::Yes; + } else { + rv = QMessageBox::Cancel; + } + + if (m_delete_on_close) delete this; + + return rv; } void SendConfirmationDialog::countDown() diff --git a/src/qt/sendcoinsdialog.h b/src/qt/sendcoinsdialog.h index 4a167027560..d20c0ef149d 100644 --- a/src/qt/sendcoinsdialog.h +++ b/src/qt/sendcoinsdialog.h @@ -110,12 +110,18 @@ private Q_SLOTS: #define SEND_CONFIRM_DELAY 3 +#define ADDRESS_REUSE_OVERRIDE_DELAY 10 class SendConfirmationDialog : public QMessageBox { Q_OBJECT public: + bool m_delete_on_close{false}; + QString confirmButtonText{tr("Send")}; + QMessageBox::StandardButton m_yes_button{QMessageBox::Yes}; + QMessageBox::StandardButton m_cancel_button{QMessageBox::Cancel}; + SendConfirmationDialog(const QString& title, const QString& text, const QString& informative_text = "", const QString& detailed_text = "", int secDelay = SEND_CONFIRM_DELAY, bool enable_send = true, bool always_show_unsigned = true, QWidget* parent = nullptr); int exec() override; @@ -128,7 +134,7 @@ private Q_SLOTS: QAbstractButton *m_psbt_button; QTimer countDownTimer; int secDelay; - QString confirmButtonText{tr("Send")}; + bool m_enable_save; bool m_enable_send; QString m_psbt_button_text{tr("Create Unsigned")}; }; diff --git a/src/qt/sendcoinsentry.cpp b/src/qt/sendcoinsentry.cpp index 339ac580d80..6f156f4be6d 100644 --- a/src/qt/sendcoinsentry.cpp +++ b/src/qt/sendcoinsentry.cpp @@ -85,6 +85,12 @@ void SendCoinsEntry::setModel(WalletModel *_model) { this->model = _model; + if (_model) { + ui->payTo->setWarningValidator(new BitcoinAddressUnusedInWalletValidator(*_model)); + } else { + ui->payTo->setWarningValidator(nullptr); + } + if (_model && _model->getOptionsModel()) connect(_model->getOptionsModel(), &OptionsModel::displayUnitChanged, this, &SendCoinsEntry::updateDisplayUnit); @@ -166,6 +172,11 @@ bool SendCoinsEntry::validate(interfaces::Node& node) return retval; } +bool SendCoinsEntry::hasPaytoWarning() const +{ + return ui->payTo->hasWarning(); +} + SendCoinsRecipient SendCoinsEntry::getValue() { recipient.address = ui->payTo->text(); diff --git a/src/qt/sendcoinsentry.h b/src/qt/sendcoinsentry.h index e8db1e3a5ce..603ae0c4eeb 100644 --- a/src/qt/sendcoinsentry.h +++ b/src/qt/sendcoinsentry.h @@ -35,6 +35,7 @@ class SendCoinsEntry : public QStackedWidget void setModel(WalletModel *model); bool validate(interfaces::Node& node); + bool hasPaytoWarning() const; SendCoinsRecipient getValue(); /** Return whether the entry is still empty and unedited */ diff --git a/src/qt/test/wallettests.cpp b/src/qt/test/wallettests.cpp index 6ab534764b6..dc7db521486 100644 --- a/src/qt/test/wallettests.cpp +++ b/src/qt/test/wallettests.cpp @@ -51,19 +51,37 @@ using wallet::WalletRescanReserver; namespace { -//! Press "Yes" or "Cancel" buttons in modal send confirmation dialog. -void ConfirmSend(QString* text = nullptr, bool cancel = false) +void ConfirmSendAttempt(QString* text, bool cancel) { - QTimer::singleShot(0, [text, cancel]() { for (QWidget* widget : QApplication::topLevelWidgets()) { if (widget->inherits("SendConfirmationDialog")) { SendConfirmationDialog* dialog = qobject_cast(widget); if (text) *text = dialog->text(); - QAbstractButton* button = dialog->button(cancel ? QMessageBox::Cancel : QMessageBox::Yes); + QAbstractButton* button = nullptr; + for (QAbstractButton* maybe_button : dialog->buttons()) { + const bool is_yes_button = (dialog->buttonRole(maybe_button) == QMessageBox::YesRole); + if (is_yes_button != cancel) { + button = maybe_button; + break; + } + } button->setEnabled(true); button->click(); + if (!button->text().startsWith("Override")) return; } } + + // Try again + QTimer::singleShot(0, [text, cancel]{ + ConfirmSendAttempt(text, cancel); + }); +} + +//! Press "Yes" or "Cancel" buttons in modal send confirmation dialog. +void ConfirmSend(QString* text = nullptr, bool cancel = false) +{ + QTimer::singleShot(0, [text, cancel]{ + ConfirmSendAttempt(text, cancel); }); } diff --git a/src/qt/walletmodel.cpp b/src/qt/walletmodel.cpp index 5ee32e79d5d..a636b46bd79 100644 --- a/src/qt/walletmodel.cpp +++ b/src/qt/walletmodel.cpp @@ -150,6 +150,22 @@ bool WalletModel::validateAddress(const QString &address) return IsValidDestinationString(address.toStdString()); } +bool WalletModel::checkAddressForUsage(const std::vector& addresses) const +{ + return m_wallet->checkAddressForUsage(addresses); +} + +bool WalletModel::findAddressUsage(const QStringList& addresses, std::function callback) const +{ + std::vector std_addresses; + for (const auto& address : addresses) { + std_addresses.push_back(address.toStdString()); + } + return m_wallet->findAddressUsage(std_addresses, [&callback](const std::string& address, const interfaces::WalletTx& wtx, uint32_t output_index){ + callback(QString::fromStdString(address), wtx, output_index); + }); +} + WalletModel::SendCoinsReturn WalletModel::prepareTransaction(WalletModelTransaction &transaction, const CCoinControl& coinControl) { CAmount total = 0; @@ -509,7 +525,7 @@ bool WalletModel::bumpFee(uint256 hash, uint256& new_hash) } auto confirmationDialog = new SendConfirmationDialog(tr("Confirm fee bump"), questionString, "", "", SEND_CONFIRM_DELAY, !m_wallet->privateKeysDisabled(), getOptionsModel()->getEnablePSBTControls(), nullptr); - confirmationDialog->setAttribute(Qt::WA_DeleteOnClose); + confirmationDialog->m_delete_on_close = true; // TODO: Replace QDialog::exec() with safer QDialog::show(). const auto retval = static_cast(confirmationDialog->exec()); @@ -599,3 +615,18 @@ uint256 WalletModel::getLastBlockProcessed() const { return m_client_model ? m_client_model->getBestBlockHash() : uint256{}; } + +BitcoinAddressUnusedInWalletValidator::BitcoinAddressUnusedInWalletValidator(const WalletModel& wallet_model, QObject *parent) : + QValidator(parent), + m_wallet_model(wallet_model) +{ +} + +QValidator::State BitcoinAddressUnusedInWalletValidator::validate(QString &input, int &pos) const +{ + Q_UNUSED(pos); + if (m_wallet_model.checkAddressForUsage(std::vector{input.toStdString()})) { + return QValidator::Invalid; + } + return QValidator::Acceptable; +} diff --git a/src/qt/walletmodel.h b/src/qt/walletmodel.h index ad1239ccdcd..8c16aa166db 100644 --- a/src/qt/walletmodel.h +++ b/src/qt/walletmodel.h @@ -10,6 +10,7 @@ #endif #include +#include #include