diff --git a/build_msvc/libbitcoin_qt/libbitcoin_qt.vcxproj b/build_msvc/libbitcoin_qt/libbitcoin_qt.vcxproj index 9f9dc9d5fa0..db448c23c4f 100644 --- a/build_msvc/libbitcoin_qt/libbitcoin_qt.vcxproj +++ b/build_msvc/libbitcoin_qt/libbitcoin_qt.vcxproj @@ -22,6 +22,7 @@ + @@ -78,6 +79,7 @@ + diff --git a/src/Makefile.qt.include b/src/Makefile.qt.include index 1ac6b746889..81b1c738dac 100644 --- a/src/Makefile.qt.include +++ b/src/Makefile.qt.include @@ -18,6 +18,7 @@ QT_FORMS_UI = \ qt/forms/askpassphrasedialog.ui \ qt/forms/coincontroldialog.ui \ qt/forms/createwalletdialog.ui \ + qt/forms/deniabilitydialog.ui \ qt/forms/editaddressdialog.ui \ qt/forms/helpmessagedialog.ui \ qt/forms/intro.ui \ @@ -49,6 +50,7 @@ QT_MOC_CPP = \ qt/moc_coincontroldialog.cpp \ qt/moc_coincontroltreewidget.cpp \ qt/moc_csvmodelwriter.cpp \ + qt/moc_deniabilitydialog.cpp \ qt/moc_editaddressdialog.cpp \ qt/moc_guiutil.cpp \ qt/moc_initexecutor.cpp \ @@ -120,6 +122,7 @@ BITCOIN_QT_H = \ qt/coincontroltreewidget.h \ qt/createwalletdialog.h \ qt/csvmodelwriter.h \ + qt/deniabilitydialog.h \ qt/editaddressdialog.h \ qt/guiconstants.h \ qt/guiutil.h \ @@ -188,6 +191,7 @@ QT_RES_ICONS = \ qt/res/icons/connect2.png \ qt/res/icons/connect3.png \ qt/res/icons/connect4.png \ + qt/res/icons/crosseye.png \ qt/res/icons/edit.png \ qt/res/icons/editcopy.png \ qt/res/icons/editpaste.png \ @@ -255,6 +259,7 @@ BITCOIN_QT_WALLET_CPP = \ qt/coincontroldialog.cpp \ qt/coincontroltreewidget.cpp \ qt/createwalletdialog.cpp \ + qt/deniabilitydialog.cpp \ qt/editaddressdialog.cpp \ qt/openuridialog.cpp \ qt/overviewpage.cpp \ diff --git a/src/qt/bitcoin.qrc b/src/qt/bitcoin.qrc index fed373e551c..24584c50ceb 100644 --- a/src/qt/bitcoin.qrc +++ b/src/qt/bitcoin.qrc @@ -44,6 +44,7 @@ res/icons/hd_disabled.png res/icons/network_disabled.png res/icons/proxy.png + res/icons/crosseye.png res/animation/spinner-000.png diff --git a/src/qt/bitcoingui.cpp b/src/qt/bitcoingui.cpp index 1f78550a559..b42bbfeba17 100644 --- a/src/qt/bitcoingui.cpp +++ b/src/qt/bitcoingui.cpp @@ -277,6 +277,13 @@ void BitcoinGUI::createActions() historyAction->setShortcut(QKeySequence(QStringLiteral("Alt+4"))); tabGroup->addAction(historyAction); + deniabilityAction = new QAction(platformStyle->SingleColorIcon(":/icons/crosseye"), tr("&Deniability"), this); + deniabilityAction->setStatusTip(tr("Improve coin ownership privacy")); + deniabilityAction->setToolTip(deniabilityAction->statusTip()); + deniabilityAction->setCheckable(true); + deniabilityAction->setShortcut(QKeySequence(QStringLiteral("Alt+5"))); + tabGroup->addAction(deniabilityAction); + #ifdef ENABLE_WALLET // These showNormalIfMinimized are needed because Send Coins and Receive Coins // can be triggered from the tray menu, and need to show the GUI to be useful. @@ -288,6 +295,8 @@ void BitcoinGUI::createActions() connect(receiveCoinsAction, &QAction::triggered, this, &BitcoinGUI::gotoReceiveCoinsPage); connect(historyAction, &QAction::triggered, [this]{ showNormalIfMinimized(); }); connect(historyAction, &QAction::triggered, this, &BitcoinGUI::gotoHistoryPage); + connect(deniabilityAction, &QAction::triggered, [this] { showNormalIfMinimized(); }); + connect(deniabilityAction, &QAction::triggered, this, &BitcoinGUI::gotoDeniabilityPage); #endif // ENABLE_WALLET quitAction = new QAction(tr("E&xit"), this); @@ -575,6 +584,7 @@ void BitcoinGUI::createToolBars() toolbar->addAction(sendCoinsAction); toolbar->addAction(receiveCoinsAction); toolbar->addAction(historyAction); + toolbar->addAction(deniabilityAction); overviewAction->setChecked(true); #ifdef ENABLE_WALLET @@ -795,6 +805,7 @@ void BitcoinGUI::setWalletActionsEnabled(bool enabled) sendCoinsAction->setEnabled(enabled); receiveCoinsAction->setEnabled(enabled); historyAction->setEnabled(enabled); + deniabilityAction->setEnabled(enabled); encryptWalletAction->setEnabled(enabled); backupWalletAction->setEnabled(enabled); changePassphraseAction->setEnabled(enabled); @@ -957,6 +968,12 @@ void BitcoinGUI::gotoHistoryPage() if (walletFrame) walletFrame->gotoHistoryPage(); } +void BitcoinGUI::gotoDeniabilityPage() +{ + deniabilityAction->setChecked(true); + if (walletFrame) walletFrame->gotoDeniabilityPage(); +} + void BitcoinGUI::gotoReceiveCoinsPage() { receiveCoinsAction->setChecked(true); @@ -1272,6 +1289,7 @@ void BitcoinGUI::changeEvent(QEvent *e) sendCoinsAction->setIcon(platformStyle->SingleColorIcon(QStringLiteral(":/icons/send"))); receiveCoinsAction->setIcon(platformStyle->SingleColorIcon(QStringLiteral(":/icons/receiving_addresses"))); historyAction->setIcon(platformStyle->SingleColorIcon(QStringLiteral(":/icons/history"))); + deniabilityAction->setIcon(platformStyle->SingleColorIcon(QStringLiteral(":/icons/crosseye"))); } QMainWindow::changeEvent(e); diff --git a/src/qt/bitcoingui.h b/src/qt/bitcoingui.h index 73adbda5a5f..da7a65ced59 100644 --- a/src/qt/bitcoingui.h +++ b/src/qt/bitcoingui.h @@ -134,6 +134,7 @@ class BitcoinGUI : public QMainWindow QToolBar* appToolBar = nullptr; QAction* overviewAction = nullptr; QAction* historyAction = nullptr; + QAction* deniabilityAction = nullptr; QAction* quitAction = nullptr; QAction* sendCoinsAction = nullptr; QAction* usedSendingAddressesAction = nullptr; @@ -279,6 +280,8 @@ public Q_SLOTS: void gotoOverviewPage(); /** Switch to history (transactions) page */ void gotoHistoryPage(); + /** Switch to deniability (ownership obfuscation) page */ + void gotoDeniabilityPage(); /** Switch to receive coins page */ void gotoReceiveCoinsPage(); /** Switch to send coins page */ diff --git a/src/qt/deniabilitydialog.cpp b/src/qt/deniabilitydialog.cpp new file mode 100644 index 00000000000..b4aaf58099b --- /dev/null +++ b/src/qt/deniabilitydialog.cpp @@ -0,0 +1,1484 @@ +// Copyright (c) 2022- The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include // IWYU pragma: keep + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +using common::PSBTError; +using interfaces::Wallet; + +enum { + COLUMN_CHECKBOX, + COLUMN_DESTINATION, + COLUMN_UTXO_COUNT, + COLUMN_AMOUNT, + COLUMN_DENIABILIZATION_CYCLES, + COLUMN_ESTIMATED_FEE, + COLUMN_COUNT +}; + +constexpr CAmount MAX_DENIABILIZATION_BUDGET = 100000; // 1 mBTC + +static bool hasExternalSigner(Wallet& wallet) +{ +#ifdef ENABLE_EXTERNAL_SIGNER + if (wallet.hasExternalSigner()) { + return true; + } +#endif + return false; +} + +static bool externalSignerConnected() +{ + std::vector signers; +#ifdef ENABLE_EXTERNAL_SIGNER + const std::string command = gArgs.GetArg("-signer", ""); + if (command.empty()) + return false; + try { + ExternalSigner::Enumerate(command, signers, Params().GetChainTypeString()); + } catch (const std::runtime_error& e) { + (void)e.what(); + } +#endif + return signers.size() == 1; +} + +CScript DeniabilityDialog::CoinInfo::scriptPubKey() const +{ + Assert(!utxos.empty()); + return utxos.front().walletTxOut.txout.scriptPubKey; +} + +CTxDestination DeniabilityDialog::CoinInfo::destination() const +{ + CTxDestination destination = CNoDestination(); + if (!ExtractDestination(scriptPubKey(), destination)) { + // For backwards compatibility, we convert P2PK output scripts into PKHash destinations + if (auto pubKeyDestination = std::get_if(&destination)) { + destination = PKHash(pubKeyDestination->GetPubKey()); + } + } + return destination; +} + +uint256 DeniabilityDialog::CoinInfo::hash() const +{ + HashWriter hasher{}; + for (const auto& utxo : utxos) { + hasher << utxo.outpoint.hash; + hasher << utxo.outpoint.n; + } + return hasher.GetSHA256(); +} + +CAmount DeniabilityDialog::CoinInfo::value() const +{ + CAmount amount = 0; + for (const auto& utxo : utxos) { + amount += utxo.walletTxOut.txout.nValue; + } + return amount; +} + +int DeniabilityDialog::CoinInfo::depthInMainChain() const +{ + int depth = INT_MAX; + for (const auto& utxo : utxos) { + depth = std::min(depth, utxo.walletTxOut.depth_in_main_chain); + } + return depth; +} + +uint DeniabilityDialog::CoinInfo::deniabilizationCycles() const +{ + uint cycles = UINT_MAX; + for (const auto& utxo : utxos) { + cycles = std::min(cycles, utxo.deniabilizationStats.cycles); + } + return cycles; +} + +bool DeniabilityDialog::CoinInfo::allUTXOsAreBlockReward() const +{ + for (const auto& utxo : utxos) { + if (!utxo.deniabilizationStats.blockReward) { + return false; + } + } + return true; +} + +bool DeniabilityDialog::CoinInfo::anyLockedCoin(interfaces::Wallet& wallet) const +{ + for (const auto& utxo : utxos) { + if (wallet.isLockedCoin(utxo.outpoint)) { + return true; + } + } + return false; +} + +DeniabilityDialog::DeniabilityDialog(const PlatformStyle* platformStyle, QWidget* parent) : QDialog(parent, GUIUtil::dialog_flags), + m_ui(new Ui::DeniabilityDialog), + m_platformStyle(platformStyle) +{ + m_ui->setupUi(this); + + setupTableWidget(); + + m_ui->budgetSpinner->setDisplayUnit(BitcoinUnit::SAT); + m_ui->budgetSpinner->SetMaxValue(MAX_DENIABILIZATION_BUDGET); + m_ui->budgetSpinner->setSingleStep(1000); + + m_deniabilizeProcTimer = new QTimer(this); + connect(m_deniabilizeProcTimer, SIGNAL(timeout()), this, SLOT(deniabilizeProc())); + + m_contextMenu = new QMenu(this); + m_contextMenu->setObjectName("contextMenuDeniability"); + + QAction* copyAddress = new QAction(tr("Copy Address"), this); + m_contextMenu->addAction(copyAddress); + + connect(copyAddress, &QAction::triggered, this, [this]() { + auto selectionModel = m_ui->tableWidgetCoins->selectionModel(); + if (!selectionModel) + return; + QModelIndexList selection = selectionModel->selectedRows(); + if (!selection.isEmpty()) { + GUIUtil::setClipboard(selection.at(0).data(destinationRole).toString()); + } + }); + + QAction* copyTxHash = new QAction(tr("Copy Transaction ID"), this); + m_contextMenu->addAction(copyTxHash); + + connect(copyTxHash, &QAction::triggered, this, [this]() { + auto selectionModel = m_ui->tableWidgetCoins->selectionModel(); + if (!selectionModel) + return; + QModelIndexList selection = selectionModel->selectedRows(); + if (!selection.isEmpty()) { + std::string destinationStr = selection.at(0).data(destinationRole).toString().toStdString(); + CTxDestination destination = DecodeDestination(destinationStr); + for (auto& coin : m_coinsList) { + if (coin.destination() == destination) { + QString hashStr = QString::fromStdString(coin.utxos.front().outpoint.hash.GetHex()); + GUIUtil::setClipboard(hashStr); + break; + } + } + } + }); +} + +DeniabilityDialog::~DeniabilityDialog() +{ + m_deniabilizeProcTimer->stop(); + delete m_deniabilizeProcTimer; + m_deniabilizeProcTimer = nullptr; + + saveSettings(); + + delete m_ui; +} + +void DeniabilityDialog::setupTableWidget() +{ + static_assert(COLUMN_COUNT == 6, "Update the header names below for any change in columns"); + QStringList headerLables; + headerLables << ""; + headerLables << tr("Address"); + headerLables << tr("UTXO Count"); + headerLables << tr("Amount") + " (" + BitcoinUnits::shortName(m_displayUnit) + ")"; + headerLables << tr("Cycles"); + headerLables << tr("Estimated Fees") + " (" + BitcoinUnits::shortName(BitcoinUnits::Unit::SAT) + ")"; + + // Setup coin table + m_ui->tableWidgetCoins->setColumnCount(COLUMN_COUNT); + m_ui->tableWidgetCoins->setHorizontalHeaderLabels(headerLables); + m_ui->tableWidgetCoins->horizontalHeader()->setDefaultAlignment(Qt::AlignLeft); + + // Resize cells (in a backwards compatible way) +#if QT_VERSION < 0x050000 + m_ui->tableWidgetCoins->horizontalHeader()->setResizeMode(QHeaderView::ResizeToContents); +#else + m_ui->tableWidgetCoins->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents); +#endif + m_ui->tableWidgetCoins->horizontalHeader()->setStretchLastSection(false); + m_ui->tableWidgetCoins->verticalHeader()->setVisible(false); + + // Select rows + m_ui->tableWidgetCoins->setSelectionBehavior(QAbstractItemView::SelectRows); + + // connect selection/deselection + connect(m_ui->tableWidgetCoins, &QTableWidget::itemClicked, this, &DeniabilityDialog::updateCheckState); + + // Apply custom context menu + m_ui->tableWidgetCoins->setContextMenuPolicy(Qt::CustomContextMenu); + + // Connect context menus + connect(m_ui->tableWidgetCoins, &QWidget::customContextMenuRequested, this, &DeniabilityDialog::contextualMenu); +} + +void DeniabilityDialog::contextualMenu(const QPoint& point) +{ + QModelIndex index = m_ui->tableWidgetCoins->indexAt(point); + if (index.isValid() && index.column() == COLUMN_DESTINATION) { + m_contextMenu->popup(m_ui->tableWidgetCoins->viewport()->mapToGlobal(point)); + } +} + +void DeniabilityDialog::loadSettings() +{ + // we don't store settings if there's no wallet name to avoid contaminating settings between different unnamed wallets + if (m_walletName.empty()) { + m_deniabilizationBudget = 0; + m_ui->budgetSpinner->setValue(m_deniabilizationBudget); + m_deniabilizationFrequency = std::chrono::hours(24); + m_ui->dailyRadioButton->setChecked(true); + m_nextDeniabilizationCycle.reset(); + m_lastDeniabilizationTxHash.reset(); + m_coinStateMap.clear(); + return; + } + + Assert(m_model); + Wallet& wallet = m_model->wallet(); + + QSettings settings; + settings.beginGroup("Deniability[" + QString::fromStdString(m_walletName) + "]"); + + if (!settings.contains("nDeniabilizationBudget")) { + settings.setValue("nDeniabilizationBudget", (qint64)0); + } + if (!settings.contains("nDeniabilizationFrequency")) { + settings.setValue("nDeniabilizationFrequency", (quint64)(60 * 60 * 24)); // 60 seconds to a minute, 60 minutes to an hour, 24 hours to a day + } + if (!settings.contains("nNextDeniabilizationCycle")) { + settings.setValue("nNextDeniabilizationCycle", (quint64)0); + } + if (!settings.contains("fDeniabilizationProcessAccepted")) { + settings.setValue("fDeniabilizationProcessAccepted", false); + } + if (!settings.contains("sLastDeniabilizationTxHash")) { + settings.setValue("sLastDeniabilizationTxHash", ""); + } + + CAmount nDeniabilizationBudget = settings.value("nDeniabilizationBudget").toLongLong(); + if (nDeniabilizationBudget < 0) { + nDeniabilizationBudget = 0; + } else if (nDeniabilizationBudget > MAX_DENIABILIZATION_BUDGET) { + nDeniabilizationBudget = MAX_DENIABILIZATION_BUDGET; + } + m_deniabilizationBudget = nDeniabilizationBudget; + m_ui->budgetSpinner->setValue(m_deniabilizationBudget); + + uint64_t nDeniabilizationFrequency = settings.value("nDeniabilizationFrequency").toULongLong(); + m_deniabilizationFrequency = std::chrono::seconds(nDeniabilizationFrequency); + + if (m_deniabilizationFrequency == std::chrono::hours(1)) { + m_ui->hourlyRadioButton->setChecked(true); + } else if (m_deniabilizationFrequency == std::chrono::hours(24)) { + m_ui->dailyRadioButton->setChecked(true); + } else if (m_deniabilizationFrequency == std::chrono::hours(24 * 7)) { + m_ui->weeklyRadioButton->setChecked(true); + } else { + m_deniabilizationFrequency = std::chrono::hours(24); + m_ui->dailyRadioButton->setChecked(true); + } + + m_nextDeniabilizationCycle.reset(); + uint64_t nNextDeniabilizationCycle = settings.value("nNextDeniabilizationCycle").toULongLong(); + if (nNextDeniabilizationCycle) { + m_nextDeniabilizationCycle = std::chrono::system_clock::time_point(std::chrono::system_clock::duration(nNextDeniabilizationCycle)); + } + + m_deniabilizationProcessAccepted = settings.value("fDeniabilizationProcessAccepted").toBool(); + + m_lastDeniabilizationTxHash.reset(); + QString hashStr = settings.value("sLastDeniabilizationTxHash").toString(); + if (!hashStr.isEmpty()) { + uint256 hash = uint256S(hashStr.toStdString()); + if (wallet.getTx(hash)) { + m_lastDeniabilizationTxHash = hash; + } + } + + m_coinStateMap.clear(); + int coinCount = settings.beginReadArray("coinCheckStateArray"); + for (int coinIndex = 0; coinIndex < coinCount; coinIndex++) { + settings.setArrayIndex(coinIndex); + QString hashStr = settings.value("hash").toString(); + uint256 hash = uint256S(hashStr.toStdString()); + CoinState coinState; + coinState.deniabilizable = (Deniabilizable)settings.value("deniabilizable").toUInt(); + bool validStatus = false; + switch (coinState.deniabilizable) { + case Deniabilizable::YES: + case Deniabilizable::YES_BUT_BLOCK_REWARD: + case Deniabilizable::YES_BUT_COIN_LOCKED: + case Deniabilizable::YES_BUT_TX_NOT_MATURE: + case Deniabilizable::YES_BUT_AMOUNT_NOT_WORTHWHILE: + case Deniabilizable::NO_FULLY_DENIABILIZED: + case Deniabilizable::NO_PRIVATE_KEYS_DISABLED: + case Deniabilizable::NO_AMOUNT_TOO_SMALL: + case Deniabilizable::NO: + validStatus = true; + break; + } + if (!validStatus) + continue; + coinState.checkState = (Qt::CheckState)settings.value("checkState").toUInt(); + if (!(coinState.checkState == Qt::Checked || coinState.checkState == Qt::Unchecked)) + continue; + m_coinStateMap[hash] = coinState; + } + settings.endArray(); + + settings.endGroup(); +} + +void DeniabilityDialog::saveSettings() +{ + if (m_walletName.empty()) { + return; + } + + QSettings settings; + settings.beginGroup("Deniability[" + QString::fromStdString(m_walletName) + "]"); + + settings.setValue("nDeniabilizationBudget", (qint64)m_deniabilizationBudget); + + uint64_t nDeniabilizationFrequency = m_deniabilizationFrequency.count(); + settings.setValue("nDeniabilizationFrequency", (quint64)nDeniabilizationFrequency); + + uint64_t nNextDeniabilizationCycle = 0; + if (m_nextDeniabilizationCycle.has_value()) { + nNextDeniabilizationCycle = m_nextDeniabilizationCycle.value().time_since_epoch().count(); + } + settings.setValue("nNextDeniabilizationCycle", (quint64)nNextDeniabilizationCycle); + + settings.setValue("fDeniabilizationProcessAccepted", m_deniabilizationProcessAccepted); + + if (m_lastDeniabilizationTxHash) { + uint256 hash = m_lastDeniabilizationTxHash.value(); + QString hashStr = QString::fromStdString(hash.ToString()); + settings.setValue("sLastDeniabilizationTxHash", hashStr); + } else { + settings.setValue("sLastDeniabilizationTxHash", ""); + } + + settings.beginWriteArray("coinCheckStateArray"); + int coinIndex = 0; + for (const auto& coin : m_coinsList) { + // only store the the state of coins that may be deniabilized + if (coin.state.mayBeDeniabilized()) { + settings.setArrayIndex(coinIndex++); + uint256 hash = coin.hash(); + QString hashStr = QString::fromStdString(hash.GetHex()); + settings.setValue("hash", hashStr); + settings.setValue("deniabilizable", (uint)coin.state.deniabilizable); + settings.setValue("checkState", (uint)coin.state.checkState); + } + } + settings.endArray(); + + settings.endGroup(); +} + +void DeniabilityDialog::updateCheckState(QTableWidgetItem* itemCheck) +{ + std::string destinationStr = itemCheck->data(destinationRole).toString().toStdString(); + CTxDestination destination = DecodeDestination(destinationStr); + Qt::CheckState checkState = itemCheck->checkState(); + + for (auto& coin : m_coinsList) { + if (coin.destination() == destination) { + coin.state.checkState = checkState; + break; + } + } + + updateStart(); + updateStatus(); +} + +bool DeniabilityDialog::walletSupportsDeniabilization() const +{ + if (!m_model) + return false; + + Wallet& wallet = m_model->wallet(); + if (wallet.privateKeysDisabled() && !hasExternalSigner(wallet)) { + return false; + } + + if (wallet.isLegacy()) { + return false; + } + + return true; +} + + +void DeniabilityDialog::updateStart() +{ + if (m_ui->stopButton->isEnabled()) { + // stop button is active that means start button should not be + Assert(!m_ui->startButton->isEnabled()); + return; + } + + CAmount budgetValue = m_ui->budgetSpinner->value(); + bool hasCandidates = hasDeniabilizationCandidates(); + + // disable or enable the start button depending on the budget provided and the availability of candidates + if (m_ui->startButton->isEnabled()) { + if (budgetValue == 0 || !hasCandidates) { + m_ui->startButton->setEnabled(false); + } + } else { + if (budgetValue > 0 && hasCandidates) { + m_ui->startButton->setEnabled(true); + } + } +} + +void DeniabilityDialog::updateStatus() +{ + if (!m_model) { + m_ui->statusLabel->setText(tr("Deniabilization is not supported without a wallet")); + return; + } + + if (!m_clientModel || m_clientModel->node().isInitialBlockDownload()) { + m_ui->statusLabel->setText(tr("Waiting for blockchain data to synchronize...")); + return; + } + + if (!walletSupportsDeniabilization()) { + Wallet& wallet = m_model->wallet(); + if (wallet.privateKeysDisabled() && !hasExternalSigner(wallet)) { + m_ui->statusLabel->setText(tr("Deniabilization is not supported without private keys")); + return; + } + + if (wallet.isLegacy()) { + m_ui->statusLabel->setText(tr("Deniabilization is not supported on legacy wallets")); + return; + } + } + + if (m_ui->startButton->isEnabled()) { + Assert(!m_ui->stopButton->isEnabled()); + // start button is enabled which means a non-zero budget was entered + if (hasDeniabilizationCandidates()) { + m_ui->statusLabel->setText(tr("Deniabilization process is not active. Choose a frequency and press Start to begin.")); + } else { + m_ui->statusLabel->setText(tr("No deniabilization candidates available.")); + } + return; + } + + if (m_ui->stopButton->isEnabled()) { + if (m_deniabilizationTxInProgress) { + m_ui->statusLabel->setText(tr("Deniabilization cycle in progress...")); + return; + } + + if (m_lastDeniabilizationTxHash) { + m_ui->statusLabel->setText(tr("Waiting for the deniabilization transaction to be confirmed...")); + return; + } + + Assert(m_nextDeniabilizationCycle.has_value()); + auto timeNow = std::chrono::system_clock::now(); + if (timeNow < m_nextDeniabilizationCycle.value()) { + auto deltaMinutes = std::chrono::duration_cast(m_nextDeniabilizationCycle.value() - timeNow); + QString deltaTimeStr; + if (deltaMinutes < std::chrono::minutes(60)) { + deltaTimeStr = QString::number(deltaMinutes.count()) + " " + tr("minutes"); + } else { + auto deltaHours = std::chrono::duration_cast(deltaMinutes); + deltaMinutes -= std::chrono::minutes(deltaHours); + if (deltaHours < std::chrono::hours(24)) { + deltaTimeStr = QString::number(deltaHours.count()) + " " + tr("hours and") + " " + QString::number(deltaMinutes.count()) + " " + tr("minutes"); + } else { + auto deltaDays = deltaHours / 24; + deltaHours -= deltaDays * 24; + deltaTimeStr = QString::number(deltaDays.count()) + " " + tr("days") + ", " + QString::number(deltaHours.count()) + " " + tr("hours and") + " " + QString::number(deltaMinutes.count()) + " " + tr("minutes"); + } + } + m_ui->statusLabel->setText(tr("Next deniabilization cycle in") + " " + deltaTimeStr + ". " + tr("Press Stop to cancel.")); + } else { + Wallet& wallet = m_model->wallet(); + if (hasExternalSigner(wallet)) { + m_ui->statusLabel->setText(tr("Deniabilization process is active. Waiting on external signer to be connected...")); + } else { + m_ui->statusLabel->setText(tr("Deniabilization cycle is about to begin...")); + } + } + return; + } + + // both start and stop buttons are not active, so we're waiting for a budget to be entered + if (hasDeniabilizationCandidates()) { + m_ui->statusLabel->setText(tr("Deniabilization process is not active. Choose a frequency and a budget, and then press Start.")); + } else { + m_ui->statusLabel->setText(tr("No deniabilization candidates available.")); + } +} + +void DeniabilityDialog::updateCoinTable() +{ + m_ui->tableWidgetCoins->setUpdatesEnabled(false); + + m_ui->tableWidgetCoins->setRowCount(0); + + int nRow = 0; + for (const auto& coin : m_coinsList) { + m_ui->tableWidgetCoins->insertRow(nRow); + + QString destinationStr = QString::fromStdString(EncodeDestination(coin.destination())); + + static_assert(COLUMN_COUNT == 6, "Update the item logic below for any change in columns"); + + { + // Checkbox + QTableWidgetItem* itemCheck = new QTableWidgetItem(); + itemCheck->setTextAlignment(Qt::AlignRight | Qt::AlignVCenter); + itemCheck->setCheckState(Qt::Unchecked); + itemCheck->setData(destinationRole, destinationStr); + if (coin.state.mayBeDeniabilized()) { + itemCheck->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled | Qt::ItemIsUserCheckable); + } else { + itemCheck->setFlags(Qt::NoItemFlags); + } + itemCheck->setCheckState(coin.state.checkState); + m_ui->tableWidgetCoins->setItem(nRow, COLUMN_CHECKBOX, itemCheck); + } + + { + // Destination + QTableWidgetItem* itemDestination = new QTableWidgetItem(); + itemDestination->setTextAlignment(Qt::AlignLeft | Qt::AlignVCenter); + itemDestination->setText(destinationStr); + // Keep the destination field always enabled so we can copy/paste the address + itemDestination->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled); + if (!coin.state.mayBeDeniabilized()) { + itemDestination->setForeground(Qt::gray); + } + switch (coin.state.deniabilizable) { + case Deniabilizable::YES: + itemDestination->setToolTip(tr("This coin can be deniabilized")); + break; + case Deniabilizable::YES_BUT_BLOCK_REWARD: + itemDestination->setToolTip(tr("This coin can be deniabilized but it's from a block reward and likely not needed")); + break; + case Deniabilizable::YES_BUT_COIN_LOCKED: + itemDestination->setToolTip(tr("This coin can be deniabilized but contains locked UTXOs, selecting it will unlock the coins during deniabilization")); + break; + case Deniabilizable::YES_BUT_TX_NOT_MATURE: + itemDestination->setToolTip(tr("This coin can be deniabilized but waiting for more confirmations is recommended")); + break; + case Deniabilizable::YES_BUT_AMOUNT_NOT_WORTHWHILE: + itemDestination->setToolTip(tr("This coin can be deniabilized but the amount is not worthwhile")); + break; + case Deniabilizable::NO_FULLY_DENIABILIZED: + itemDestination->setToolTip(tr("This coin is already fully deniabilized")); + break; + case Deniabilizable::NO_PRIVATE_KEYS_DISABLED: + itemDestination->setToolTip(tr("This coin can't be deniabilized because the wallet's private keys are disabled")); + break; + case Deniabilizable::NO_AMOUNT_TOO_SMALL: + itemDestination->setToolTip(tr("This coin can't be deniabilized because the coin amount is less than the estimated fees")); + break; + case Deniabilizable::NO: + itemDestination->setToolTip(tr("This coin can't be deniabilized")); + break; + } + m_ui->tableWidgetCoins->setItem(nRow, COLUMN_DESTINATION, itemDestination); + } + + { + // UTXO Count + QTableWidgetItem* itemUTXOCount = new QTableWidgetItem(); + itemUTXOCount->setTextAlignment(Qt::AlignRight | Qt::AlignVCenter); + itemUTXOCount->setText(QString::number(coin.numUTXOs())); + if (coin.state.mayBeDeniabilized()) { + itemUTXOCount->setFlags(Qt::ItemIsEnabled); + } else { + itemUTXOCount->setFlags(Qt::NoItemFlags); + itemUTXOCount->setForeground(Qt::gray); + } + m_ui->tableWidgetCoins->setItem(nRow, COLUMN_UTXO_COUNT, itemUTXOCount); + } + + { + // Amount + QTableWidgetItem* itemAmount = new QTableWidgetItem(); + itemAmount->setTextAlignment(Qt::AlignRight | Qt::AlignVCenter); + itemAmount->setText(BitcoinUnits::format(m_displayUnit, coin.value())); + if (coin.state.mayBeDeniabilized()) { + itemAmount->setFlags(Qt::ItemIsEnabled); + } else { + itemAmount->setFlags(Qt::NoItemFlags); + itemAmount->setForeground(Qt::gray); + } + m_ui->tableWidgetCoins->setItem(nRow, COLUMN_AMOUNT, itemAmount); + } + + { + // Deniabilization status + QTableWidgetItem* itemDeniabilization = new QTableWidgetItem(); + itemDeniabilization->setTextAlignment(Qt::AlignHCenter | Qt::AlignVCenter); + itemDeniabilization->setText(QString::number(coin.deniabilizationCycles())); + if (coin.state.mayBeDeniabilized()) { + itemDeniabilization->setFlags(Qt::ItemIsEnabled); + } else { + itemDeniabilization->setFlags(Qt::NoItemFlags); + itemDeniabilization->setForeground(Qt::gray); + } + // Set deniabilization cell highlight color + if (coin.allUTXOsAreBlockReward()) { + itemDeniabilization->setBackground(QColor(0, 128, 0, 128)); + } else { + float deniabilizationProbability = wallet::CalculateDeniabilizationProbability(coin.deniabilizationCycles()); + itemDeniabilization->setBackground(QColor(deniabilizationProbability * 128, (1.0f - deniabilizationProbability) * 128, 0, 128)); + } + m_ui->tableWidgetCoins->setItem(nRow, COLUMN_DENIABILIZATION_CYCLES, itemDeniabilization); + } + + { + // Estimated fee + QTableWidgetItem* itemEstimatedFee = new QTableWidgetItem(); + itemEstimatedFee->setTextAlignment(Qt::AlignHCenter | Qt::AlignVCenter); + itemEstimatedFee->setText(QString::number(coin.deniabilizationFeeEstimate)); + if (coin.state.mayBeDeniabilized()) { + itemEstimatedFee->setFlags(Qt::ItemIsEnabled); + } else { + itemEstimatedFee->setFlags(Qt::NoItemFlags); + itemEstimatedFee->setForeground(Qt::gray); + } + m_ui->tableWidgetCoins->setItem(nRow, COLUMN_ESTIMATED_FEE, itemEstimatedFee); + } + + nRow++; + } + + m_ui->tableWidgetCoins->setUpdatesEnabled(true); +} + +void DeniabilityDialog::startDeniabilization() +{ + Assert(m_model); + + if (!m_deniabilizationProcessAccepted) { + Wallet& wallet = m_model->wallet(); + + QString message = tr("Deniabilization is about to start:"); + message += "

