From 6d07de6c723cbbb0d7ea26f1160434dc93ad534f Mon Sep 17 00:00:00 2001 From: Denny Avila Date: Wed, 31 May 2023 00:09:02 -0700 Subject: [PATCH] Deniability - a tool to automatically improve coin ownership privacy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This new feature is an implementation of the ideas in Paul Sztorc's blog post "Deniability - Unilateral Transaction Meta-Privacy"(https://www.truthcoin.info/blog/deniability/). In short, the idea is to periodically split coins and send them to yourself, making it look like a common "spend" transaction, such that blockchain ownership analysis becomes more difficult, and thus improving the user's privacy. This is the GUI portion of the PR (bitcoin-core/gui). The core functionality PR is in the main repo (bitcoin/bitcoin). This PR implements an additional "Deniability" wallet view. The majority of the GUI code is in a new deniabilitydialog.cpp/h source files containing a new DeniabilityDialog class, hooked up via the WalletView class.  On startup and on notable events (new blocks, new transactions, etc), we evaluate the privacy of all coins in the wallet, and we build a "deniabilization" candidate list. UTXOs that share the same destination address are grouped together into a single candidate (see DeniabilityDialog::updateCoins and DeniabilityDialog::updateCoinTable). We inspect the blockchain data to find out if we have performed "deniabilization" transactions already, and we count how many "cycles" (self-sends) have been performed for each coin (see DeniabilityDialog::calculateDeniabilizationStats). Since we infer the state entirely from the blockchain data, even if the wallet is restored from a seed phrase, the state would not be lost. This also means that if the user has performed manual self-sends that have improved the ownership privacy, they will be counted too. The user can initiate the "deniabillization" process by pressing a Start button (DeniabilityDialog::startDeniabilization). The process periodically perform a "deniabilization" cycle (DeniabilityDialog::deniabilizeProc). Each such cycle goes as follows: A coin is selected form the candidate list. The more a coin is "deniabilized", the less likely it is to be selected. Smaller coins are also less likely to be selected. If a coin is selected, we prepare and broadcast a transaction, which splits the coin into a pair of new wallet addresses (DeniabilityDialog::deniabilizeCoin).  The user can control this process via a Frequency selector and a Budget spinner, which respectively determine how often to perform the cycle and how much total BTC to spend on transaction fees. If Bitcoin Core is left running continuously, the cycles would be performed at the selected frequency (with some randomization). If Bitcoin Core is shutdown, the "deniabilization" process will resume at the next restart, and if more time has elapsed than the selected frequency, it will perform a single cycle. We deliberately don't "catch up" all missed cycles, since that would expose the process to blockchain analysis. The state is saved and restored via QSettings (DeniabilityDialog::loadSettings and DeniabilityDialog::saveSettings). We monitor each broadcasted transaction and we automatically attempt a fee bump if the transaction is still in the memory pool since the previous cycle (DeniabilityDialog::bumpDeniabilizationTx). We don't issue any other deniabilization transactions until the previous transaction is confirmed (or abandoned/dropped). The process ends when the budget is exhausted or if there's no candidates left. The user can also stop the process manually by pressing a Stop button (DeniabilityDialog::stopDeniabilization). External signers are supported in a "best effort" way - since the user needs to manually sign, we postpone the processing till the external signer is connected and use some additional UI to get the user's attention to sign (see the codepath predicated on hasExternalSigner). This is not ideal, so I'm looking for some ideas if we can improve this in some way. Watch-only wallets are partially supported, where we display the candidate list, but we don't allow any processing (since we don't have the private keys to issue transactions). I've tested all this functionality on regtest, testnet, signet and mainnet. I've also added some unit tests (under WalletTests) to exercise the main GUI functionality. This is my first change and PR for Bitcoin Core, and I tried as much as possible to validate everything against the guidelines and documentation and to follow the patterns in the existing code, but I'm sure there are things I missed, so I'm looking forward to your feedback. In particular things I'm not very sure about - the save/restore of state via QSettings makes me a bit nervous as we store some wallet specific data there which I put some effort to validate on load, however keying the settings based on wallet name is not ideal, so I'd like to improve this somehow - either by storing the settings based on some wallet identity signature, or by storing the state in the wallet database (however that doesn't seem accessible via the interfaces::Wallet API). Please let me know your thoughts and suggestions. Thank you. --- .../libbitcoin_qt/libbitcoin_qt.vcxproj | 2 + src/Makefile.qt.include | 5 + src/qt/bitcoin.qrc | 1 + src/qt/bitcoingui.cpp | 18 + src/qt/bitcoingui.h | 3 + src/qt/deniabilitydialog.cpp | 1441 +++++++++++++++++ src/qt/deniabilitydialog.h | 190 +++ src/qt/forms/deniabilitydialog.ui | 179 ++ src/qt/res/icons/crosseye.png | Bin 0 -> 21985 bytes src/qt/test/wallettests.cpp | 222 ++- src/qt/walletframe.cpp | 7 + src/qt/walletframe.h | 2 + src/qt/walletview.cpp | 16 + src/qt/walletview.h | 4 + 14 files changed, 2083 insertions(+), 7 deletions(-) create mode 100644 src/qt/deniabilitydialog.cpp create mode 100644 src/qt/deniabilitydialog.h create mode 100644 src/qt/forms/deniabilitydialog.ui create mode 100644 src/qt/res/icons/crosseye.png 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 7852d1a2fa9..49a4b87f4d3 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 d26ef52eb43..1656f5b96a2 100644 --- a/src/qt/bitcoingui.cpp +++ b/src/qt/bitcoingui.cpp @@ -278,6 +278,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. @@ -289,6 +296,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 @@ -789,6 +799,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); @@ -947,6 +958,12 @@ void BitcoinGUI::gotoHistoryPage() if (walletFrame) walletFrame->gotoHistoryPage(); } +void BitcoinGUI::gotoDeniabilityPage() +{ + deniabilityAction->setChecked(true); + if (walletFrame) walletFrame->gotoDeniabilityPage(); +} + void BitcoinGUI::gotoReceiveCoinsPage() { receiveCoinsAction->setChecked(true); @@ -1246,6 +1263,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 92b889263ba..b31b1be140f 100644 --- a/src/qt/bitcoingui.h +++ b/src/qt/bitcoingui.h @@ -136,6 +136,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; @@ -277,6 +278,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..82851f9df23 --- /dev/null +++ b/src/qt/deniabilitydialog.cpp @@ -0,0 +1,1441 @@ +// 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 +#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 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; +} + +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())); + + QAction* copyAddress = new QAction(tr("Copy Address"), this); + m_contextMenu = new QMenu(this); + m_contextMenu->setObjectName("contextMenuDeniability"); + 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()); + } + }); +} + +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 (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 + GetRand(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(); + + auto coinsListMap = wallet.listCoins(); + m_coinsList.reserve(coinsListMap.size()); + for (const auto& coinsPair : coinsListMap) { + const auto& destination = coinsPair.first; + if (!IsValidDestination(destination)) + continue; + CScript script = GetScriptForDestination(destination); + if (script.IsUnspendable()) + continue; + const auto& coinsTuples = coinsPair.second; + + CoinInfo coin; + coin.destination = destination; + coin.utxos.reserve(coinsTuples.size()); + 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; + auto result = wallet.calculateDeniabilizationCycles(output.outpoint); + output.deniabilizationStats = DeniabilizationStats(result.first, result.second); + coin.utxos.push_back(std::move(output)); + } + // skip any addresses with no unspent outputs + if (coin.utxos.empty()) { + continue; + } + + // 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(); + CAmount dustThreshold = GetDustThreshold(CTxOut(coinValue, GetScriptForDestination(coin.destination)), dustRelayFee); + uint deniabilizationCycles = coin.deniabilizationCycles(); + float deniabilizationProbability = wallet::CalculateDeniabilizationProbability(deniabilizationCycles); + uint deniabilizationProbabilityPercent = deniabilizationProbability * 100; + coin.deniabilizationFeeEstimate = wallet::CalculateDeniabilizationFeeEstimate(coin.destination, 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. + TransactionError err = wallet.fillPSBT(SIGHASH_ALL, /*sign=*/false, /*bip32derivs=*/true, /*n_signed=*/nullptr, psbtx, complete); + Assert(!complete); + Assert(err == TransactionError::OK); + + 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 == TransactionError::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 == TransactionError::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 != TransactionError::OK) { + 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 = GetRand(100); + 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, 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..4a101f84e12 --- /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