Skip to content

qt: allow a locked wallet to redeem DigiDollar via on-demand unlock - #440

Open
JohnnyLawDGB wants to merge 2 commits into
DigiByte-Core:developfrom
JohnnyLawDGB:fix/qt-dd-locked-wallet-redeem
Open

qt: allow a locked wallet to redeem DigiDollar via on-demand unlock#440
JohnnyLawDGB wants to merge 2 commits into
DigiByte-Core:developfrom
JohnnyLawDGB:fix/qt-dd-locked-wallet-redeem

Conversation

@JohnnyLawDGB

Copy link
Copy Markdown

Problem

When an encrypted wallet is locked, the Qt DigiDollar redeem actions are disabled — but the click handler they would invoke already contains the intended WalletModel::requestUnlock() passphrase flow (digidollarredeemwidget.cpp, in onRedeemClicked()).

Because the action can never be clicked, that flow is unreachable dead code. There is also no standalone unlock action in the Settings menu, so the practical workaround is to drop to the console and run walletpassphrase before redeeming. Users hitting this have had to be walked through the RPC by hand.

The RPC layer is already correct: test/functional/digidollar_encrypted_wallet.py covers locked-rejects and unlocked-succeeds. Only the GUI is affected.

Why this is a real defect, not a design choice

The widgets already distinguish permanent signing incapability from a temporary lock — a watch-only wallet gets a "Watch-Only" badge, a locked encrypted wallet gets "Wallet Locked" — but they collapse both to setEnabled(false).

Only the first is a genuine incapability. A locked encrypted wallet still holds its keys; it needs authentication, not refusal. The unreachable requestUnlock() call is direct evidence that on-demand unlock was the original intent.

This also makes redeem consistent with its siblings: digidollarsendwidget.cpp and digidollarmintwidget.cpp already use the confirm → requestUnlock() → act pattern. Redeem was the odd one out.

Changes

Commit 1 — qt: allow locked wallet to redeem DigiDollar

Separates permanent incapability (privateKeysDisabled(), or no wallet model) from a temporary lock in both DigiDollarRedeemWidget and DigiDollarPositionsWidget. Only the former disables the action; a locked encrypted wallet stays actionable and the existing requestUnlock() prompt runs on click.

Two gates were involved, not one: besides the button-enable predicate, DigiDollarPositionsWidget::loadPositionsFromWallet() folded the lock into walletCanSign, which feeds pos.canRedeem and also drives the context-menu action. Fixing only the button would have left locked vaults falling through to the disabled timelock branch.

The reconcile-time guards previously named walletCannotSign are now walletIsWatchOnly. ReconcilePositionStates() touches no private keys — it reads coins via chain().findCoins()/IsSpent() and writes WalletBatch::WriteDDTimeLock(), all of which work on a locked wallet — so gating it on the lock only served to leave is_active stale, which would have surfaced as an enabled Redeem on a vault whose collateral was already spent.

Commit 2 — qt: add "Unlock Wallet" action to Settings menu

Adds a standalone unlock action, enabled only when the wallet is Locked. Kept as a separate commit so it can be dropped independently if maintainers prefer, since upstream Bitcoin Core deliberately has no such action.

This adds no unlock logic: WalletFrame::unlockWallet() / WalletView::unlockWallet() already existed and already showed AskPassphraseDialog(Unlock); they were simply unreachable from any menu. Because the existing slot is deliberately synchronous (WalletModel::requestUnlock() requires it), the menu path uses a new unlockWalletFromMenu() built on GUIUtil::ShowModalDialogAsynchronously(), matching how encryptWallet and changePassphrase are wired and avoiding a nested modal loop from an action handler.

Tests

Two existing tests asserted the old contract and are intentionally flipped — renamed so the change is explicit rather than a silent boolean inversion. Both would fail against pre-branch code. No assertion was weakened; assertion counts went up (5→7 and 3→4), and both watch-only tests are unchanged and still assert disabled.

Added:

  • a test that drives onRedeemClicked() on a locked wallet and asserts WalletModel::requireUnlock actually fires — pinning the reachability this PR exists to restore — plus the cancel path
  • coverage for the encrypted and watch-only wallet, the one state where "locked" and "cannot sign" are both true, which was previously untested in both widgets

DigiDollarWidgetTests goes 87 → 89 passed (0 failed, 3 skipped — the pre-existing platform-gated skips), and the full Qt suite passes.

Note on GUI/RPC parity