"; + message += tr("A coin will be selected from the top of the candidate list."); + message += "
"; + message += tr("A transaction will be prepared to split the coin into a pair of new addresses in your wallet."); + message += "
"; + message += tr("This makes blockchain analysis harder and thus improves privacy with each \"deniabilization\" cycle."); + message += "
"; + if (hasExternalSigner(wallet)) { + message += tr("You'll be prompted to confirm the transaction on your hardware device, and it will be broadcast immediately."); + } else { + message += tr("The transaction will be broadcast immediately."); + } + message += "

"; + message += tr("If %1 is left running continuously, the above process will repeat at the selected frequency (with some amount of randomization).").arg(PACKAGE_NAME); + message += "

"; + message += tr("If %1 is shutdown and later restarted, the process will resume, and if the elapsed time has exceeded the selected frequency, it will prepare and broadcast a transaction immediately.").arg(PACKAGE_NAME); + message += "

"; + message += tr("You can exclude a particular candidate from being selected by unchecking the checkbox on the left."); + message += "

"; + message += tr("The deniabilization process will stop when the specified budget is exhausted or if it runs out of suitable candidates."); + message += "
"; + message += tr("You can also stop it at any time by pressing the Stop button."); + message += "

"; + message += tr("If you'd like to change the budget or frequency, press Cancel now."); + message += "
"; + message += tr("Otherwise, press Ok to continue."); + QMessageBox::StandardButton resultButton = QMessageBox::information(nullptr, tr("Starting deniabilization"), message, QMessageBox::Ok | QMessageBox::Cancel, QMessageBox::Cancel); + if (resultButton == QMessageBox::Cancel) { + return; + } + m_deniabilizationProcessAccepted = true; + } + + // disable the start button + m_ui->startButton->setEnabled(false); + // disable all settings UI + m_ui->hourlyRadioButton->setEnabled(false); + m_ui->dailyRadioButton->setEnabled(false); + m_ui->weeklyRadioButton->setEnabled(false); + m_ui->budgetSpinner->setEnabled(false); + // enable the stop button + m_ui->stopButton->setEnabled(true); + + // if this is the first time we're running, schedule a deniabilization right away + if (!m_nextDeniabilizationCycle.has_value()) { + m_nextDeniabilizationCycle = std::chrono::system_clock::now(); + } + updateStatus(); + // update status every 10 seconds + m_deniabilizeProcTimer->start(std::chrono::seconds(10)); + deniabilizeProc(); +} + +void DeniabilityDialog::stopDeniabilization() +{ + Assert(m_model); + + m_deniabilizeProcTimer->stop(); + m_nextDeniabilizationCycle.reset(); + + // disable the stop button + m_ui->stopButton->setEnabled(false); + // enable back all settings UI + m_ui->hourlyRadioButton->setEnabled(true); + m_ui->dailyRadioButton->setEnabled(true); + m_ui->weeklyRadioButton->setEnabled(true); + m_ui->budgetSpinner->setEnabled(true); + + updateStart(); + updateStatus(); +} + +bool DeniabilityDialog::hasDeniabilizationCandidates() const +{ + // if the last tx hasn't confirmed yet, + // consider it a candidate to prevent the deniabilization process from stopping prematurely + if (m_lastDeniabilizationTxHash) + return true; + for (const auto& coin : m_coinsList) { + // if a coin is not selected but may become deniabilizable, + // consider it a candidate to prevent the deniabilization process from stopping prematurely + if (coin.state.checkState == Qt::Checked || coin.state.mayBeDeniabilized()) { + return true; + } + } + return false; +} + +enum TxStatus { + TX_UNKNOWN, + TX_CONFLICTING, + TX_ABANDONED, + TX_IN_MEMPOOL, + TX_CONFIRMED +}; + +static TxStatus getTxStatus(interfaces::Wallet& wallet, uint256 hash) +{ + int numBlocks; + interfaces::WalletTxStatus status; + interfaces::WalletOrderForm orderForm; + bool inMempool; + interfaces::WalletTx wtx = wallet.getWalletTxDetails(hash, status, orderForm, inMempool, numBlocks); + if (!wtx.tx) { + // transaction not found + return TX_UNKNOWN; + } else if (status.depth_in_main_chain < 0) { + // conflicting transaction + return TX_CONFLICTING; + } else if (status.depth_in_main_chain == 0) { + if (status.is_abandoned || !inMempool) { + // abandoned or dropped from the mempool + return TX_ABANDONED; + } else { + // still in the mempool + return TX_IN_MEMPOOL; + } + } else { + Assert(status.depth_in_main_chain > 0); + // the tx was included in a block + return TX_CONFIRMED; + } +} + +void DeniabilityDialog::deniabilizeProc() +{ + if (!m_model) + return; + + if (!m_clientModel || m_clientModel->node().isInitialBlockDownload()) { + updateStatus(); + return; + } + + Assert(m_nextDeniabilizationCycle.has_value()); + auto timeNow = std::chrono::system_clock::now(); + if (timeNow < m_nextDeniabilizationCycle.value()) { + updateStatus(); + return; + } + + if (m_deniabilizationTxInProgress) { + updateStatus(); + return; + } + + Wallet& wallet = m_model->wallet(); + if (hasExternalSigner(wallet)) { + if (!externalSignerConnected()) { + updateStatus(); + return; + } + } + + m_deniabilizationTxInProgress = true; + + updateCoins(); + + // check up on the last TX and make sure it's not still in the mempool + if (m_lastDeniabilizationTxHash) { + TxStatus txStatus = getTxStatus(wallet, m_lastDeniabilizationTxHash.value()); + if (txStatus == TX_IN_MEMPOOL) { + // if it's still in the mempool, try a fee bump + QString hashStr = QString::fromStdString(m_lastDeniabilizationTxHash.value().ToString()); + if (bumpDeniabilizationTx(m_lastDeniabilizationTxHash.value())) { + Assert(m_lastDeniabilizationTxHash.has_value()); + LogPrintf("Deniability[%s]: Fee bump transaction (%s) broadcasted successfully.\n", m_walletName, m_lastDeniabilizationTxHash.value().GetHex()); + // Update the table + m_model->getTransactionTableModel()->updateTransaction(hashStr, CT_UPDATED, true); + } else { + LogPrintf("Deniability[%s]: Skipping fee bump transaction.\n", m_walletName); + } + } + } + + // Check which coin can be deniabilized + bool stop = false; + if (!m_lastDeniabilizationTxHash) { + for (auto& coin : m_coinsList) { + if (coin.state.checkState == Qt::Unchecked) { + continue; + } + + DeniabilizationResult result = deniabilizeCoin(coin); + if (result == DENIABILIZATION_SKIP_COIN) { + // try the next coin now, but retry this coin at the next cycle + LogPrintf("Deniability[%s]: Skipping coin.\n", m_walletName); + continue; + } else if (result == DENIABILIZATION_SKIP_ALL_COINS) { + // don't try any other coins now, but retry the selected coins at the next cycle + LogPrintf("Deniability[%s]: Skipping all coins.\n", m_walletName); + break; + } else if (result == DENIABILIZATION_DESELECT_COIN) { + // deselect this coin so it won't retry at the next cycle (the user can still re-select manually) + coin.state.checkState = Qt::Unchecked; + LogPrintf("Deniability[%s]: Deselecting coin.\n", m_walletName); + // try the next coin now + continue; + } else if (result == DENIABILIZATION_STOP) { + // don't try any more coins and stop the processing, eg due to out of budget or other fatal error + stop = true; + LogPrintf("Deniability[%s]: Stopping the deniabilization process.\n", m_walletName); + break; + } else { + Assert(result == DENIABILIZATION_SUCCESS); + Assert(m_lastDeniabilizationTxHash.has_value()); + LogPrintf("Deniability[%s]: Transaction (%s) broadcasted successfully.\n", m_walletName, m_lastDeniabilizationTxHash.value().GetHex()); + updateCoins(); + break; + } + } + } + + if (!stop && m_deniabilizationBudget > 0 && hasDeniabilizationCandidates()) { + // Update the next deniabilization cycle time with the desired frequency with some randomization + uint64_t frequency = m_deniabilizationFrequency.count(); + uint64_t randomizedFrequency = frequency + FastRandomContext().randrange(frequency); + + timeNow = std::chrono::system_clock::now(); + m_nextDeniabilizationCycle = timeNow + std::chrono::seconds(randomizedFrequency); + } else { + // if stop-processing was requested or there's no coins left to deniabilize, turn off the deniabilization process + stopDeniabilization(); + } + + m_deniabilizationTxInProgress = false; + updateStatus(); +} + +void DeniabilityDialog::clear() +{ + // if deniabilization is running don't do anything + if (m_ui->stopButton->isEnabled()) { + return; + } + + // reset the UI to default values + m_ui->budgetSpinner->setValue(0); + m_ui->dailyRadioButton->setChecked(true); + updateStart(); + updateStatus(); +} + +void DeniabilityDialog::reject() +{ + clear(); +} + +void DeniabilityDialog::accept() +{ + clear(); +} + +void DeniabilityDialog::updateCoinsIfVisible() +{ + if (this->isVisible()) { + updateCoins(); + } +} + +void DeniabilityDialog::updateNumberOfBlocks(int count, const QDateTime& blockDate, double nVerificationProgress, SyncType synctype, SynchronizationState sync_state) +{ + if (sync_state == SynchronizationState::POST_INIT) { + updateCoinsIfVisible(); + } +} + +void DeniabilityDialog::setClientModel(ClientModel* clientModel) +{ + m_clientModel = clientModel; + + if (m_clientModel) { + connect(m_clientModel, &ClientModel::numBlocksChanged, this, &DeniabilityDialog::updateNumberOfBlocks); + } +} + +void DeniabilityDialog::setModel(WalletModel* model) +{ + m_model = model; + + if (walletSupportsDeniabilization()) { + Assert(m_model); + OptionsModel* optionsModel = m_model->getOptionsModel(); + if (optionsModel) { + connect(optionsModel, &OptionsModel::displayUnitChanged, this, [this, optionsModel]() { + m_displayUnit = optionsModel->getDisplayUnit(); + setupTableWidget(); + updateCoinsIfVisible(); + }); + } + + connect(m_model, &WalletModel::balanceChanged, this, &DeniabilityDialog::updateCoinsIfVisible); + + connect(m_ui->startButton, &QPushButton::clicked, this, &DeniabilityDialog::startDeniabilization); + connect(m_ui->stopButton, &QPushButton::clicked, this, &DeniabilityDialog::stopDeniabilization); + + connect(m_ui->budgetSpinner, &BitcoinAmountField::valueChanged, this, [this]() { + m_deniabilizationBudget = m_ui->budgetSpinner->value(); + updateStart(); + updateStatus(); + }); + + connect(m_ui->hourlyRadioButton, &QRadioButton::toggled, this, [this](bool checked) { + if (checked) { + m_deniabilizationFrequency = std::chrono::hours(1); + updateCoinsIfVisible(); + } + }); + connect(m_ui->dailyRadioButton, &QRadioButton::toggled, this, [this](bool checked) { + if (checked) { + m_deniabilizationFrequency = std::chrono::hours(24); + updateCoinsIfVisible(); + } + }); + connect(m_ui->weeklyRadioButton, &QRadioButton::toggled, this, [this](bool checked) { + if (checked) { + m_deniabilizationFrequency = std::chrono::hours(24 * 7); + updateCoinsIfVisible(); + } + }); + } else { + // disable all settings UI + m_ui->startButton->setEnabled(false); + m_ui->hourlyRadioButton->setEnabled(false); + m_ui->dailyRadioButton->setEnabled(false); + m_ui->weeklyRadioButton->setEnabled(false); + m_ui->budgetSpinner->setEnabled(false); + m_ui->stopButton->setEnabled(false); + } + + if (m_model) { + m_walletName = m_model->wallet().getWalletName(); + loadSettings(); + } else { + m_walletName.clear(); + m_deniabilizationBudget = 0; + m_deniabilizationFrequency = std::chrono::seconds::zero(); + m_nextDeniabilizationCycle.reset(); + } + + updateCoins(); + + // if the start button is enabled and we have a valid deniabilization time + // that means deniabilization was active at shutdown, so restart it right away + if (m_nextDeniabilizationCycle.has_value()) { + if (m_ui->startButton->isEnabled()) { + startDeniabilization(); + } else { + m_nextDeniabilizationCycle.reset(); + } + } +} + +void DeniabilityDialog::updateCoins() +{ + if (!m_model) { + m_coinsList.clear(); + updateCoinTable(); + updateStart(); + updateStatus(); + return; + } + + // wait for at least 6 confirmations before deniabilizing a coin + const int minChainDepth = 6; + + Wallet& wallet = m_model->wallet(); + + // check up on the last TX and clear it if was confirmed, abandoned or dropped from the pool + if (m_lastDeniabilizationTxHash) { + TxStatus txStatus = getTxStatus(wallet, m_lastDeniabilizationTxHash.value()); + switch (txStatus) { + case TX_IN_MEMPOOL: + // still in mempool + break; + case TX_CONFIRMED: + LogPrintf("Deniability[%s]: Deniabilization transaction (%s) was confirmed.\n", m_walletName, m_lastDeniabilizationTxHash.value().GetHex()); + m_lastDeniabilizationTxHash.reset(); + break; + case TX_CONFLICTING: + case TX_ABANDONED: + LogPrintf("Deniability[%s]: Deniabilization transaction (%s) was abandoned or dropped.\n", m_walletName, m_lastDeniabilizationTxHash.value().GetHex()); + m_lastDeniabilizationTxHash.reset(); + break; + case TX_UNKNOWN: + LogPrintf("Deniability[%s]: Deniabilization transaction (%s) was not found.\n", m_walletName, m_lastDeniabilizationTxHash.value().GetHex()); + m_lastDeniabilizationTxHash.reset(); + break; + } + } + + Assert(m_deniabilizationFrequency.count() > 0); + uint confirmTarget = m_deniabilizationFrequency.count() / (60 * 10); // 60 seconds per minute, 10 minutes per block + CFeeRate deniabilizationFeeRate = wallet.getDeniabilizationFeeRate(confirmTarget); + + CFeeRate dustRelayFee = m_model->node().getDustRelayFee(); + + // Before we reset the table, keep track of the coin state + for (const auto& coin : m_coinsList) { + // stash the coin state in the state map unless it's already there (eg from load settings) + uint256 coinHash = coin.hash(); + auto mapIter = m_coinStateMap.find(coinHash); + if (mapIter == m_coinStateMap.end()) { + m_coinStateMap[coinHash] = coin.state; + } + } + + m_coinsList.clear(); + + // group UTXOs that share the same scriptPubKey into a CoinInfo + std::map coinInfoMap; + { + auto coinsListMap = wallet.listCoins(); + for (const auto& coinsPair : coinsListMap) { + const auto& coinsTuples = coinsPair.second; + for (const auto& coinTuple : coinsTuples) { + CoinUTXO output; + output.outpoint = std::get<0>(coinTuple); + output.walletTxOut = std::get<1>(coinTuple); + // skip spent outputs + if (output.walletTxOut.is_spent) + continue; + CScript scriptPubKey = output.walletTxOut.txout.scriptPubKey; + if (scriptPubKey.IsUnspendable()) + continue; + auto result = wallet.calculateDeniabilizationCycles(output.outpoint); + output.deniabilizationStats = DeniabilizationStats(result.first, result.second); + coinInfoMap[scriptPubKey].utxos.push_back(std::move(output)); + } + } + } + + // fill in the rest of the CoinInfo data and store into m_coinsList + m_coinsList.reserve(coinInfoMap.size()); + for (const auto& coinInfoPair : coinInfoMap) { + CoinInfo coin = coinInfoPair.second; + Assert(!coin.utxos.empty()); + + // sort the outputs by outpoint so the order matches between runs + auto utxoCompare = [](const CoinUTXO& utxoA, const CoinUTXO& utxoB) -> bool { + return utxoA.outpoint < utxoB.outpoint; + }; + + std::sort(coin.utxos.begin(), coin.utxos.end(), utxoCompare); + + CAmount coinValue = coin.value(); + CScript coinScript = coin.scriptPubKey(); + CAmount dustThreshold = GetDustThreshold(CTxOut(coinValue, coinScript), dustRelayFee); + uint deniabilizationCycles = coin.deniabilizationCycles(); + float deniabilizationProbability = wallet::CalculateDeniabilizationProbability(deniabilizationCycles); + uint deniabilizationProbabilityPercent = deniabilizationProbability * 100; + coin.deniabilizationFeeEstimate = wallet::CalculateDeniabilizationFeeEstimate(coinScript, coinValue, coin.numUTXOs(), deniabilizationCycles, deniabilizationFeeRate); + + coin.state.deniabilizable = Deniabilizable::YES; + if (wallet.privateKeysDisabled() && !hasExternalSigner(wallet)) { + // disable coins that don't have private keys (unless it's an external signer) + coin.state.deniabilizable = Deniabilizable::NO_PRIVATE_KEYS_DISABLED; + } else if (deniabilizationProbabilityPercent == 0) { + // disable coins that are already fully deniabilized + coin.state.deniabilizable = Deniabilizable::NO_FULLY_DENIABILIZED; + } else if (coinValue < coin.deniabilizationFeeEstimate + dustThreshold) { + // disable coins that are too small (eg after full deniabilization won't leave any more than dust) + coin.state.deniabilizable = Deniabilizable::NO_AMOUNT_TOO_SMALL; + } else if (coin.allUTXOsAreBlockReward()) { + // deselect the coin if all UTXOs are from a block reward (thus probably not necessary to deniabilize) + coin.state.deniabilizable = Deniabilizable::YES_BUT_BLOCK_REWARD; + } else if (coin.depthInMainChain() < minChainDepth) { + // deselect non-mature coins + coin.state.deniabilizable = Deniabilizable::YES_BUT_TX_NOT_MATURE; + } else if (coin.anyLockedCoin(wallet)) { + // deselect locked coins + coin.state.deniabilizable = Deniabilizable::YES_BUT_COIN_LOCKED; + } else if (!wallet::IsDeniabilizationWorthwhile(coinValue, coin.deniabilizationFeeEstimate)) { + // deselect coins that are too small to be worth obuscation (eg fees are more than 10% of the amount) + coin.state.deniabilizable = Deniabilizable::YES_BUT_AMOUNT_NOT_WORTHWHILE; + } + + if (coin.state.mayBeDeniabilized()) { + const CoinState* coinState = nullptr; + { + uint256 coinHash = coin.hash(); + auto mapStateIter = m_coinStateMap.find(coinHash); + if (mapStateIter != m_coinStateMap.end()) { + coinState = &mapStateIter->second; + } + } + + if (coinState && coinState->deniabilizable == coin.state.deniabilizable) { + coin.state.checkState = coinState->checkState; + } else { + if (coin.state.canBeDeniabilized()) { + coin.state.checkState = Qt::Checked; + } else { + coin.state.checkState = Qt::Unchecked; + } + } + } else { + coin.state.checkState = Qt::Unchecked; + } + + m_coinsList.push_back(std::move(coin)); + } + + // all state is now transferred to the coin list so we can clear the state map + m_coinStateMap.clear(); + + auto coinCompare = [](const CoinInfo& coinA, const CoinInfo& coinB) -> bool { + // coins that can be deniabilized go first + if (coinA.state.canBeDeniabilized() != coinB.state.canBeDeniabilized()) + return coinA.state.canBeDeniabilized() > coinB.state.canBeDeniabilized(); + // coins that may be deniabilized go first + if (coinA.state.mayBeDeniabilized() != coinB.state.mayBeDeniabilized()) + return coinA.state.mayBeDeniabilized() > coinB.state.mayBeDeniabilized(); + + // calculate a compound "value and probability" and sort larger values first + // this way bigger coins that are more likely to deniabilize will be tried first + CAmount valueProbabilityA = coinA.value() * wallet::CalculateDeniabilizationProbability(coinA.deniabilizationCycles()); + CAmount valueProbabilityB = coinB.value() * wallet::CalculateDeniabilizationProbability(coinB.deniabilizationCycles()); + if (valueProbabilityA != valueProbabilityB) + return valueProbabilityA > valueProbabilityB; + + // coins with more confirmations go first + return coinA.depthInMainChain() > coinB.depthInMainChain(); + }; + + std::sort(m_coinsList.begin(), m_coinsList.end(), coinCompare); + + updateCoinTable(); + updateStart(); + updateStatus(); +} + +bool DeniabilityDialog::signExternalSigner(interfaces::Wallet& wallet, CTransactionRef& tx, const QString& message) +{ + // the wallet must be unlocked before calling this function + Assert(m_model && m_model->getEncryptionStatus() != WalletModel::Locked); + + QMessageBox::StandardButton resultButton = QMessageBox::question(nullptr, tr("Confirm on device"), message, QMessageBox::Yes | QMessageBox::Cancel, QMessageBox::Cancel); + if (resultButton == QMessageBox::Cancel) { + // skip all coins to avoid spamming the user + LogPrintf("Deniability[%s]: External signing cancelled.\n", m_walletName); + return false; + } + Assert(resultButton == QMessageBox::Yes); + + CMutableTransaction mtx(*tx); + PartiallySignedTransaction psbtx(mtx); + bool complete = false; + // Always fill without signing first. This prevents an external signer + // from being called prematurely and is not expensive. + std::optional err = wallet.fillPSBT(SIGHASH_ALL, /*sign=*/false, /*bip32derivs=*/true, /*n_signed=*/nullptr, psbtx, complete); + Assert(!complete); + Assert(!err); + + try { + err = m_model->wallet().fillPSBT(SIGHASH_ALL, /*sign=*/true, /*bip32derivs=*/true, /*n_signed=*/nullptr, psbtx, complete); + } catch (const std::runtime_error& e) { + LogPrintf("Deniability[%s]: External sign failed (%s).\n", m_walletName, e.what()); + QMessageBox::critical(nullptr, tr("Sign failed"), e.what()); + return false; + } + if (err == PSBTError::EXTERNAL_SIGNER_NOT_FOUND) { + //: "External signer" means using devices such as hardware wallets. + LogPrintf("Deniability[%s]: External signer not found.\n", m_walletName); + QMessageBox::critical(nullptr, tr("External signer not found"), "External signer not found"); + return false; + } + if (err == PSBTError::EXTERNAL_SIGNER_FAILED) { + //: "External signer" means using devices such as hardware wallets. + LogPrintf("Deniability[%s]: External signer failure.\n", m_walletName); + QMessageBox::critical(nullptr, tr("External signer failure"), "External signer failure"); + return false; + } + if (err) { + LogPrintf("Deniability[%s]: PSBT failure. Failed to create transaction!\n", m_walletName); + QMessageBox::critical(nullptr, tr("PSBT failure"), "Failed to create transaction!"); + return false; + } + // fillPSBT does not always properly finalize + complete = FinalizeAndExtractPSBT(psbtx, mtx); + if (!complete) { + LogPrintf("Deniability[%s]: External signing failed.\n", m_walletName); + return false; + } + // Prepare transaction for broadcast transaction if complete + tx = MakeTransactionRef(mtx); + return true; +} + +void DeniabilityDialog::finalizeTxBroadcast(uint256 hash, CAmount txFee) +{ + // store the transaction hash so we can check up on it later + m_lastDeniabilizationTxHash = hash; + + // update the deniabilization budget with the amount spent on tx fees + Assert(m_deniabilizationBudget >= txFee); + m_deniabilizationBudget -= txFee; + // if the remaining budget is below a single tx fee, then zero it out so the deniabilization process stops + if (m_deniabilizationBudget < txFee) { + m_deniabilizationBudget = 0; + } + m_ui->budgetSpinner->setValue(m_deniabilizationBudget); + Assert(m_deniabilizationBudget == m_ui->budgetSpinner->value()); +} + +DeniabilityDialog::DeniabilizationResult DeniabilityDialog::deniabilizeCoin(CoinInfo coin) +{ + Assert(walletSupportsDeniabilization()); + Assert(coin.state.mayBeDeniabilized()); + Assert(m_deniabilizationTxInProgress); + + // draw a random percent to decide if we should split this coin + // randomizing the split decision makes the deniabilized transaction tree non-uniform and thus harder to identify + uint deniabilizationCycles = coin.deniabilizationCycles(); + float deniabilizationProbability = wallet::CalculateDeniabilizationProbability(deniabilizationCycles); + uint deniabilizationProbabilityPercent = deniabilizationProbability * 100; + Assert(deniabilizationProbabilityPercent > 0); + uint randomPercent = FastRandomContext().randrange(100u); + LogPrintf("Deniability[%s]: Random probability (%u%%), coin probability (%u%%).\n", m_walletName, randomPercent, deniabilizationProbabilityPercent); + if (randomPercent >= deniabilizationProbabilityPercent) { + // skip this coin and retry next cycle + return DENIABILIZATION_SKIP_COIN; + } + + // we need to unlock the wallet to get new addresses and prepare/sign transactions + WalletModel::UnlockContext ctx(m_model->requestUnlock()); + if (!ctx.isValid()) { + // Unlock wallet was cancelled + LogPrintf("Deniability[%s]: Wallet unlock cancelled.\n", m_walletName); + return DENIABILIZATION_SKIP_ALL_COINS; + } + + Wallet& wallet = m_model->wallet(); + + if (coin.anyLockedCoin(wallet)) { + // locked coins are not automatically selected + // so if we got here it means the user manually selected it + // and we can go ahead an unlock it + for (const auto& utxo : coin.utxos) { + if (wallet.isLockedCoin(utxo.outpoint)) { + if (!wallet.unlockCoin(utxo.outpoint)) { + // unlock failed so we'll skip the coin for this cycle + LogPrintf("Deniability[%s]: Coin unlock failed.\n", m_walletName); + return DENIABILIZATION_SKIP_COIN; + } + } + } + } + + Assert(m_deniabilizationFrequency.count() > 0); + uint confirmTarget = m_deniabilizationFrequency.count() / (60 * 10); // 60 seconds per minute, 10 minutes per block + + std::set inputs; + for (const auto& utxo : coin.utxos) { + inputs.insert(utxo.outpoint); + } + + CTransactionRef newTx; + CAmount txFee = 0; + try { + bool sign = !wallet.privateKeysDisabled(); + bool insufficientAmount = false; + auto res = wallet.createDeniabilizationTransaction(inputs, OutputTypeFromDestination(coin.destination()), confirmTarget, deniabilizationCycles, sign, insufficientAmount, txFee); + if (res) { + newTx = *res; + } else if (insufficientAmount) { + // The amount is not enough for a split, so we disable this coin from further deniabilization + LogPrintf("Deniability[%s]: %s\n", m_walletName, util::ErrorString(res).original); + return DENIABILIZATION_DESELECT_COIN; + } else { + LogPrintf("Deniability[%s]: Creating the deniabilization transaction failed (%s).\n", m_walletName, util::ErrorString(res).original); + Q_EMIT message(tr("Deniability"), tr("Creating the deniabilization transaction failed. ") + QString::fromStdString(util::ErrorString(res).translated), CClientUIInterface::MSG_ERROR); + return DENIABILIZATION_STOP; + } + } catch (const std::runtime_error& err) { + // Something unexpected happened, instruct user to report this bug. + LogPrintf("Deniability[%s]: Creating the deniabilization transaction failed (%s).\n", m_walletName, err.what()); + Q_EMIT message(tr("Deniability"), tr("Creating the deniabilization transaction failed. ") + QString::fromStdString(err.what()), CClientUIInterface::MSG_ERROR); + return DENIABILIZATION_STOP; + } + + if (txFee > m_deniabilizationBudget) { + LogPrintf("Deniability[%s]: Deniabilization budget (%d) exhausted (tx fee %d).\n", m_walletName, m_deniabilizationBudget, txFee); + Q_EMIT message(tr("Deniability"), tr("Not enough budget left for a deniabilization transaction!"), CClientUIInterface::MSG_WARNING); + return DENIABILIZATION_STOP; + } + + if (hasExternalSigner(wallet)) { + QString message = tr("Prepare to confirm the deniabilization transaction on your device.

