From cbbb053389f7b32e146ff449bdfd8b249d7bb0d0 Mon Sep 17 00:00:00 2001 From: johnnylawdgb Date: Sun, 9 Aug 2026 20:54:34 +0000 Subject: [PATCH 1/2] qt: allow locked wallet to redeem DigiDollar The DigiDollar redeem widget and the positions-tab row action both disable themselves when an encrypted wallet is locked, so a user has to reach for `walletpassphrase` in the node console before the GUI will let them close a vault. This is inconsistent with the sibling DigiDollar flows, which all confirm and then unlock on demand: digidollarsendwidget.cpp:649 and digidollarmintwidget.cpp:834 both open with WalletModel::UnlockContext ctx(m_walletModel->requestUnlock()); if (!ctx.isValid()) return; Redeem has exactly that block, at digidollarredeemwidget.cpp:625 -- but with the button disabled it can never run. So this change does not introduce a new pattern; it lets redeem reach the one its siblings already use. The RPC layer has always behaved this way, see test/functional/digidollar_encrypted_wallet.py. Both widgets already distinguish a permanent inability to sign ("Watch-Only", private keys disabled) from a temporary lock ("Wallet Locked") in their labels and tooltips, then collapse the two to setEnabled(false). Only the former is a real incapability: a locked wallet still holds its keys. Split the two states apart: - canWalletSignRedemption() now reports only permanent incapability (no wallet model, or privateKeysDisabled()). The lock check moves to a new walletNeedsUnlockToRedeem() predicate that drives presentation, not enablement, and the corresponding branch is dropped from redeemDisabledReason() so the remaining disabled states still report an accurate reason. - The redeem button reads "Unlock & Redeem" with a tooltip announcing the passphrase prompt while locked, and keeps the existing "Redeem & Unlock DGB" wording once unlocked. - In the positions tab, walletCanSign no longer folds in the lock, so a matured vault stays clickable. The row keeps its distinct locked-wallet colour, now with the metrics of the ordinary redeemable button so the same "Redeem" label does not change size with lock state. The row action only navigates to the redeem tab, so it inherits the unlock prompt rather than duplicating it. Watch-only wallets keep the disabled "Watch-Only" badge, and a locked wallet whose vault has not matured still shows the timelock "Locked" state. Also narrow the two ReconcilePositionStates() guards, which skipped reconciliation whenever privateKeysDisabled() || status == Locked. That function reads mapWallet and the chain UTXO set via chain().findCoins() and rewrites the is_active flag through WalletBatch; it never retrieves key material, so the lock half of the condition was unrelated to what it does. It was not merely redundant: with the row action no longer gated on the lock, pos.canRedeem would have been computed from an is_active flag deliberately left stale, so a vault whose collateral had been spent out of band would offer an enabled "Redeem" until the user unlocked. Both guards are now privateKeysDisabled() only, renamed walletIsWatchOnly. Tests: - redeemWidgetButtonStateLockedWallet -> redeemWidgetButtonStateLockedWalletOffersUnlock. Was: disabled button, tooltip contains "Unlock". Now: enabled, "Unlock & Redeem", tooltip announcing the passphrase step. The contract changed, so the assertion did. Its fixture also funds the $DD burn generously; the exact-amount funding it used before was never actually exercised, because the lock check short-circuited ahead of the balance check. - positionsWidgetDisablesRedeemForLockedEncryptedWallet -> positionsWidgetKeepsRedeemActionableForLockedEncryptedWallet, now requiring the row action to stay enabled. Its mock position is anchored on a live coinbase output, because a locked wallet now really does reconcile and would otherwise correctly retire a position whose collateral outpoint does not exist. - New redeemWidgetLockedWalletRequestsUnlockOnRedeem drives onRedeemClicked() on a locked wallet, accepts the confirmation box, and asserts that WalletModel::requireUnlock fires exactly once -- pinning that the requestUnlock() call site is reachable, which is the point of the change. It also covers the dismissal path: nothing answers the request, the wallet stays locked, and redemptionCompleted() is not emitted. - New widgetsDisableRedeemForEncryptedWatchOnlyWallet covers the one state where the two conditions overlap. WalletModel::getEncryptionStatus() reports Locked, not NoKeys, for a wallet that is both crypted and privateKeysDisabled(), and both widgets must still refuse it. - redeemWidgetRefreshesWhenWalletUnlocks keeps asserting the locked -> unlocked transition; only its locked half now checks the "Unlock & Redeem" affordance instead of a disabled button. The two tests asserting a disabled button for privateKeysDisabled() wallets are unchanged: that behaviour is correct. --- src/qt/digidollarpositionswidget.cpp | 42 +++--- src/qt/digidollarredeemwidget.cpp | 41 ++++-- src/qt/digidollarredeemwidget.h | 5 + src/qt/test/digidollarwidgettests.cpp | 195 ++++++++++++++++++++++++-- src/qt/test/digidollarwidgettests.h | 6 +- 5 files changed, 244 insertions(+), 45 deletions(-) diff --git a/src/qt/digidollarpositionswidget.cpp b/src/qt/digidollarpositionswidget.cpp index ad2473a954..96247d8ebe 100644 --- a/src/qt/digidollarpositionswidget.cpp +++ b/src/qt/digidollarpositionswidget.cpp @@ -465,9 +465,10 @@ void DigiDollarPositionsWidget::loadPositionsFromWallet() // Get current oracle price in micro-USD; regtest can use mock helpers, // while testnet/mainnet use the live oracle price. CAmount oraclePrice = GetMockOraclePrice(); - const bool isWatchOnly = m_walletModel->wallet().privateKeysDisabled(); - const bool isWalletLocked = m_walletModel->getEncryptionStatus() == WalletModel::Locked; - const bool walletCanSign = !isWatchOnly && !isWalletLocked; + // Only a watch-only wallet can never sign a redemption. A locked but + // encrypted wallet still holds its keys and the redeem flow prompts for the + // passphrase, so its vaults stay redeemable. + const bool walletCanSign = !m_walletModel->wallet().privateKeysDisabled(); // Get positions from wallet backend std::vector walletPositions = GetWalletPositions(); @@ -515,7 +516,7 @@ void DigiDollarPositionsWidget::loadPositionsFromWallet() pos.health = CalculatePositionHealth(wp.dd_minted, wp.dgb_collateral, oraclePrice); // Can redeem if timelock expired, position is active, and the wallet can sign. - // Private-key-disabled/watch-only wallets may observe vaults but cannot unlock them. + // Private-key-disabled/watch-only wallets may observe vaults but can never redeem them. pos.canRedeem = walletCanSign && (pos.blocksRemaining == 0) && wp.is_active; // A freshly-created mint can be known to the wallet before the collateral @@ -856,8 +857,8 @@ QPushButton* DigiDollarPositionsWidget::createRedeemButton(const QString& positi // - "Redeemed" if already redeemed (with strikethrough) // - "Watch-Only" if the wallet has private keys disabled and so cannot // ever construct a redemption witness - // - "Wallet Locked" if private keys exist but are currently unavailable - // - "Redeem" if can redeem now (green, clickable) + // - "Redeem" if can redeem now (clickable; a locked wallet is prompted for + // its passphrase by the redeem flow, so it stays clickable too) // - "Locked" if vault hasn't matured yet (grayed out) QString buttonText; if (isPendingMint) { @@ -868,8 +869,6 @@ QPushButton* DigiDollarPositionsWidget::createRedeemButton(const QString& positi buttonText = tr("Redeemed"); } else if (isWatchOnly) { buttonText = tr("Watch-Only"); - } else if (isWalletLocked) { - buttonText = tr("Wallet Locked"); } else if (canRedeem) { buttonText = tr("Redeem"); } else { @@ -967,25 +966,30 @@ QPushButton* DigiDollarPositionsWidget::createRedeemButton(const QString& positi .arg(woText); tooltip = tr("Watch-only wallet\nThis wallet cannot sign DigiDollar redemptions because private keys are disabled."); button->setEnabled(false); - } else if (isWalletLocked) { + } else if (canRedeem && isWalletLocked) { + // Redeemable, but the wallet is locked: keep the action available and + // use a distinct styling to flag the extra passphrase step. QString lockedWalletBg = isDarkTheme ? "#4a4655" : "#e4dfea"; QString lockedWalletText = isDarkTheme ? "#d6c6e6" : "#4d3f5f"; + // Metrics match the plain redeemable branch below so that the same + // "Redeem" label does not change size with the wallet lock state; only + // the colour differs. buttonStyle = QString( "QPushButton { " " background-color: %1; " " color: %2; " " border: 1px solid %2; " " border-radius: 5px; " - " padding: 6px 8px; " + " padding: 6px 12px; " " font-weight: 600; " - " font-size: 10px; " - " min-width: 72px; " + " font-size: 11px; " + " min-width: 60px; " "}") .arg(lockedWalletBg) .arg(lockedWalletText); - tooltip = tr("Wallet is locked\nUnlock the wallet to redeem this DigiDollar vault."); - button->setEnabled(false); + tooltip = tr("Wallet is locked\nYou will be asked for your passphrase before this DigiDollar vault is redeemed."); + button->setEnabled(true); } else if (canRedeem) { // Can redeem - green button QString successColor = isDarkTheme ? "#4caf50" : "#28a745"; @@ -1242,10 +1246,12 @@ std::vector DigiDollarPositionsWidget::GetWalletPositi // Access DigiDollarWallet directly from wallet model DigiDollarWallet* ddWallet = m_walletModel->wallet().getDigiDollarWallet(); if (ddWallet) { - const bool walletCannotSign = - m_walletModel->wallet().privateKeysDisabled() || - m_walletModel->getEncryptionStatus() == WalletModel::Locked; - if (!walletCannotSign) { + // ReconcilePositionStates() only reads the wallet's transactions and the + // chain UTXO set and rewrites the is_active flag; it never touches key + // material, so a locked wallet must still refresh. Skipping it there + // would leave the row action enabled off a stale is_active flag. + const bool walletIsWatchOnly = m_walletModel->wallet().privateKeysDisabled(); + if (!walletIsWatchOnly) { ddWallet->ReconcilePositionStates(); } positions = ddWallet->GetDDTimeLocks(false); // Get ALL time locks including redeemed ones diff --git a/src/qt/digidollarredeemwidget.cpp b/src/qt/digidollarredeemwidget.cpp index 251d3366bb..16d5928b5e 100644 --- a/src/qt/digidollarredeemwidget.cpp +++ b/src/qt/digidollarredeemwidget.cpp @@ -753,12 +753,20 @@ void DigiDollarRedeemWidget::updateRedeemButtons() validateDDBalance() && canWalletSignRedemption(); + // A locked wallet keeps the action available: the redeem flow prompts for + // the passphrase. Only advertise the extra step up front. + const bool needsUnlock = canRedeem && walletNeedsUnlockToRedeem(); + m_redeemButton->setEnabled(canRedeem); if (canRedeem) { - m_redeemButton->setText(tr("Redeem && Unlock DGB")); - const QString readyText = tr("Ready to redeem this DigiDollar vault and release the locked DGB collateral."); + m_redeemButton->setText(needsUnlock ? tr("Unlock && Redeem") : tr("Redeem && Unlock DGB")); + const QString readyText = needsUnlock + ? tr("Wallet is locked.\nYou will be asked for your passphrase, then this DigiDollar vault is redeemed and the locked DGB collateral released.") + : tr("Ready to redeem this DigiDollar vault and release the locked DGB collateral."); m_redeemButton->setToolTip(readyText); - m_positionValidationLabel->setText(tr("Vault ready to redeem.")); + m_positionValidationLabel->setText(needsUnlock + ? tr("Vault ready to redeem — you will be asked for your passphrase.") + : tr("Vault ready to redeem.")); m_positionValidationLabel->setToolTip(readyText); } else { const QString reason = redeemDisabledReason(); @@ -838,10 +846,11 @@ void DigiDollarRedeemWidget::loadPositionDetails() auto loadPositionFromWallet = [&]() -> bool { DigiDollarWallet* ddWallet = m_walletModel->wallet().getDigiDollarWallet(); if (!ddWallet) return false; - const bool walletCannotSign = - m_walletModel->wallet().privateKeysDisabled() || - m_walletModel->getEncryptionStatus() == WalletModel::Locked; - if (!walletCannotSign) { + // ReconcilePositionStates() only reads the wallet's transactions and the + // chain UTXO set and rewrites the is_active flag; it never touches key + // material, so a locked wallet must still refresh. + const bool walletIsWatchOnly = m_walletModel->wallet().privateKeysDisabled(); + if (!walletIsWatchOnly) { ddWallet->ReconcilePositionStates(); } @@ -1014,15 +1023,24 @@ bool DigiDollarRedeemWidget::canWalletSignRedemption() const if (!m_walletModel) { return false; } + // Private keys disabled (watch-only) is a permanent incapability: no + // passphrase can make this wallet produce a redemption witness. + // + // A locked but encrypted wallet is NOT in that category. It still holds the + // keys, and onRedeemClicked() asks for the passphrase through + // WalletModel::requestUnlock() before building the transaction, exactly + // like the send and mint flows do. if (m_walletModel->wallet().privateKeysDisabled()) { return false; } - if (m_walletModel->getEncryptionStatus() == WalletModel::Locked) { - return false; - } return true; } +bool DigiDollarRedeemWidget::walletNeedsUnlockToRedeem() const +{ + return m_walletModel && m_walletModel->getEncryptionStatus() == WalletModel::Locked; +} + QString DigiDollarRedeemWidget::redeemDisabledReason() const { if (!m_positionFound) { @@ -1039,9 +1057,6 @@ QString DigiDollarRedeemWidget::redeemDisabledReason() const if (m_walletModel->wallet().privateKeysDisabled()) { return tr("Watch-only wallet.\nThis wallet cannot sign DigiDollar redemptions because private keys are disabled."); } - if (m_walletModel->getEncryptionStatus() == WalletModel::Locked) { - return tr("Wallet is locked.\nUnlock the wallet to redeem this DigiDollar vault."); - } if (!validateAmount()) { return tr("Invalid redeem amount.\nDigiDollar redemptions must use the exact vault amount."); } diff --git a/src/qt/digidollarredeemwidget.h b/src/qt/digidollarredeemwidget.h index 1f350e50cf..7fcce313a9 100644 --- a/src/qt/digidollarredeemwidget.h +++ b/src/qt/digidollarredeemwidget.h @@ -99,7 +99,12 @@ private Q_SLOTS: bool validateAmount() const; bool validateRedeemable() const; bool validateDDBalance() const; + /** True unless the wallet can never sign a redemption (no wallet model, or + * private keys disabled). A locked but encrypted wallet still holds its + * keys, so it returns true; see walletNeedsUnlockToRedeem(). */ bool canWalletSignRedemption() const; + /** True when redeeming will first prompt for the wallet passphrase. */ + bool walletNeedsUnlockToRedeem() const; QString redeemDisabledReason() const; QString formatDDAmount(double amount) const; diff --git a/src/qt/test/digidollarwidgettests.cpp b/src/qt/test/digidollarwidgettests.cpp index 05c7d689f5..2c9e2580f2 100644 --- a/src/qt/test/digidollarwidgettests.cpp +++ b/src/qt/test/digidollarwidgettests.cpp @@ -44,6 +44,7 @@ #include #include +#include #include #include #include @@ -402,6 +403,30 @@ QDialog* FindVisibleDialogByTitle(const QString& title) return nullptr; } +//! Click "Yes" on the modal "Confirm Redeem" box as soon as it appears. +//! Re-arms itself so it works whether or not the dialog is up yet, and closes +//! any leftover modal after the last attempt so a failure never hangs the run. +void AcceptRedeemConfirmation(int attempts_left = 40) +{ + QTimer::singleShot(25, [attempts_left]() { + if (QDialog* dialog = FindVisibleDialogByTitle(QStringLiteral("Confirm Redeem"))) { + auto* box = qobject_cast(dialog); + QAbstractButton* yes = box ? box->button(QMessageBox::Yes) : nullptr; + if (yes) { + yes->click(); + } else { + dialog->reject(); + } + return; + } + if (attempts_left > 0) { + AcceptRedeemConfirmation(attempts_left - 1); + } else if (QWidget* modal = QApplication::activeModalWidget()) { + modal->close(); + } + }); +} + bool SelectCoinControlDialogInput(const COutPoint& outpoint, QString& error) { QDialog* dialog{nullptr}; @@ -1340,7 +1365,7 @@ void DigiDollarWidgetTests::positionsWidgetDisablesRedeemForPrivateKeyDisabledWa QVERIFY(redeemButton->toolTip().contains("Watch-only")); } -void DigiDollarWidgetTests::positionsWidgetDisablesRedeemForLockedEncryptedWallet() +void DigiDollarWidgetTests::positionsWidgetKeepsRedeemActionableForLockedEncryptedWallet() { #ifdef Q_OS_MACOS if (QApplication::platformName() == "minimal") { @@ -1357,7 +1382,13 @@ void DigiDollarWidgetTests::positionsWidgetDisablesRedeemForLockedEncryptedWalle m_node.setContext(&test.m_node); const std::shared_ptr& wallet = SetupDescriptorsWallet(m_node, test); - AddMockDigiDollarPosition(wallet, uint256::ONE, 10000, 300 * COIN, 1, 100); + // A locked wallet now runs ReconcilePositionStates() like any other, so the + // position must be backed by a collateral outpoint that is really unspent; + // otherwise reconciliation correctly retires it and the row reads + // "Redeemed". Anchor it on a live coinbase output so that the wallet lock + // stays the only variable under test. + const uint256 position_id = test.m_coinbase_txns[0]->GetHash(); + AddMockDigiDollarPosition(wallet, position_id, 10000, 300 * COIN, 1, 100); SecureString passphrase{"wave17-qt-locked-wallet"}; QVERIFY(wallet->EncryptWallet(passphrase)); @@ -1381,9 +1412,13 @@ void DigiDollarWidgetTests::positionsWidgetDisablesRedeemForLockedEncryptedWalle QPushButton* redeemButton = qobject_cast( table->cellWidget(0, DigiDollarPositionsWidget::COL_ACTIONS)); QVERIFY(redeemButton != nullptr); - QCOMPARE(redeemButton->text(), QString("Wallet Locked")); - QVERIFY(!redeemButton->isEnabled()); - QVERIFY(redeemButton->toolTip().contains("Unlock")); + // A locked encrypted wallet still holds its keys. The redeem flow prompts + // for the passphrase via WalletModel::requestUnlock(), so the row action + // must stay clickable; only its tooltip warns about the extra step. + QCOMPARE(redeemButton->text(), QString("Redeem")); + QVERIFY(redeemButton->isEnabled()); + QVERIFY(redeemButton->toolTip().contains("Wallet is locked")); + QVERIFY(redeemButton->toolTip().contains("passphrase")); } void DigiDollarWidgetTests::redeemWidgetButtonStateNoSelection() @@ -1588,7 +1623,7 @@ void DigiDollarWidgetTests::redeemWidgetButtonStatePrivateKeyDisabledWallet() QVERIFY(validationLabel->toolTip().contains("Watch-only")); } -void DigiDollarWidgetTests::redeemWidgetButtonStateLockedWallet() +void DigiDollarWidgetTests::redeemWidgetButtonStateLockedWalletOffersUnlock() { #ifdef Q_OS_MACOS if (QApplication::platformName() == "minimal") { @@ -1606,7 +1641,11 @@ void DigiDollarWidgetTests::redeemWidgetButtonStateLockedWallet() const std::shared_ptr& wallet = SetupDescriptorsWallet(m_node, test, "qt-dd-redeem-wallet-locked"); AddMockDigiDollarPosition(wallet, uint256::ONE, 10000, 300 * COIN, 1, 100); - wallet->GetDDWallet()->AddDDUTXO(COutPoint(uint256::ONE, 1), 10000); + // Fund the $DD burn generously so that the wallet lock is the only thing + // this test can be measuring (the ERR-adjusted burn can exceed the minted + // amount). The previous exact-amount funding was never exercised because + // the lock check short-circuited before the balance check. + wallet->GetDDWallet()->AddDDUTXO(COutPoint(uint256::ONE, 1), 100000000); SecureString passphrase{"qt-dd-redeem-wallet-locked"}; QVERIFY(wallet->EncryptWallet(passphrase)); @@ -1625,11 +1664,143 @@ void DigiDollarWidgetTests::redeemWidgetButtonStateLockedWallet() QPushButton* redeemButton = redeemWidget.findChild("redeemButton"); QVERIFY(redeemButton != nullptr); - QVERIFY(!redeemButton->isEnabled()); - QVERIFY(redeemButton->toolTip().contains("Unlock")); + // A locked encrypted wallet is only temporarily unable to sign. Keeping the + // button enabled is what makes the WalletModel::requestUnlock() passphrase + // prompt in onRedeemClicked() reachable at all. + QVERIFY(redeemButton->isEnabled()); + QCOMPARE(redeemButton->text(), QString("Unlock && Redeem")); + QVERIFY(redeemButton->toolTip().contains("Wallet is locked")); + QVERIFY(redeemButton->toolTip().contains("passphrase")); QLabel* validationLabel = redeemWidget.findChild("positionValidationLabel"); QVERIFY(validationLabel != nullptr); - QVERIFY(validationLabel->toolTip().contains("Unlock")); + QVERIFY(validationLabel->toolTip().contains("passphrase")); +} + +void DigiDollarWidgetTests::redeemWidgetLockedWalletRequestsUnlockOnRedeem() +{ +#ifdef Q_OS_MACOS + if (QApplication::platformName() == "minimal") { + QWARN("Skipping DigiDollarWidgetTests on mac build with 'minimal' platform set due to Qt bugs."); + return; + } +#endif + TestChain100Setup test; + for (int i = 0; i < 5; ++i) { + test.CreateAndProcessBlock({}, GetScriptForRawPubKey(test.coinbaseKey.GetPubKey())); + } + auto wallet_loader = interfaces::MakeWalletLoader(*test.m_node.chain, *Assert(test.m_node.args)); + test.m_node.wallet_loader = wallet_loader.get(); + m_node.setContext(&test.m_node); + + const std::shared_ptr& wallet = SetupDescriptorsWallet(m_node, test, "qt-dd-redeem-requests-unlock"); + AddMockDigiDollarPosition(wallet, uint256::ONE, 10000, 300 * COIN, 1, 100); + wallet->GetDDWallet()->AddDDUTXO(COutPoint(uint256::ONE, 1), 100000000); + SecureString passphrase{"qt-dd-redeem-requests-unlock"}; + QVERIFY(wallet->EncryptWallet(passphrase)); + QVERIFY(wallet->IsLocked()); + + DigiDollarMiniGUI mini_gui(m_node); + mini_gui.initModelForWallet(m_node, wallet); + QCOMPARE(mini_gui.walletModel->getEncryptionStatus(), WalletModel::Locked); + WalletContext& context = *m_node.walletLoader().context(); + AddWallet(context, wallet); + + DigiDollarRedeemWidget redeemWidget; + redeemWidget.setWalletModel(mini_gui.walletModel.get()); + redeemWidget.setClientModel(mini_gui.clientModel.get()); + redeemWidget.setPosition(QString::fromStdString(uint256::ONE.GetHex())); + QCoreApplication::processEvents(); + + QPushButton* redeemButton = redeemWidget.findChild("redeemButton"); + QVERIFY(redeemButton != nullptr); + QVERIFY(redeemButton->isEnabled()); + + // This is the behaviour the whole change exists for: driving the redeem + // action on a LOCKED wallet must reach WalletModel::requestUnlock(), which + // emits requireUnlock so the GUI can raise AskPassphraseDialog. While the + // button was disabled that call site was unreachable. + QSignalSpy unlockSpy(mini_gui.walletModel.get(), &WalletModel::requireUnlock); + QSignalSpy completedSpy(&redeemWidget, &DigiDollarRedeemWidget::redemptionCompleted); + + AcceptRedeemConfirmation(); + redeemWidget.onRedeemClicked(); + QCoreApplication::processEvents(); + + QCOMPARE(unlockSpy.count(), 1); + // Nothing answers requireUnlock in this harness, which is exactly the + // "user dismissed the passphrase prompt" path: the UnlockContext is invalid, + // so onRedeemClicked() must return without building a redemption. + QVERIFY(wallet->IsLocked()); + QCOMPARE(completedSpy.count(), 0); + + RemoveWallet(context, wallet, std::nullopt); +} + +void DigiDollarWidgetTests::widgetsDisableRedeemForEncryptedWatchOnlyWallet() +{ +#ifdef Q_OS_MACOS + if (QApplication::platformName() == "minimal") { + QWARN("Skipping DigiDollarWidgetTests on mac build with 'minimal' platform set due to Qt bugs."); + return; + } +#endif + TestChain100Setup test; + for (int i = 0; i < 5; ++i) { + test.CreateAndProcessBlock({}, GetScriptForRawPubKey(test.coinbaseKey.GetPubKey())); + } + auto wallet_loader = interfaces::MakeWalletLoader(*test.m_node.chain, *Assert(test.m_node.args)); + test.m_node.wallet_loader = wallet_loader.get(); + m_node.setContext(&test.m_node); + + const std::shared_ptr& wallet = SetupDescriptorsWallet(m_node, test, "qt-dd-redeem-encrypted-watchonly"); + AddMockDigiDollarPosition(wallet, uint256::ONE, 10000, 300 * COIN, 1, 100); + wallet->GetDDWallet()->AddDDUTXO(COutPoint(uint256::ONE, 1), 100000000); + SecureString passphrase{"qt-dd-redeem-encrypted-watchonly"}; + QVERIFY(wallet->EncryptWallet(passphrase)); + wallet->SetWalletFlag(WALLET_FLAG_DISABLE_PRIVATE_KEYS); + + DigiDollarMiniGUI mini_gui(m_node); + mini_gui.initModelForWallet(m_node, wallet); + WalletContext& context = *m_node.walletLoader().context(); + AddWallet(context, wallet); + + // A wallet that is both crypted and private-keys-disabled reports Locked, + // not NoKeys (see WalletModel::getEncryptionStatus). This is the one state + // where the two conditions this change separates overlap, so pin it: the + // permanent incapability must still win over the temporary lock. + QCOMPARE(mini_gui.walletModel->getEncryptionStatus(), WalletModel::Locked); + QVERIFY(mini_gui.walletModel->wallet().privateKeysDisabled()); + + DigiDollarPositionsWidget positionsWidget; + positionsWidget.setClientModel(mini_gui.clientModel.get()); + positionsWidget.setWalletModel(mini_gui.walletModel.get()); + positionsWidget.show(); + QCoreApplication::processEvents(); + positionsWidget.updateView(); + + QTableWidget* table = positionsWidget.findChild("positionsTable"); + QVERIFY(table != nullptr); + QCOMPARE(table->rowCount(), 1); + QPushButton* rowButton = qobject_cast( + table->cellWidget(0, DigiDollarPositionsWidget::COL_ACTIONS)); + QVERIFY(rowButton != nullptr); + QCOMPARE(rowButton->text(), QString("Watch-Only")); + QVERIFY(!rowButton->isEnabled()); + QVERIFY(rowButton->toolTip().contains("Watch-only")); + + DigiDollarRedeemWidget redeemWidget; + redeemWidget.setWalletModel(mini_gui.walletModel.get()); + redeemWidget.setClientModel(mini_gui.clientModel.get()); + redeemWidget.setPosition(QString::fromStdString(uint256::ONE.GetHex())); + QCoreApplication::processEvents(); + + QPushButton* redeemButton = redeemWidget.findChild("redeemButton"); + QVERIFY(redeemButton != nullptr); + QVERIFY(!redeemButton->isEnabled()); + QCOMPARE(redeemButton->text(), QString("Cannot Redeem")); + QVERIFY(redeemButton->toolTip().contains("Watch-only")); + + RemoveWallet(context, wallet, std::nullopt); } void DigiDollarWidgetTests::redeemWidgetRefreshesWhenWalletUnlocks() @@ -1667,8 +1838,8 @@ void DigiDollarWidgetTests::redeemWidgetRefreshesWhenWalletUnlocks() QPushButton* redeemButton = redeemWidget.findChild("redeemButton"); QVERIFY(redeemButton != nullptr); - QVERIFY(!redeemButton->isEnabled()); - QVERIFY(redeemButton->toolTip().contains("Unlock")); + QVERIFY(redeemButton->isEnabled()); + QCOMPARE(redeemButton->text(), QString("Unlock && Redeem")); QVERIFY(mini_gui.walletModel->setWalletLocked(false, passphrase)); mini_gui.walletModel->updateStatus(); diff --git a/src/qt/test/digidollarwidgettests.h b/src/qt/test/digidollarwidgettests.h index 0713f31ec8..adf754e3df 100644 --- a/src/qt/test/digidollarwidgettests.h +++ b/src/qt/test/digidollarwidgettests.h @@ -95,13 +95,15 @@ private Q_SLOTS: void positionsWidgetInitialLoadNotThrottled(); void positionsWidgetHealthUsesMicroUsdOraclePrice(); void positionsWidgetDisablesRedeemForPrivateKeyDisabledWallet(); - void positionsWidgetDisablesRedeemForLockedEncryptedWallet(); + void positionsWidgetKeepsRedeemActionableForLockedEncryptedWallet(); void redeemWidgetButtonStateNoSelection(); void redeemWidgetButtonStateTimelockActive(); void redeemWidgetButtonStateInvalidAmount(); void redeemWidgetButtonStateInsufficientDDBalance(); void redeemWidgetButtonStatePrivateKeyDisabledWallet(); - void redeemWidgetButtonStateLockedWallet(); + void redeemWidgetButtonStateLockedWalletOffersUnlock(); + void redeemWidgetLockedWalletRequestsUnlockOnRedeem(); + void widgetsDisableRedeemForEncryptedWatchOnlyWallet(); void redeemWidgetRefreshesWhenWalletUnlocks(); void redeemWidgetButtonStateReady(); void positionsWidgetLockedTooltipShowsRemainingBlocksAndTime(); From 409f84efc85b90524bbd7eb07f9805781c86d8c2 Mon Sep 17 00:00:00 2001 From: johnnylawdgb Date: Sun, 9 Aug 2026 20:54:34 +0000 Subject: [PATCH 2/2] qt: add "Unlock Wallet" action to Settings menu The GUI can only unlock a wallet as a side effect of starting an operation that needs keys: WalletView::unlockWallet() is reached solely through the WalletModel::requireUnlock signal. A user who wants the wallet unlocked up front has to use `walletpassphrase` in the node console. Add a Settings menu entry following the encryptWalletAction / changePassphraseAction pattern: declare the QAction, create it in createActions(), connect it, add it to the menu, and drive its enabled state from setEncryptionStatus() so it is offered only for an encrypted wallet that is currently locked. setWalletActionsEnabled() also toggles it so it is disabled with no wallet loaded; setEncryptionStatus() runs immediately afterwards via WalletFrame::currentWalletSet and narrows it to the Locked case, the same way encryptWalletAction is handled. The existing unlockWallet() slot is deliberately synchronous, because WalletModel::requestUnlock() inspects the encryption status again as soon as the dialog returns. Nothing waits on a menu-driven unlock, so wiring QAction::triggered into that slot would nest a modal event loop inside an action handler -- precisely what GUIUtil::ShowModalDialogAsynchronously() exists to avoid, and what the other menu-driven wallet dialogs (WalletView::encryptWallet, WalletView::changePassphrase) already use. Add a sibling WalletView::unlockWalletFromMenu() that shows the same AskPassphraseDialog asynchronously, plus a WalletFrame forwarder, and leave the synchronous slot wired only to WalletModel::requireUnlock. --- src/qt/digibytegui.cpp | 10 ++++++++++ src/qt/digibytegui.h | 1 + src/qt/walletframe.cpp | 7 +++++++ src/qt/walletframe.h | 4 +++- src/qt/walletview.cpp | 13 +++++++++++++ src/qt/walletview.h | 7 ++++++- 6 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/qt/digibytegui.cpp b/src/qt/digibytegui.cpp index 66c7810930..746dd159b5 100644 --- a/src/qt/digibytegui.cpp +++ b/src/qt/digibytegui.cpp @@ -448,6 +448,8 @@ void DigiByteGUI::createActions() backupWalletAction->setStatusTip(tr("Backup wallet to another location")); changePassphraseAction = new QAction(tr("&Change Passphrase…"), this); changePassphraseAction->setStatusTip(tr("Change the passphrase used for wallet encryption")); + unlockWalletAction = new QAction(tr("&Unlock Wallet…"), this); + unlockWalletAction->setStatusTip(tr("Unlock the wallet so it can sign transactions")); signMessageAction = new QAction(tr("Sign &message…"), this); signMessageAction->setStatusTip(tr("Sign messages with your DigiByte addresses to prove you own them")); verifyMessageAction = new QAction(tr("&Verify message…"), this); @@ -520,6 +522,7 @@ void DigiByteGUI::createActions() connect(encryptWalletAction, &QAction::triggered, walletFrame, &WalletFrame::encryptWallet); connect(backupWalletAction, &QAction::triggered, walletFrame, &WalletFrame::backupWallet); connect(changePassphraseAction, &QAction::triggered, walletFrame, &WalletFrame::changePassphrase); + connect(unlockWalletAction, &QAction::triggered, walletFrame, &WalletFrame::unlockWalletFromMenu); connect(signMessageAction, &QAction::triggered, [this]{ showNormalIfMinimized(); }); connect(signMessageAction, &QAction::triggered, [this]{ gotoSignMessageTab(); }); connect(m_load_psbt_action, &QAction::triggered, [this]{ gotoLoadPSBT(); }); @@ -636,6 +639,7 @@ void DigiByteGUI::createMenuBar() { settings->addAction(encryptWalletAction); settings->addAction(changePassphraseAction); + settings->addAction(unlockWalletAction); settings->addSeparator(); settings->addAction(m_mask_values_action); settings->addSeparator(); @@ -967,6 +971,8 @@ void DigiByteGUI::setWalletActionsEnabled(bool enabled) encryptWalletAction->setEnabled(enabled); backupWalletAction->setEnabled(enabled); changePassphraseAction->setEnabled(enabled); + // setEncryptionStatus() refines this to the Locked case only. + unlockWalletAction->setEnabled(enabled); signMessageAction->setEnabled(enabled); verifyMessageAction->setEnabled(enabled); usedSendingAddressesAction->setEnabled(enabled); @@ -1640,12 +1646,14 @@ void DigiByteGUI::setEncryptionStatus(int status) encryptWalletAction->setChecked(false); changePassphraseAction->setEnabled(false); encryptWalletAction->setEnabled(false); + unlockWalletAction->setEnabled(false); break; case WalletModel::Unencrypted: labelWalletEncryptionIcon->hide(); encryptWalletAction->setChecked(false); changePassphraseAction->setEnabled(false); encryptWalletAction->setEnabled(true); + unlockWalletAction->setEnabled(false); break; case WalletModel::Unlocked: labelWalletEncryptionIcon->show(); @@ -1654,6 +1662,7 @@ void DigiByteGUI::setEncryptionStatus(int status) encryptWalletAction->setChecked(true); changePassphraseAction->setEnabled(true); encryptWalletAction->setEnabled(false); + unlockWalletAction->setEnabled(false); break; case WalletModel::Locked: labelWalletEncryptionIcon->show(); @@ -1662,6 +1671,7 @@ void DigiByteGUI::setEncryptionStatus(int status) encryptWalletAction->setChecked(true); changePassphraseAction->setEnabled(true); encryptWalletAction->setEnabled(false); + unlockWalletAction->setEnabled(true); break; } } diff --git a/src/qt/digibytegui.h b/src/qt/digibytegui.h index 8bde2535eb..500e88ea14 100644 --- a/src/qt/digibytegui.h +++ b/src/qt/digibytegui.h @@ -155,6 +155,7 @@ class DigiByteGUI : public QMainWindow QAction* encryptWalletAction = nullptr; QAction* backupWalletAction = nullptr; QAction* changePassphraseAction = nullptr; + QAction* unlockWalletAction = nullptr; QAction* aboutQtAction = nullptr; QAction* openRPCConsoleAction = nullptr; QAction* openAction = nullptr; diff --git a/src/qt/walletframe.cpp b/src/qt/walletframe.cpp index 97c4b14b38..983dada98c 100644 --- a/src/qt/walletframe.cpp +++ b/src/qt/walletframe.cpp @@ -272,6 +272,13 @@ void WalletFrame::unlockWallet() walletView->unlockWallet(); } +void WalletFrame::unlockWalletFromMenu() +{ + WalletView *walletView = currentWalletView(); + if (walletView) + walletView->unlockWalletFromMenu(); +} + void WalletFrame::usedSendingAddresses() { WalletView *walletView = currentWalletView(); diff --git a/src/qt/walletframe.h b/src/qt/walletframe.h index 5653ae4e80..1e8d341454 100644 --- a/src/qt/walletframe.h +++ b/src/qt/walletframe.h @@ -92,8 +92,10 @@ public Q_SLOTS: void backupWallet(); /** Change encrypted wallet passphrase */ void changePassphrase(); - /** Ask for passphrase to unlock wallet temporarily */ + /** Ask for passphrase to unlock wallet temporarily (synchronous) */ void unlockWallet(); + /** Ask for passphrase to unlock the wallet from the menu (asynchronous) */ + void unlockWalletFromMenu(); /** Show used sending addresses */ void usedSendingAddresses(); diff --git a/src/qt/walletview.cpp b/src/qt/walletview.cpp index fd5fc841bb..ba246745d9 100644 --- a/src/qt/walletview.cpp +++ b/src/qt/walletview.cpp @@ -265,6 +265,19 @@ void WalletView::unlockWallet() } } +void WalletView::unlockWalletFromMenu() +{ + // Unlike unlockWallet() above, nothing is waiting on the result here, so + // show the dialog asynchronously like the other menu-driven wallet dialogs + // instead of nesting a modal event loop inside the action handler. + if (walletModel->getEncryptionStatus() != WalletModel::Locked) return; + + auto dlg = new AskPassphraseDialog(AskPassphraseDialog::Unlock, this); + dlg->setModel(walletModel); + connect(dlg, &QDialog::finished, this, &WalletView::encryptionStatusChanged); + GUIUtil::ShowModalDialogAsynchronously(dlg); +} + void WalletView::usedSendingAddresses() { GUIUtil::bringToFront(usedSendingAddressesPage); diff --git a/src/qt/walletview.h b/src/qt/walletview.h index 808c2a4d20..f4167dd5e0 100644 --- a/src/qt/walletview.h +++ b/src/qt/walletview.h @@ -100,8 +100,13 @@ public Q_SLOTS: void backupWallet(); /** Change encrypted wallet passphrase */ void changePassphrase(); - /** Ask for passphrase to unlock wallet temporarily */ + /** Ask for passphrase to unlock wallet temporarily. + Synchronous: WalletModel::requestUnlock() depends on the dialog having + returned before it inspects the encryption status again. */ void unlockWallet(); + /** Ask for passphrase to unlock the wallet, driven from the menu. + Asynchronous: no caller is waiting on the result. */ + void unlockWalletFromMenu(); /** Show used sending addresses */ void usedSendingAddresses();