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/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(); 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();