Ready?"); + if (!signExternalSigner(wallet, newTx, message)) { + // skip all coins to avoid spamming the user + return DENIABILIZATION_SKIP_ALL_COINS; + } + } + + // Broadcast the transaction + wallet.commitTransaction(newTx, /*value_map=*/{}, /*order_form=*/{}); + + finalizeTxBroadcast(newTx->GetHash(), txFee); + return DENIABILIZATION_SUCCESS; +} + +bool DeniabilityDialog::bumpDeniabilizationTx(uint256 txid) +{ + Assert(m_model); + WalletModel::UnlockContext ctx(m_model->requestUnlock()); + if (!ctx.isValid()) { + // Unlock wallet was cancelled + LogPrintf("Deniability[%s]: Wallet unlock cancelled.\n", m_walletName); + return false; + } + Wallet& wallet = m_model->wallet(); + + Assert(m_deniabilizationFrequency.count() > 0); + uint confirmTarget = m_deniabilizationFrequency.count() / (60 * 10); // 60 seconds per minute, 10 minutes per block + + CTransactionRef newTx; + CAmount oldTxFee = 0; + CAmount newTxFee = 0; + try { + bool sign = !wallet.privateKeysDisabled(); + auto res = wallet.createBumpDeniabilizationTransaction(txid, confirmTarget, sign, oldTxFee, newTxFee); + if (res) { + newTx = *res; + } else { + LogPrintf("Deniability[%s]: Creating the deniabilization bump transaction failed (%s).\n", m_walletName, util::ErrorString(res).original); + Q_EMIT message(tr("Deniability"), tr("Creating the deniabilization bump transaction failed. ") + QString::fromStdString(util::ErrorString(res).translated), CClientUIInterface::MSG_ERROR); + return false; + } + } catch (const std::runtime_error& err) { + // Something unexpected happened, instruct user to report this bug. + LogPrintf("Deniability[%s]: Creating the deniabilization bump transaction failed (%s).\n", m_walletName, err.what()); + Q_EMIT message(tr("Deniability"), tr("Creating the deniabilization bump transaction failed. ") + QString::fromStdString(err.what()), CClientUIInterface::MSG_ERROR); + return false; + } + + if (newTxFee <= oldTxFee) { + // no point broadcasting a fee bump tx unless it's larger than the old fee + LogPrintf("Deniability[%s]: New tx fee (%d) is not larger than the old tx fee (%d).\n", m_walletName, newTxFee, oldTxFee); + return false; + } + + CAmount txFee = newTxFee - oldTxFee; + if (txFee > m_deniabilizationBudget) { + Q_EMIT message(tr("Deniability"), tr("Not enough budget left for a fee bump!"), CClientUIInterface::MSG_WARNING); + LogPrintf("Deniability[%s]: Not enough budget (%d) for a fee bump (%d).\n", m_walletName, m_deniabilizationBudget, txFee); + return false; + } + + if (hasExternalSigner(wallet)) { + QString message = tr("Prepare to confirm the fee bump of the deniabilization transaction on your device.