listdigidollarpositions still reports can_redeem / spendable as false while the wallet is locked, whereas the GUI now offers the action. That asymmetry is intentional: the GUI can prompt for a passphrase and the RPC cannot, so the RPC's answer to "can this be redeemed right now, without further input?" remains correct. Happy to align the RPC wording separately if maintainers would prefer.


Prepared with AI assistance; all changes were human-reviewed, and every test result above was independently reproduced before submission.

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.
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.
@JohnnyLawDGB
JohnnyLawDGB force-pushed the fix/qt-dd-locked-wallet-redeem branch from 918b69a to 409f84e Compare August 10, 2026 22:57
@JohnnyLawDGB

Copy link
Copy Markdown
Author

Note on the macOS CI failure (previous run)

The macOS 14 ARM64 job on the previous push failed in Run Functional Tests, on rpc_blockchain.py (and its --v2transport variant):

File "test/functional/rpc_blockchain.py", line 519, in _test_stopatheight
    self.nodes[0].wait_until_stopped()
AssertionError: [node 0] Node returned unexpected exit code (-6) vs (0) when stopping

This is a pre-existing flake on develop, not a regression from this PR:

  • The identical failure occurs on develop with no PR involved. Run 28624106866 (push to develop, 2026-07-02) failed on the same macOS job, the same rpc_blockchain.py, and the same _test_stopatheight exit-code -6 assertion — the daemon SIGABRTs during shutdown instead of exiting cleanly.
  • This PR touches only src/qt/ (11 files, all GUI and GUI tests). rpc_blockchain.py exercises headless digibyted shutdown via -stopatheight; there is no code path from this diff to it.
  • Ubuntu 22.04 passed on the same push with the identical functional-test suite, and both the build and unit tests passed on macOS — only the daemon shutdown test aborted.

I've force-pushed to re-trigger CI. Happy to open a separate issue tracking the _test_stopatheight macOS flake if that's useful.

@JohnnyLawDGB

Copy link
Copy Markdown
Author

Update: stronger evidence, and a correction to my previous comment

I called this a flake above and said a re-run would clear it. The re-run reproduced it immediately, on the other platform, so "flake" understates it — this looks like a genuine intermittent shutdown bug in digibyted, unrelated to this PR.

The two runs, on an identical tree (I amended the tip commit to re-trigger CI; 918b69a5b9409f84efc8 changed the SHA and nothing else):

Run macOS 14 ARM64 Ubuntu 22.04
918b69a5b9 FAIL pass
409f84efc8 pass FAIL

Same test both times — rpc_blockchain.py, _test_stopatheight (rpc_blockchain.py:519) — with two symptoms of one root cause, the node failing to shut down cleanly on -stopatheight:

  • macOS: AssertionError: [node 0] Node returned unexpected exit code (-6) vs (0) when stopping — SIGABRT during shutdown, ~10s.
  • Ubuntu: AssertionError: Predicate ... not true after 120.0 seconds — the node never stops at all, ~136s.

The platforms trading places across two runs of the same tree rules out both the diff and any platform-specific cause. It also reproduces on bare develop with no PR involved — run 28624106866 (push to develop, 2026-07-02), same test, same function, same exit-code -6 assertion. Rough hit rate is 3 of the last 8 long-running jobs.

For completeness on why it can't be this PR: the diff is 11 files, all under src/qt/; rpc_blockchain.py drives headless digibyted and never starts the GUI. Build and unit tests passed on both platforms in both runs.

Separate CI gap worth fixing

The functional-test logs are never captured, so there's no post-mortem evidence for failures like this one:

##[warning]No files were found with the provided path: src/test-suite.log.
No artifacts will be uploaded.

src/test-suite.log is the automake unit test log; functional-test output lives under /tmp/test_runner_*/ and is discarded when the runner tears down. Uploading the functional test dir (or running the suite with --combinedlogslen) on failure would make these self-diagnosing.

I'm chasing the shutdown bug locally now and will open a separate issue with whatever I find. Happy to file the artifact-path gap separately too. Neither should block review of this PR.

@JohnnyLawDGB

Copy link
Copy Markdown
Author

Root-caused and filed as #441OracleSigningOrchestrator::Shutdown() destroys the orchestrator while a queued BlockConnected callback is still in flight, so the scheduler thread locks a destroyed mutex (deadlock on Linux, abort() on macOS). Reproduced locally under load and confirmed with thread states from a live hung node; a one-line fix takes it from failing-on-round-1 to 0 failures in 60 runs.

Nothing to change in this PR — it remains a Qt-only diff, and the red check here is that bug.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant