From 87e5be44736668039ddb85cfef5041c6112a5d64 Mon Sep 17 00:00:00 2001 From: Konstantin Akimov Date: Wed, 21 Sep 2022 11:22:54 -0400 Subject: [PATCH 1/6] Merge bitcoin/bitcoin#25933: wallet: AvailableCoins, simplify output script type acquisition Dash BACKPORT NOTE: - upstream's `is_from_p2sh` flag feeds `GetOutputType(type, is_from_p2sh)` to tell P2SH-P2WPKH/P2SH-P2WSH apart from plain P2SH, and `CoinsResult::Add()` only exists on the `std::map` shape introduced by bitcoin#25734. Dash has neither segwit nor that shape (see `partial bitcoin#24584`), so the flag is dropped and the existing `switch (type)` over `result.legacy` / `result.other` is kept as is. - the `if (!ExtractDestination(...)) continue;` early-out disappears, matching upstream: `Solver` already yields the script hash for `TxoutType::SCRIPTHASH` and cannot fail for it. 58b7df3caa21519de61e10f6ee42f0be9ac3cc30 wallet: AvailableCoins, simplify output script type acquisition (furszy) Pull request description: There is an unnecessary `ExtractDestination()` call and subsequent result parse into an `CScriptID`. The `Solver()` call, which we are already doing below anyway, retrieves the script type and, in the P2SH case, the program id. ACKs for top commit: achow101: ACK 58b7df3caa21519de61e10f6ee42f0be9ac3cc30 aureleoules: re-ACK 58b7df3caa21519de61e10f6ee42f0be9ac3cc30 rajarshimaitra: ACK 58b7df3caa21519de61e10f6ee42f0be9ac3cc30 w0xlt: ACK https://github.com/bitcoin/bitcoin/pull/25933/commits/58b7df3caa21519de61e10f6ee42f0be9ac3cc30 Tree-SHA512: 51080766877c34cb2232ee3a1cb6b6a62b829c9297c67b99577742b94854a737a74d248015a4603ca9b6cd0a3c9e1d6d78673ff3cc9fc65dd82deea72dc537fd Co-authored-by: Andrew Chow --- src/wallet/spend.cpp | 26 +++++++++----------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/src/wallet/spend.cpp b/src/wallet/spend.cpp index 9c8132804272..486f9183cee5 100644 --- a/src/wallet/spend.cpp +++ b/src/wallet/spend.cpp @@ -218,26 +218,18 @@ CoinsResult AvailableCoins(const CWallet& wallet, // Filter by spendable outputs only if (!spendable && only_spendable) continue; - // When parsing a scriptPubKey, Solver returns the parsed pubkeys or hashes (depending on the script) - // We don't need those here, so we are leaving them in return_values_unused - std::vector> return_values_unused; - TxoutType type; + // Obtain script type + std::vector> script_solutions; + TxoutType type = Solver(output.scriptPubKey, script_solutions); - // If the Output is P2SH and spendable, we want to know if it is + // If the output is P2SH and solvable, we want to know if it is // a P2SH (legacy). We can determine this from the redeemScript. - // If the Output is not spendable, it will be classified as a P2SH (legacy), + // If the output is not solvable, it will be classified as a P2SH (legacy), // since we have no way of knowing otherwise without the redeemScript - if (output.scriptPubKey.IsPayToScriptHash() && solvable) { - CScript redeemScript; - CTxDestination destination; - if (!ExtractDestination(output.scriptPubKey, destination)) - continue; - const CScriptID& hash = CScriptID(std::get(destination)); - if (!provider->GetCScript(hash, redeemScript)) - continue; - type = Solver(redeemScript, return_values_unused); - } else { - type = Solver(output.scriptPubKey, return_values_unused); + if (type == TxoutType::SCRIPTHASH && solvable) { + CScript script; + if (!provider->GetCScript(CScriptID(uint160(script_solutions[0])), script)) continue; + type = Solver(script, script_solutions); } COutput coin(outpoint, output, nDepth, input_bytes, spendable, solvable, safeTx, wtx.GetTxTime(), tx_from_me, feerate); From 01c570196a7a61f917b77465da6850f1e6622215 Mon Sep 17 00:00:00 2001 From: Konstantin Akimov Date: Mon, 15 Aug 2022 19:35:52 +0100 Subject: [PATCH 2/6] Merge bitcoin-core/gui#598: Avoid recalculating the wallet balance - use model cache 4584d300a40bfd84517072f7a6eee114fb7cab08 GUI: remove now unneeded 'm_balances' field from overviewpage (furszy) 050e8b139145d6991e740b0e5f2b3364663dd348 GUI: 'getAvailableBalance', use cached balance if the user did not select UTXO manually (furszy) 96e3264a82c51b456703f500bd98e8cb98115697 GUI: use cached balance in overviewpage and sendcoinsdialog (furszy) 321335bf0292034d79afa6c44f7f072942b6cc3c GUI: add getter for WalletModel::m_cached_balances field (furszy) e62958dc81d215a1c56318d0914dfd9a33d45973 GUI: sendCoinsDialog, remove duplicate wallet().getBalances() call (furszy) Pull request description: As per the title says, we are recalculating the entire wallet balance on different situations calling to `wallet().getBalances()`, when should instead make use of the wallet model cached balance. This has the benefits of (1) not spending resources calculating a balance that we already have cached, and (2) avoid blocking the main thread for a long time, in case of big wallets, walking through the entire wallet's tx map more than what it's really needed. Changes: 1) Fix: `SendCoinsDialog` was calling `wallet().getBalances()` twice during `setModel`. 2) Use the cached balance if the user did not select any UTXO manually inside the wallet model `getAvailableBalance` call. ----------------------- As an extra note, this work born in [#25005](https://github.com/bitcoin/bitcoin/pull/25005) but grew out of scope of it. ACKs for top commit: jarolrod: ACK 4584d300a40bfd84517072f7a6eee114fb7cab08 hebasto: re-ACK 4584d300a40bfd84517072f7a6eee114fb7cab08, only suggested changes and commit message formatting since my [recent](https://github.com/bitcoin-core/gui/pull/598#pullrequestreview-1071268192) review. Tree-SHA512: 6633ce7f9a82a3e46e75aa7295df46c80a4cd4a9f3305427af203c9bc8670573fa8a1927f14a279260c488cc975a08d238faba2e9751588086fea1dcf8ea2b28 Co-authored-by: Hennadii Stepanov <32963518+hebasto@users.noreply.github.com> --- src/qt/overviewpage.cpp | 36 +++++++++++++++++------------------- src/qt/overviewpage.h | 1 - src/qt/sendcoinsdialog.cpp | 12 +++++------- src/qt/sendcoinsdialog.h | 2 +- src/qt/test/wallettests.cpp | 31 ++++++++++++++++--------------- src/qt/walletmodel.cpp | 20 ++++++++++++++++++-- src/qt/walletmodel.h | 7 +++++++ 7 files changed, 64 insertions(+), 45 deletions(-) diff --git a/src/qt/overviewpage.cpp b/src/qt/overviewpage.cpp index 9ce945530a5b..e827820cbc25 100644 --- a/src/qt/overviewpage.cpp +++ b/src/qt/overviewpage.cpp @@ -163,8 +163,6 @@ OverviewPage::OverviewPage(QWidget* parent) : GUIUtil::updateFonts(); - m_balances.balance = -1; - // Recent transactions ui->listTransactions->setItemDelegate(txdelegate); // Note: minimum height of listTransactions will be set later in updateAdvancedCJUI() to reflect actual settings @@ -202,8 +200,9 @@ void OverviewPage::setPrivacy(bool privacy) { m_privacy = privacy; clientModel->getOptionsModel()->setOption(OptionsModel::OptionID::MaskValues, privacy); - if (m_balances.balance != -1) { - setBalance(m_balances); + const auto& balances = walletModel->getCachedBalance(); + if (balances.balance != -1) { + setBalance(balances); coinJoinStatus(true); } @@ -226,7 +225,6 @@ OverviewPage::~OverviewPage() void OverviewPage::setBalance(const interfaces::WalletBalances& balances) { BitcoinUnit unit = walletModel->getOptionsModel()->getDisplayUnit(); - m_balances = balances; if (walletModel->wallet().isLegacy()) { if (walletModel->wallet().privateKeysDisabled()) { ui->labelBalance->setText(BitcoinUnits::floorHtmlWithPrivacy(unit, balances.watch_only_balance, BitcoinUnits::SeparatorStyle::ALWAYS, m_privacy)); @@ -312,12 +310,11 @@ void OverviewPage::setWalletModel(WalletModel *model) // update the display unit, to not use the default ("DASH") updateDisplayUnit(); // Keep up to date with wallet - interfaces::Wallet& wallet = model->wallet(); - interfaces::WalletBalances balances = wallet.getBalances(); - setBalance(balances); + setBalance(model->getCachedBalance()); connect(model, &WalletModel::balanceChanged, this, &OverviewPage::setBalance); - updateWatchOnlyLabels((wallet.haveWatchOnly() && !model->wallet().privateKeysDisabled()) || gArgs.GetBoolArg("-debug-ui", false)); + interfaces::Wallet& wallet = model->wallet(); + updateWatchOnlyLabels((wallet.haveWatchOnly() && !wallet.privateKeysDisabled()) || gArgs.GetBoolArg("-debug-ui", false)); connect(model, &WalletModel::notifyWatchonlyChanged, [this](bool showWatchOnly) { updateWatchOnlyLabels(showWatchOnly && !walletModel->wallet().privateKeysDisabled()); }); @@ -348,11 +345,11 @@ void OverviewPage::setWalletModel(WalletModel *model) void OverviewPage::updateDisplayUnit() { - if(walletModel && walletModel->getOptionsModel()) - { + if (walletModel && walletModel->getOptionsModel()) { m_display_bitcoin_unit = walletModel->getOptionsModel()->getDisplayUnit(); - if (m_balances.balance != -1) { - setBalance(m_balances); + const auto& balances = walletModel->getCachedBalance(); + if (balances.balance != -1) { + setBalance(balances); } // Update txdelegate->unit with the current unit @@ -404,7 +401,8 @@ void OverviewPage::updateCoinJoinProgress() QString strAmountAndRounds; QString strCoinJoinAmount = BitcoinUnits::formatHtmlWithUnit(m_display_bitcoin_unit, clientModel->coinJoinOptions().getAmount() * COIN, false, BitcoinUnits::SeparatorStyle::ALWAYS); - if(m_balances.balance == 0) + const auto& balances = walletModel->getCachedBalance(); + if(balances.balance == 0) { ui->coinJoinProgress->setValue(0); ui->coinJoinProgress->setToolTip(tr("No inputs detected")); @@ -420,7 +418,7 @@ void OverviewPage::updateCoinJoinProgress() CAmount nAnonymizableBalance = walletModel->wallet().getAnonymizableBalance(false, false); - CAmount nMaxToAnonymize = nAnonymizableBalance + m_balances.anonymized_balance; + CAmount nMaxToAnonymize = nAnonymizableBalance + balances.anonymized_balance; // If it's more than the anon threshold, limit to that. if (nMaxToAnonymize > clientModel->coinJoinOptions().getAmount() * COIN) nMaxToAnonymize = clientModel->coinJoinOptions().getAmount() * COIN; @@ -451,7 +449,6 @@ void OverviewPage::updateCoinJoinProgress() if (!fShowAdvancedCJUI) return; - const interfaces::WalletBalances balances = walletModel->wallet().getBalances(); CAmount nDenominatedConfirmedBalance = balances.denominated_trusted; CAmount nDenominatedUnconfirmedBalance = balances.denominated_untrusted_pending; CAmount nNormalizedAnonymizedBalance; @@ -477,7 +474,7 @@ void OverviewPage::updateCoinJoinProgress() anonNormPart = anonNormPart > 1 ? 1 : anonNormPart; anonNormPart *= 100; - anonFullPart = (float)m_balances.anonymized_balance / nMaxToAnonymize; + anonFullPart = (float)balances.anonymized_balance / nMaxToAnonymize; anonFullPart = anonFullPart > 1 ? 1 : anonFullPart; anonFullPart *= 100; @@ -692,7 +689,7 @@ void OverviewPage::coinJoinStatus(bool fForce) setWidgetsVisible(true); } -void OverviewPage::toggleCoinJoin(){ +void OverviewPage::toggleCoinJoin() { QSettings settings; // Popup some information on first mixing QString hasMixed = settings.value("hasMixed").toString(); @@ -707,9 +704,10 @@ void OverviewPage::toggleCoinJoin(){ bool mixing{false}; walletModel->withCoinJoin([&](auto& client) { mixing = client.isMixing(); }); if (!mixing) { + const auto& balances = walletModel->getCachedBalance(); auto& options = walletModel->node().coinJoinOptions(); const CAmount nMinAmount = options.getSmallestDenomination() + options.getMaxCollateralAmount(); - if(m_balances.balance < nMinAmount) { + if(balances.balance < nMinAmount) { QString strMinAmount(BitcoinUnits::formatWithUnit(m_display_bitcoin_unit, nMinAmount)); QMessageBox::warning(this, strCoinJoinName, tr("%1 requires at least %2 to use.").arg(strCoinJoinName).arg(strMinAmount), diff --git a/src/qt/overviewpage.h b/src/qt/overviewpage.h index b1a5c2f047ea..a12d74e5705b 100644 --- a/src/qt/overviewpage.h +++ b/src/qt/overviewpage.h @@ -54,7 +54,6 @@ public Q_SLOTS: Ui::OverviewPage *ui; ClientModel* clientModel{nullptr}; WalletModel* walletModel{nullptr}; - interfaces::WalletBalances m_balances; bool m_privacy{false}; BitcoinUnit m_display_bitcoin_unit; bool fShowAdvancedCJUI; diff --git a/src/qt/sendcoinsdialog.cpp b/src/qt/sendcoinsdialog.cpp index 1b25c171abb5..c8073ebdc378 100644 --- a/src/qt/sendcoinsdialog.cpp +++ b/src/qt/sendcoinsdialog.cpp @@ -176,11 +176,9 @@ void SendCoinsDialog::setModel(WalletModel *_model) } } - interfaces::WalletBalances balances = _model->wallet().getBalances(); - setBalance(balances); connect(_model, &WalletModel::balanceChanged, this, &SendCoinsDialog::setBalance); - connect(_model->getOptionsModel(), &OptionsModel::displayUnitChanged, this, &SendCoinsDialog::updateDisplayUnit); - updateDisplayUnit(); + connect(_model->getOptionsModel(), &OptionsModel::displayUnitChanged, this, &SendCoinsDialog::refreshBalance); + refreshBalance(); // Coin Control connect(_model->getOptionsModel(), &OptionsModel::displayUnitChanged, this, &SendCoinsDialog::coinControlUpdateLabels); @@ -822,9 +820,9 @@ void SendCoinsDialog::setBalance(const interfaces::WalletBalances& balances) } } -void SendCoinsDialog::updateDisplayUnit() +void SendCoinsDialog::refreshBalance() { - setBalance(model->wallet().getBalances()); + setBalance(model->getCachedBalance()); coinControlUpdateLabels(); ui->customFee->setDisplayUnit(model->getOptionsModel()->getDisplayUnit()); updateSmartFeeLabel(); @@ -896,7 +894,7 @@ void SendCoinsDialog::useAvailableBalance(SendCoinsEntry* entry) m_coin_control->fAllowWatchOnly = model->wallet().privateKeysDisabled() && !model->wallet().hasExternalSigner(); // Calculate available amount to send. - CAmount amount = model->wallet().getAvailableBalance(*m_coin_control); + CAmount amount = model->getAvailableBalance(m_coin_control.get()); for (int i = 0; i < ui->entries->count(); ++i) { SendCoinsEntry* e = qobject_cast(ui->entries->itemAt(i)->widget()); if (e && !e->isHidden() && e != entry) { diff --git a/src/qt/sendcoinsdialog.h b/src/qt/sendcoinsdialog.h index d691f1555195..65a65999a579 100644 --- a/src/qt/sendcoinsdialog.h +++ b/src/qt/sendcoinsdialog.h @@ -99,7 +99,7 @@ private Q_SLOTS: void on_buttonMinimizeFee_clicked(); void removeEntry(SendCoinsEntry* entry); void useAvailableBalance(SendCoinsEntry* entry); - void updateDisplayUnit(); + void refreshBalance(); void coinControlFeatureChanged(bool); void coinControlButtonClicked(); void coinControlChangeChecked(int); diff --git a/src/qt/test/wallettests.cpp b/src/qt/test/wallettests.cpp index 1394ac1fdea1..3be37a434f34 100644 --- a/src/qt/test/wallettests.cpp +++ b/src/qt/test/wallettests.cpp @@ -100,6 +100,15 @@ QModelIndex FindTx(const QAbstractItemModel& model, const uint256& txid) return {}; } +void CompareBalance(WalletModel& walletModel, CAmount expected_balance, QLabel* balance_label_to_check, bool use_privacy_formatting) +{ + BitcoinUnit unit = walletModel.getOptionsModel()->getDisplayUnit(); + QString balanceComparison = use_privacy_formatting + ? BitcoinUnits::floorHtmlWithPrivacy(unit, expected_balance, BitcoinUnits::SeparatorStyle::ALWAYS, false) + : BitcoinUnits::formatWithUnit(unit, expected_balance, false/*, BitcoinUnits::SeparatorStyle::ALWAYS*/); + QCOMPARE(balance_label_to_check->text().trimmed(), balanceComparison); +} + //! Simple qt wallet tests. // // Test widgets can be debugged interactively calling show() on them and @@ -162,15 +171,10 @@ void TestGUI(interfaces::Node& node) sendCoinsDialog.setModel(&walletModel); transactionView.setModel(&walletModel); - { - // Check balance in send dialog - QLabel* balanceLabel = sendCoinsDialog.findChild("labelBalance"); - QString balanceText = balanceLabel->text(); - BitcoinUnit unit = walletModel.getOptionsModel()->getDisplayUnit(); - CAmount balance = walletModel.wallet().getBalance(); - QString balanceComparison = BitcoinUnits::formatWithUnit(unit, balance, false /*, BitcoinUnits::SeparatorStyle::ALWAYS*/); - QCOMPARE(balanceText, balanceComparison); - } + // Update walletModel cached balance which will trigger an update for the 'labelBalance' QLabel. + walletModel.pollBalanceChanged(); + // Check balance in send dialog + CompareBalance(walletModel, walletModel.wallet().getBalance(), sendCoinsDialog.findChild("labelBalance"), false); // Send two transactions, and verify they are added to transaction list. TransactionTableModel* transactionTableModel = walletModel.getTransactionTableModel(); @@ -187,12 +191,8 @@ void TestGUI(interfaces::Node& node) OverviewPage overviewPage; overviewPage.setClientModel(&clientModel); overviewPage.setWalletModel(&walletModel); - QLabel* balanceLabel = overviewPage.findChild("labelBalance"); - QString balanceText = balanceLabel->text().trimmed(); - BitcoinUnit unit = walletModel.getOptionsModel()->getDisplayUnit(); - CAmount balance = walletModel.wallet().getBalance(); - QString balanceComparison = BitcoinUnits::floorHtmlWithPrivacy(unit, balance, BitcoinUnits::SeparatorStyle::ALWAYS, false); - QCOMPARE(balanceText, balanceComparison); + walletModel.pollBalanceChanged(); // Manual balance polling update + CompareBalance(walletModel, walletModel.wallet().getBalance(), overviewPage.findChild("labelBalance"), true); // Check that each autobackup failure state selects its specific tooltip on the CoinJoin status label { @@ -238,6 +238,7 @@ void TestGUI(interfaces::Node& node) QPushButton* requestPaymentButton = receiveCoinsDialog.findChild("receiveButton"); requestPaymentButton->click(); QString address; + BitcoinUnit unit = walletModel.getOptionsModel()->getDisplayUnit(); for (QWidget* widget : QApplication::topLevelWidgets()) { if (widget->inherits("ReceiveRequestDialog")) { ReceiveRequestDialog* receiveRequestDialog = qobject_cast(widget); diff --git a/src/qt/walletmodel.cpp b/src/qt/walletmodel.cpp index d4f25cee6410..da9490aa3657 100644 --- a/src/qt/walletmodel.cpp +++ b/src/qt/walletmodel.cpp @@ -73,6 +73,10 @@ WalletModel::~WalletModel() void WalletModel::startPollBalance() { + // Update the cached balance right away, so every view can make use of it, + // so them don't need to waste resources recalculating it. + pollBalanceChanged(); + // This timer will be fired repeatedly to update the balance // Since the QTimer::timeout is a private signal, it cannot be used // in the GUIUtil::ExceptionSafeConnect directly. @@ -137,12 +141,17 @@ void WalletModel::pollBalanceChanged() void WalletModel::checkBalanceChanged(const interfaces::WalletBalances& new_balances) { - if(new_balances.balanceChanged(m_cached_balances)) { + if (new_balances.balanceChanged(m_cached_balances)) { m_cached_balances = new_balances; Q_EMIT balanceChanged(new_balances); } } +interfaces::WalletBalances WalletModel::getCachedBalance() const +{ + return m_cached_balances; +} + void WalletModel::updateTransaction() { // Balance and number of transactions might have changed @@ -258,7 +267,9 @@ WalletModel::SendCoinsReturn WalletModel::prepareTransaction(WalletModelTransact } } - CAmount nBalance = m_wallet->getAvailableBalance(coinControl); + // If no coin was manually selected, use the cached balance + // Future: can merge this call with 'createTransaction'. + CAmount nBalance = getAvailableBalance(&coinControl); if(total > nBalance) { @@ -633,3 +644,8 @@ uint256 WalletModel::getLastBlockProcessed() const { return m_client_model ? m_client_model->getBestBlockHash() : uint256{}; } + +CAmount WalletModel::getAvailableBalance(const CCoinControl* control) +{ + return control && control->HasSelected() ? wallet().getAvailableBalance(*control) : getCachedBalance().balance; +} diff --git a/src/qt/walletmodel.h b/src/qt/walletmodel.h index 262c766ae482..7c07875092c9 100644 --- a/src/qt/walletmodel.h +++ b/src/qt/walletmodel.h @@ -163,6 +163,13 @@ class WalletModel : public QObject uint256 getLastBlockProcessed() const; + // Retrieve the cached wallet balance + interfaces::WalletBalances getCachedBalance() const; + + // If coin control has selected outputs, searches the total amount inside the wallet. + // Otherwise, uses the wallet's cached available balance. + CAmount getAvailableBalance(const wallet::CCoinControl* control); + private: std::unique_ptr m_wallet; std::unique_ptr m_handler_unload; From 2c5fa1952eac0e6e99ac14a7dc4789b994d68038 Mon Sep 17 00:00:00 2001 From: furszy Date: Wed, 14 Dec 2022 22:30:59 -0300 Subject: [PATCH 3/6] partial Merge bitcoin/bitcoin#26699: wallet, gui: bugfix, getAvailableBalance skips selected coins BACKPORT NOTE It includes this commit: cd98b717398f7b13ace91ea9efac9ce1e60b4d62 gui: 'getAvailableBalance', include watch only balance (furszy) ---- Only for wallets with private keys disabled. The returned amount need to include the watch-only available balance too. Solves #26687. --- src/qt/walletmodel.cpp | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/qt/walletmodel.cpp b/src/qt/walletmodel.cpp index da9490aa3657..96776fb452d2 100644 --- a/src/qt/walletmodel.cpp +++ b/src/qt/walletmodel.cpp @@ -647,5 +647,17 @@ uint256 WalletModel::getLastBlockProcessed() const CAmount WalletModel::getAvailableBalance(const CCoinControl* control) { - return control && control->HasSelected() ? wallet().getAvailableBalance(*control) : getCachedBalance().balance; + // No selected coins, return the cached balance + if (!control || !control->HasSelected()) { + const interfaces::WalletBalances& balances = getCachedBalance(); + CAmount available_balance = balances.balance; + // if wallet private keys are disabled, this is a watch-only wallet + // so, let's include the watch-only balance. + if (balances.have_watch_only && m_wallet->privateKeysDisabled()) { + available_balance += balances.watch_only_balance; + } + return available_balance; + } + // Fetch balance from the wallet, taking into account the selected coins + return wallet().getAvailableBalance(*control); } From b8c7d4a5f179d1034f169c087201999a479c8fb0 Mon Sep 17 00:00:00 2001 From: Konstantin Akimov Date: Tue, 11 Aug 2026 22:25:30 +0700 Subject: [PATCH 4/6] fix(qt): adopt cached balances model for coinjoin Firstly the wallet-side getAvailableBalance() reports only spend fully mixed inputs in case of UseCoinJoin(). Secondly, since the advanced CoinJoin view reads denominated_trusted and denominated_untrusted_pending from WalletModel's balance cache instead of querying the wallet on every update, those two fields have to take part in the cache invalidation check. They can move on their own: CachedTxGetAvailableCoinJoinCredits() marks a credit unconfirmed while the transaction is trusted and at depth 0, whereas GetBalance() already counts a trusted 0-conf transaction in m_mine_trusted. Confirming a self-created denominating transaction therefore only shifts an amount from denominated_untrusted_pending to denominated_trusted, leaving every field balanceChanged() compared untouched, and the progress bar kept using the stale values. --- src/interfaces/wallet.h | 4 +++- src/qt/walletmodel.cpp | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/interfaces/wallet.h b/src/interfaces/wallet.h index 6bcde7e75af5..643f45c52a7c 100644 --- a/src/interfaces/wallet.h +++ b/src/interfaces/wallet.h @@ -511,7 +511,9 @@ struct WalletBalances return balance != prev.balance || unconfirmed_balance != prev.unconfirmed_balance || anonymized_balance != prev.anonymized_balance || immature_balance != prev.immature_balance || watch_only_balance != prev.watch_only_balance || unconfirmed_watch_only_balance != prev.unconfirmed_watch_only_balance || - immature_watch_only_balance != prev.immature_watch_only_balance; + immature_watch_only_balance != prev.immature_watch_only_balance || + denominated_untrusted_pending != prev.denominated_untrusted_pending || + denominated_trusted != prev.denominated_trusted; } }; diff --git a/src/qt/walletmodel.cpp b/src/qt/walletmodel.cpp index 96776fb452d2..0e2b33f13776 100644 --- a/src/qt/walletmodel.cpp +++ b/src/qt/walletmodel.cpp @@ -650,6 +650,9 @@ CAmount WalletModel::getAvailableBalance(const CCoinControl* control) // No selected coins, return the cached balance if (!control || !control->HasSelected()) { const interfaces::WalletBalances& balances = getCachedBalance(); + if (control && control->IsUsingCoinJoin()) { + return balances.anonymized_balance; + } CAmount available_balance = balances.balance; // if wallet private keys are disabled, this is a watch-only wallet // so, let's include the watch-only balance. From 5b34dabd5bc7d1f065e28b5c08b1029579ee7b5e Mon Sep 17 00:00:00 2001 From: Konstantin Akimov Date: Sat, 22 Aug 2026 03:10:59 +0700 Subject: [PATCH 5/6] fix(qt): recheck cached balances when CoinJoin is toggled GetBalance() calculates anonymized and denominated balances only while CoinJoin is enabled, and pollBalanceChanged() skips the recalculation until the chain tip moves or a recheck is forced. A wallet that enables CoinJoin after startup therefore keeps serving the zeroed CoinJoin balances cached while it was disabled: the CoinJoin send dialog reports no available funds even for a fully mixed wallet until the next block or wallet transaction. Force a balance recheck when the CoinJoin enabled option changes so the next poll refreshes the cache. --- src/qt/walletmodel.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/qt/walletmodel.cpp b/src/qt/walletmodel.cpp index 0e2b33f13776..24d03b0e8548 100644 --- a/src/qt/walletmodel.cpp +++ b/src/qt/walletmodel.cpp @@ -63,6 +63,9 @@ WalletModel::WalletModel(std::unique_ptr wallet, ClientModel connect(optionsModel, &OptionsModel::dustProtectionChanged, this, &WalletModel::lockExistingDustOutputs); // Lock existing dust on startup if dust protection is enabled lockExistingDustOutputs(); + // CoinJoin balances are calculated only while CoinJoin is enabled, + // so the cached balance must be recalculated when it is toggled + connect(optionsModel, &OptionsModel::showCoinJoinChanged, this, [this] { fForceCheckBalanceChanged = true; }); } } From 5df7df4aa186e01923f55d6d6494f2d3c60dff2c Mon Sep 17 00:00:00 2001 From: Konstantin Akimov Date: Sat, 22 Aug 2026 03:11:45 +0700 Subject: [PATCH 6/6] fix(qt): track locked coins in the cached balances MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cached balance comes from GetBalance(), which counts wallet-locked outputs, while the live wallet query it replaced went through AvailableCoins(), which skips them. "Use available balance" and the send preflight could therefore offer an amount that coin selection cannot spend and fail later at transaction creation. That is less improtant for Bitcoin Core because 'locked' coin is user controllable only but in Dash Core some inputs can be locked as dust and other - as masterndoe collaterals. Fix the cache itself: GetBalance() now totals the locked share of the trusted balances (mine and watch-only) in the same pass, under the same trust, maturity and reuse conditions, and exposes it through WalletBalances so getAvailableBalance() stays pure cached arithmetic. Since locking and unlocking previously went unnoticed until the next block, CWallet now signals NotifyLockedCoinsChanged from LockCoin, UnlockCoin and UnlockAllCoins, and the wallet model forces a balance recheck on it, keeping the cache fresh for coin control, dust protection, mixing sessions and RPC lockunspent alike. The CoinJoin branch needs no adjustment: the live query it replaced (GetBalanceAnonymized) does not skip locked coins either. A watch-only coin can be locked. lockunspent has no IsMine/spendable check — it only verifies the outpoint exists in the wallet and is unspent — so any wallet output, watch-only included, can land in setLockedCoins. Watch-only coins do get spent by coin selection. In a private-keys-disabled wallet, the GUI sets fAllowWatchOnly and builds unsigned transactions/PSBTs from watch-only coins for external signing — that's the wallet type where getAvailableBalance() adds watch_only_balance in the first place. AvailableCoins() skips locked outpoints there just like anywhere else. --- src/interfaces/wallet.h | 10 +++++++++- src/qt/walletmodel.cpp | 19 +++++++++++++++++-- src/qt/walletmodel.h | 3 +++ src/wallet/interfaces.cpp | 6 ++++++ src/wallet/receive.cpp | 15 +++++++++++++++ src/wallet/receive.h | 2 ++ src/wallet/wallet.cpp | 12 ++++++++++-- src/wallet/wallet.h | 3 +++ 8 files changed, 65 insertions(+), 5 deletions(-) diff --git a/src/interfaces/wallet.h b/src/interfaces/wallet.h index 643f45c52a7c..e5451734cebf 100644 --- a/src/interfaces/wallet.h +++ b/src/interfaces/wallet.h @@ -421,6 +421,10 @@ class Wallet using CanGetAddressesChangedFn = std::function; virtual std::unique_ptr handleCanGetAddressesChanged(CanGetAddressesChangedFn fn) = 0; + //! Register handler for locked coins changed messages. + using LockedCoinsChangedFn = std::function; + virtual std::unique_ptr handleLockedCoinsChanged(LockedCoinsChangedFn fn) = 0; + //! Get governance objects stored in the wallet. virtual std::vector getGovernanceObjects() = 0; @@ -498,20 +502,24 @@ struct WalletBalances CAmount balance = 0; CAmount unconfirmed_balance = 0; CAmount immature_balance = 0; + CAmount locked_balance = 0; //!< Subset of balance locked via LockCoin CAmount anonymized_balance = 0; bool have_watch_only = false; CAmount watch_only_balance = 0; CAmount unconfirmed_watch_only_balance = 0; CAmount immature_watch_only_balance = 0; + CAmount locked_watch_only_balance = 0; //!< Subset of watch_only_balance locked via LockCoin CAmount denominated_untrusted_pending = 0; CAmount denominated_trusted = 0; bool balanceChanged(const WalletBalances& prev) const { return balance != prev.balance || unconfirmed_balance != prev.unconfirmed_balance || anonymized_balance != prev.anonymized_balance || - immature_balance != prev.immature_balance || watch_only_balance != prev.watch_only_balance || + immature_balance != prev.immature_balance || locked_balance != prev.locked_balance || + watch_only_balance != prev.watch_only_balance || unconfirmed_watch_only_balance != prev.unconfirmed_watch_only_balance || immature_watch_only_balance != prev.immature_watch_only_balance || + locked_watch_only_balance != prev.locked_watch_only_balance || denominated_untrusted_pending != prev.denominated_untrusted_pending || denominated_trusted != prev.denominated_trusted; } diff --git a/src/qt/walletmodel.cpp b/src/qt/walletmodel.cpp index 24d03b0e8548..d72dbb993d46 100644 --- a/src/qt/walletmodel.cpp +++ b/src/qt/walletmodel.cpp @@ -161,6 +161,12 @@ void WalletModel::updateTransaction() fForceCheckBalanceChanged = true; } +void WalletModel::updateLockedCoins() +{ + // Locked share of the balance changed + fForceCheckBalanceChanged = true; +} + void WalletModel::lockExistingDustOutputs() { if (!optionsModel) return; @@ -527,6 +533,12 @@ static void NotifyCanGetAddressesChanged(WalletModel* walletmodel) assert(invoked); } +static void NotifyLockedCoinsChanged(WalletModel* walletmodel) +{ + bool invoked = QMetaObject::invokeMethod(walletmodel, "updateLockedCoins", Qt::QueuedConnection); + assert(invoked); +} + void WalletModel::subscribeToCoreSignals() { // Connect signals to wallet @@ -539,6 +551,7 @@ void WalletModel::subscribeToCoreSignals() m_handler_show_progress = m_wallet->handleShowProgress(std::bind(ShowProgress, this, std::placeholders::_1, std::placeholders::_2)); m_handler_watch_only_changed = m_wallet->handleWatchOnlyChanged(std::bind(NotifyWatchonlyChanged, this, std::placeholders::_1)); m_handler_can_get_addrs_changed = m_wallet->handleCanGetAddressesChanged(std::bind(NotifyCanGetAddressesChanged, this)); + m_handler_locked_coins_changed = m_wallet->handleLockedCoinsChanged(std::bind(NotifyLockedCoinsChanged, this)); } void WalletModel::unsubscribeFromCoreSignals() @@ -553,6 +566,7 @@ void WalletModel::unsubscribeFromCoreSignals() m_handler_show_progress->disconnect(); m_handler_watch_only_changed->disconnect(); m_handler_can_get_addrs_changed->disconnect(); + m_handler_locked_coins_changed->disconnect(); } // WalletModel::UnlockContext implementation @@ -656,11 +670,12 @@ CAmount WalletModel::getAvailableBalance(const CCoinControl* control) if (control && control->IsUsingCoinJoin()) { return balances.anonymized_balance; } - CAmount available_balance = balances.balance; + // Coin selection cannot spend locked coins, so keep the locked share out + CAmount available_balance = balances.balance - balances.locked_balance; // if wallet private keys are disabled, this is a watch-only wallet // so, let's include the watch-only balance. if (balances.have_watch_only && m_wallet->privateKeysDisabled()) { - available_balance += balances.watch_only_balance; + available_balance += balances.watch_only_balance - balances.locked_watch_only_balance; } return available_balance; } diff --git a/src/qt/walletmodel.h b/src/qt/walletmodel.h index 7c07875092c9..f9a2493a2631 100644 --- a/src/qt/walletmodel.h +++ b/src/qt/walletmodel.h @@ -181,6 +181,7 @@ class WalletModel : public QObject std::unique_ptr m_handler_show_progress; std::unique_ptr m_handler_watch_only_changed; std::unique_ptr m_handler_can_get_addrs_changed; + std::unique_ptr m_handler_locked_coins_changed; ClientModel* m_client_model; interfaces::Node& m_node; @@ -249,6 +250,8 @@ public Q_SLOTS: void updateStatus(); /* New transaction, or transaction changed status */ void updateTransaction(); + /* Set of locked coins changed */ + void updateLockedCoins(); /* Lock existing dust outputs (called on startup and settings change) */ void lockExistingDustOutputs(); /* IS-Lock received */ diff --git a/src/wallet/interfaces.cpp b/src/wallet/interfaces.cpp index 39f2080d388c..0d3653ffab0b 100644 --- a/src/wallet/interfaces.cpp +++ b/src/wallet/interfaces.cpp @@ -615,12 +615,14 @@ class WalletImpl : public Wallet result.balance = bal.m_mine_trusted; result.unconfirmed_balance = bal.m_mine_untrusted_pending; result.immature_balance = bal.m_mine_immature; + result.locked_balance = bal.m_mine_trusted_locked; result.anonymized_balance = bal.m_anonymized; result.have_watch_only = haveWatchOnly(); if (result.have_watch_only) { result.watch_only_balance = bal.m_watchonly_trusted; result.unconfirmed_watch_only_balance = bal.m_watchonly_untrusted_pending; result.immature_watch_only_balance = bal.m_watchonly_immature; + result.locked_watch_only_balance = bal.m_watchonly_trusted_locked; } result.denominated_untrusted_pending = bal.m_denominated_untrusted_pending; result.denominated_trusted = bal.m_denominated_trusted; @@ -815,6 +817,10 @@ class WalletImpl : public Wallet { return MakeHandler(m_wallet->NotifyCanGetAddressesChanged.connect(fn)); } + std::unique_ptr handleLockedCoinsChanged(LockedCoinsChangedFn fn) override + { + return MakeHandler(m_wallet->NotifyLockedCoinsChanged.connect(fn)); + } std::vector getGovernanceObjects() override { LOCK(m_wallet->cs_wallet); diff --git a/src/wallet/receive.cpp b/src/wallet/receive.cpp index 309c43450a0f..54a034a1585e 100644 --- a/src/wallet/receive.cpp +++ b/src/wallet/receive.cpp @@ -327,6 +327,21 @@ Balance GetBalance(const CWallet& wallet, const int min_depth, bool avoid_reuse, } } } + + // Coins locked via LockCoin() are counted in the trusted balances + // above but cannot be selected for spending; total them separately so + // callers can tell the spendable share + for (const COutPoint& outpoint : wallet.ListLockedCoins()) { + const CWalletTx* wtx = wallet.GetWalletTx(outpoint.hash); + if (wtx == nullptr || outpoint.n >= wtx->tx->vout.size()) continue; + if (wallet.IsTxImmatureCoinBase(*wtx) || wallet.IsSpent(outpoint)) continue; + if (!CachedTxIsTrusted(wallet, *wtx, trusted_parents)) continue; + if (wallet.GetTxDepthInMainChain(*wtx) < min_depth && !(fAddLocked && wallet.IsTxLockedByInstantSend(*wtx))) continue; + const CTxOut& txout{wtx->tx->vout[outpoint.n]}; + if (avoid_reuse && wallet.IsWalletFlagSet(WALLET_FLAG_AVOID_REUSE) && wallet.IsSpentKey(txout.scriptPubKey)) continue; + ret.m_mine_trusted_locked += OutputGetCredit(wallet, txout, ISMINE_SPENDABLE); + ret.m_watchonly_trusted_locked += OutputGetCredit(wallet, txout, ISMINE_WATCH_ONLY); + } } return ret; } diff --git a/src/wallet/receive.h b/src/wallet/receive.h index 0acc4c6373e7..d9993e9e2b90 100644 --- a/src/wallet/receive.h +++ b/src/wallet/receive.h @@ -52,9 +52,11 @@ struct Balance { CAmount m_mine_trusted{0}; //!< Trusted, at depth=GetBalance.min_depth or more CAmount m_mine_untrusted_pending{0}; //!< Untrusted, but in mempool (pending) CAmount m_mine_immature{0}; //!< Immature coinbases in the main chain + CAmount m_mine_trusted_locked{0}; //!< Subset of m_mine_trusted locked via LockCoin CAmount m_watchonly_trusted{0}; CAmount m_watchonly_untrusted_pending{0}; CAmount m_watchonly_immature{0}; + CAmount m_watchonly_trusted_locked{0}; //!< Subset of m_watchonly_trusted locked via LockCoin CAmount m_anonymized{0}; CAmount m_denominated_trusted{0}; CAmount m_denominated_untrusted_pending{0}; diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp index da4a2e08d885..cec628c1a271 100644 --- a/src/wallet/wallet.cpp +++ b/src/wallet/wallet.cpp @@ -2750,7 +2750,9 @@ bool CWallet::DisplayAddress(const CTxDestination& dest) bool CWallet::LockCoin(const COutPoint& output, WalletBatch* batch) { AssertLockHeld(cs_wallet); - setLockedCoins.insert(output); + if (setLockedCoins.insert(output).second) { + NotifyLockedCoinsChanged(); + } RecalculateMixedCredit(output.hash); if (batch) { return batch->WriteLockedUTXO(output); @@ -2762,6 +2764,9 @@ bool CWallet::UnlockCoin(const COutPoint& output, WalletBatch* batch) { AssertLockHeld(cs_wallet); bool was_locked = setLockedCoins.erase(output); + if (was_locked) { + NotifyLockedCoinsChanged(); + } RecalculateMixedCredit(output.hash); if (batch && was_locked) { return batch->EraseLockedUTXO(output); @@ -2777,7 +2782,10 @@ bool CWallet::UnlockAllCoins() for (auto it = setLockedCoins.begin(); it != setLockedCoins.end(); ++it) { success &= batch.EraseLockedUTXO(*it); } - setLockedCoins.clear(); + if (!setLockedCoins.empty()) { + setLockedCoins.clear(); + NotifyLockedCoinsChanged(); + } return success; } diff --git a/src/wallet/wallet.h b/src/wallet/wallet.h index 641065321e1e..d8a3dc53fdd3 100644 --- a/src/wallet/wallet.h +++ b/src/wallet/wallet.h @@ -976,6 +976,9 @@ class CWallet final : public WalletStorage, public interfaces::Chain::Notificati /** Keypool has new keys */ boost::signals2::signal NotifyCanGetAddressesChanged; + /** Set of coins locked via LockCoin changed */ + boost::signals2::signal NotifyLockedCoinsChanged; + /** IS-lock received */ boost::signals2::signal NotifyISLockReceived;