Ready?"); + if (!signExternalSigner(wallet, newTx, message)) { + return false; + } + } + + // commit the bumped transaction + std::vector errors; + uint256 new_hash; + if (!wallet.commitBumpTransaction(txid, CMutableTransaction(*newTx), errors, new_hash)) { + LogPrintf("Deniability[%s]: Failed to commit transaction (%s).\n", m_walletName, errors.front().original); + QMessageBox::critical(nullptr, tr("Fee bump error"), tr("Failed to commit transaction") + "
(" + QString::fromStdString(errors.front().translated) + ")"); + return false; + } + + finalizeTxBroadcast(new_hash, txFee); + return true; +} diff --git a/src/qt/deniabilitydialog.h b/src/qt/deniabilitydialog.h new file mode 100644 index 00000000000..f207d7851ee --- /dev/null +++ b/src/qt/deniabilitydialog.h @@ -0,0 +1,190 @@ +// Copyright (c) 2022- The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#ifndef BITCOIN_QT_DENIABILITYDIALOG_H +#define BITCOIN_QT_DENIABILITYDIALOG_H + +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +class ClientModel; +class WalletModel; +class PlatformStyle; +class COutPoint; +class WalletModelTransaction; +enum class SynchronizationState; +enum class SyncType; + +QT_BEGIN_NAMESPACE +class QMenu; +class QPushButton; +class QTimer; +class QTableWidgetItem; +QT_END_NAMESPACE + +namespace Ui { +class DeniabilityDialog; +} + +class DeniabilityDialog : public QDialog +{ + Q_OBJECT + +public: + explicit DeniabilityDialog(const PlatformStyle* platformStyle, QWidget* parent = nullptr); + ~DeniabilityDialog(); + + void setClientModel(ClientModel* model); + void setModel(WalletModel* model); + + bool walletSupportsDeniabilization() const; + bool hasDeniabilizationCandidates() const; + void updateCoins(); + void updateCoinsIfVisible(); + + enum { + destinationRole = Qt::UserRole, + }; + +private Q_SLOTS: + void updateCheckState(QTableWidgetItem* itemCheck); + void startDeniabilization(); + void stopDeniabilization(); + void deniabilizeProc(); + void contextualMenu(const QPoint& point); + void updateNumberOfBlocks(int count, const QDateTime& blockDate, double nVerificationProgress, SyncType synctype, SynchronizationState sync_state); +public Q_SLOTS: + void reject() override; + void accept() override; + +private: + Ui::DeniabilityDialog* m_ui; + QMenu* m_contextMenu = nullptr; + const PlatformStyle* m_platformStyle = nullptr; + + std::string m_walletName; + ClientModel* m_clientModel = nullptr; + WalletModel* m_model = nullptr; + BitcoinUnit m_displayUnit = BitcoinUnit::BTC; + + enum class Deniabilizable : uint { + YES, + YES_BUT_BLOCK_REWARD, + YES_BUT_COIN_LOCKED, + YES_BUT_TX_NOT_MATURE, + YES_BUT_AMOUNT_NOT_WORTHWHILE, + NO_FULLY_DENIABILIZED, + NO_PRIVATE_KEYS_DISABLED, + NO_AMOUNT_TOO_SMALL, + NO, + }; + + struct CoinState { + Deniabilizable deniabilizable = Deniabilizable::NO; + Qt::CheckState checkState = Qt::Unchecked; + inline bool canBeDeniabilized() const + { + return deniabilizable == Deniabilizable::YES; + } + inline bool mayBeDeniabilized() const + { + switch (deniabilizable) { + case Deniabilizable::YES: + case Deniabilizable::YES_BUT_BLOCK_REWARD: + case Deniabilizable::YES_BUT_COIN_LOCKED: + case Deniabilizable::YES_BUT_TX_NOT_MATURE: + case Deniabilizable::YES_BUT_AMOUNT_NOT_WORTHWHILE: + return true; + default: + return false; + } + } + }; + + struct DeniabilizationStats { + uint cycles = 0; + bool blockReward = false; + DeniabilizationStats() = default; + explicit DeniabilizationStats(uint _cycles, bool _blockReward) + : cycles(_cycles), blockReward(_blockReward) + { + } + }; + + struct CoinUTXO { + COutPoint outpoint; + interfaces::WalletTxOut walletTxOut; + DeniabilizationStats deniabilizationStats; + }; + + struct CoinInfo { + CoinState state; + CAmount deniabilizationFeeEstimate = 0; + std::vector utxos; + uint numUTXOs() const + { + return (uint)utxos.size(); + } + CScript scriptPubKey() const; + CTxDestination destination() const; + uint256 hash() const; + CAmount value() const; + int depthInMainChain() const; + uint deniabilizationCycles() const; + bool allUTXOsAreBlockReward() const; + bool anyLockedCoin(interfaces::Wallet& wallet) const; + }; + std::vector m_coinsList; + + std::map m_coinStateMap; + + QTimer* m_deniabilizeProcTimer = nullptr; + bool m_deniabilizationProcessAccepted = false; + bool m_deniabilizationTxInProgress = false; + std::optional m_lastDeniabilizationTxHash; + std::optional m_nextDeniabilizationCycle; + std::chrono::seconds m_deniabilizationFrequency = std::chrono::seconds::zero(); + CAmount m_deniabilizationBudget = 0; + + void clear(); + void setupTableWidget(); + void loadSettings(); + void saveSettings(); + void updateStart(); + void updateStatus(); + void updateCoinTable(); + + bool signExternalSigner(interfaces::Wallet& wallet, CTransactionRef& tx, const QString& message); + void finalizeTxBroadcast(uint256 hash, CAmount txFee); + + enum DeniabilizationResult : uint { + DENIABILIZATION_SUCCESS, + DENIABILIZATION_SKIP_COIN, + DENIABILIZATION_SKIP_ALL_COINS, + DENIABILIZATION_DESELECT_COIN, + DENIABILIZATION_STOP, + }; + + // deniabilize a given coin (passed by value to avoid crashes if m_coinsList gets updated while a tx is being built) + DeniabilizationResult deniabilizeCoin(CoinInfo coin); + + // bump the fee of a deniabilization transaction + bool bumpDeniabilizationTx(uint256 txid); + +Q_SIGNALS: + // Fired when a message should be reported to the user + void message(const QString& title, const QString& message, unsigned int style); +}; + +#endif // BITCOIN_QT_DENIABILITYDIALOG_H diff --git a/src/qt/forms/deniabilitydialog.ui b/src/qt/forms/deniabilitydialog.ui new file mode 100644 index 00000000000..f1ed5d3a08c --- /dev/null +++ b/src/qt/forms/deniabilitydialog.ui @@ -0,0 +1,179 @@ + + + DeniabilityDialog + + + + 0 + 0 + 952 + 808 + + + + Deniability + + + + + + Candidate coins for deniabilization: + + + + + + + + Noto Mono + + + + + + + + + + + + Frequency at which to perform a deniabilization cycle. Less frequent provides better privacy and usually has lower fees. + + + Frequency: + + + + + + + Hourly + + + + + + + Daily + + + true + + + + + + + Weekly + + + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 40 + 20 + + + + + + + + -1 + + + 0 + + + + + Total budget to spend on deniabilization transactions + + + Budget: + + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + false + + + Start + + + false + + + + + + + false + + + Stop + + + false + + + + + + + + + + + QFrame::StyledPanel + + + + + + + + + + + BitcoinAmountField + QLineEdit +
qt/bitcoinamountfield.h
+ 1 +
+
+ + +
diff --git a/src/qt/res/icons/crosseye.png b/src/qt/res/icons/crosseye.png new file mode 100644 index 00000000000..a6eb14d38dd Binary files /dev/null and b/src/qt/res/icons/crosseye.png differ diff --git a/src/qt/test/wallettests.cpp b/src/qt/test/wallettests.cpp index 603df0b15f2..d1e7af94b0b 100644 --- a/src/qt/test/wallettests.cpp +++ b/src/qt/test/wallettests.cpp @@ -5,13 +5,13 @@ #include #include -#include #include #include #include #include #include #include +#include #include #include #include @@ -25,8 +25,10 @@ #include #include #include