fix(lite): lock/unlock parity — shared attempt-lockout, immediate unlock state, no dead PIN, surfaced encrypt failure

Lite-parity audit, FIX C (gating/validation/presentation only — no encrypt/unlock/send backend call changed):
- Lite unlock went straight to the backend with no lockout. New App::unlockLiteWallet() runs the SAME
  escalating, persisted attempt-lockout as the full node (restoreLockoutState/applyUnlockFailure), wired into
  the Settings unlock, the send-time prompt and the lock screen. A wrong-passphrase failure counts; an
  already-unlocked/not-encrypted result does not.
- state_.locked is now cleared immediately on a successful unlock (the ~2s status poll lagged, so the UI
  stayed "locked" after a correct passphrase).
- PIN quick-unlock controls are hidden in lite Settings — lite has no PIN vault, so they were dead.
- A create/restore whose passphrase encryption FAILS now raises a one-shot visible error
  (takeEncryptionFailureNotice -> lite_encrypt_failed_unencrypted) instead of only a log line; the wallet is
  left open but unencrypted. Fixed the flag on BOTH the lifecycle and the direct create/restore encrypt paths.
- A background sync save is skipped while an encrypted wallet is unlocked (the backend save re-locks it),
  resuming once locked.

Compile- and unit-verified (testLiteWalletControllerLockUnlockParity covers the save-deferral + encrypt-failure
notice, run twice for the flaky guard); the lock-screen lockout/PIN-hiding UX itself is runtime/GUI-only and
not exercised here — worth a manual lite GUI pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 15:33:28 -05:00
co-authored by Claude Opus 4.8
parent 0cb1df3bfe
commit 16a1ca02e8
10 changed files with 199 additions and 10 deletions
+39 -4
View File
@@ -110,6 +110,21 @@ bool persistAfterBroadcast(LiteClientBridge& bridge)
return false;
}
// True when the wallet is encrypted AND currently unlocked. The backend's `save` re-locks such a wallet (do_save),
// so a background save must not run under a user who just unlocked. Fail-open: any error reads as "not unlocked" and
// the save proceeds as it always did.
bool liteWalletEncryptedAndUnlocked(LiteClientBridge& bridge)
{
const auto r = bridge.execute("encryptionstatus", "");
if (!r.ok) return false;
try {
const auto j = nlohmann::json::parse(r.value);
return j.is_object() && !j.contains("error") && j.value("encrypted", false) && !j.value("locked", false);
} catch (...) {
return false;
}
}
// The detached sync/rescan thread. `firstCommand` is "sync" or "rescan"; every retry is "sync" (a failed rescan has
// already cleared the wallet, so re-running it would throw away the partial progress). Partial progress is saved after
// every attempt. Captures shared state only.
@@ -129,7 +144,12 @@ void runLiteScanThread(const std::shared_ptr<LiteClientBridge>& bridge,
op.error = call.error.empty() ? "sync failed" : call.error;
}
// The backend does NOT auto-save after a sync; persist what was scanned even when the attempt failed.
if (!bridge->execute("save", "").ok) // W5-2
// Not while the user has the wallet unlocked: the backend save re-locks an encrypted+unlocked wallet, which
// would yank the unlock out from under a spend/unlock in progress. The progress is saved by the next scan
// once locked (and by the shutdown save); a crash in between only costs a re-scan from the checkpoint.
if (liteWalletEncryptedAndUnlocked(*bridge))
liteLog("sync save deferred: wallet is unlocked (a save would re-lock it)");
else if (!bridge->execute("save", "").ok) // W5-2
liteLog("save failed after sync — the next launch will re-scan from the checkpoint");
if (op.ok) {
scan->setError(std::string());
@@ -938,9 +958,11 @@ void LiteWalletController::applyPendingLifecycleSecurity()
if (op == PendingSecurityOp::Encrypt) {
// encryptWallet() takes its own copy and wipes it; the backend encrypts + locks + saves.
const auto enc = encryptWallet(pendingLifecyclePassphrase_);
if (!enc.ok)
if (!enc.ok) {
lifecycleEncryptFailed_ = true; // surfaced to the user by the App (takeEncryptionFailureNotice)
liteLog("wallet created/restored but ENCRYPTION FAILED — wallet is NOT encrypted: " +
enc.error);
}
} else if (op == PendingSecurityOp::Unlock) {
if (!unlockWallet(pendingLifecyclePassphrase_))
liteLog("wallet opened but unlock failed (wrong passphrase?)");
@@ -1597,6 +1619,13 @@ bool LiteWalletController::unlockWallet(std::string passphrase)
return ok;
}
bool LiteWalletController::takeEncryptionFailureNotice()
{
const bool failed = lifecycleEncryptFailed_;
lifecycleEncryptFailed_ = false;
return failed;
}
bool LiteWalletController::lockWallet()
{
if (!walletOpen_.load() || !bridge_) return false;
@@ -1767,7 +1796,10 @@ LiteWalletLifecycleResult LiteWalletController::createWallet(LiteWalletCreateReq
// copy and wipes it.
if (walletOpen_.load() && !request.passphrase.empty()) {
const auto enc = encryptWallet(request.passphrase);
if (!enc.ok) liteLog("wallet created but encryption failed: " + enc.error);
if (!enc.ok) {
lifecycleEncryptFailed_ = true; // the wallet is open but NOT encrypted; App surfaces this
liteLog("wallet created but encryption failed: " + enc.error);
}
}
secureWipeLiteSecret(request.passphrase);
return result;
@@ -1797,7 +1829,10 @@ LiteWalletLifecycleResult LiteWalletController::restoreWallet(LiteWalletRestoreR
// If the user supplied a passphrase, encrypt the restored wallet with it now that it's open (W5-3).
if (walletOpen_.load() && !request.passphrase.empty()) {
const auto enc = encryptWallet(request.passphrase);
if (!enc.ok) liteLog("wallet restored but encryption failed: " + enc.error);
if (!enc.ok) {
lifecycleEncryptFailed_ = true; // the wallet is open but NOT encrypted; App surfaces this
liteLog("wallet restored but encryption failed: " + enc.error);
}
}
secureWipeLiteSecret(request.seedPhrase);
secureWipeLiteSecret(request.passphrase);