diff --git a/src/app.cpp b/src/app.cpp index 646994d..43ee453 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -1070,6 +1070,7 @@ void App::update() // Auto-lock check (only when connected + encrypted + unlocked) if (state_.connected && state_.isUnlocked()) { checkAutoLock(); + reconcileUnlockExpiry(); // C90: follow the daemon when its absolute unlock window ran out } // Apply a settled mining thread-count pick: one settings save + at most one miner change per gesture. diff --git a/src/app.h b/src/app.h index e97154e..3462aaa 100644 --- a/src/app.h +++ b/src/app.h @@ -1942,6 +1942,8 @@ private: // Auto-lock on idle. Fed by real input events (noteUserInput, from main.cpp's event ingest), NOT by // widget state: a focused-but-untouched text field must not keep the wallet unlocked (C21). util::IdleTracker idle_; + // C90: back-off clock for the daemon-unlock-expiry reconcile (steady clock, wall-clock independent). + std::chrono::steady_clock::time_point unlock_reconcile_retry_at_{}; // Wall-clock delta for logic timers (see logicDeltaTime()). std::chrono::steady_clock::time_point last_update_time_{}; @@ -2052,6 +2054,9 @@ private: bool shouldRunWalletTransactionRefresh() const; bool shouldRefreshRecentTransactions() const; void checkAutoLock(); + // C90: the daemon's absolute unlock window ran out while the GUI still says "unlocked": re-read getwalletinfo + // (once per expiry, backed off) so the lock screen / tx-cache lock follow the daemon. + void reconcileUnlockExpiry(); void checkIdleMining(); }; diff --git a/src/app_security.cpp b/src/app_security.cpp index 657dc79..35429e5 100644 --- a/src/app_security.cpp +++ b/src/app_security.cpp @@ -758,6 +758,23 @@ void App::checkAutoLock() { } } +void App::reconcileUnlockExpiry() +{ + // Full node only: Lite has no daemon timer (unlocked_until stays 0), and its lock is the backend's. + if (lite_wallet_ || !state_.connected || !rpc_ || !rpc_->isConnected()) return; + if (!data::unlockWindowExpired(state_.encrypted, state_.locked, state_.unlocked_until, + static_cast(std::time(nullptr)))) return; + if (lock_unlock_in_progress_ || encrypt_in_progress_ || pin_in_progress_) return; // an unlock/lock/PIN change decides it + const auto now = std::chrono::steady_clock::now(); + if (now < unlock_reconcile_retry_at_) return; + // Back-off, wall-clock independent. refreshEncryptionState() coalesces via Job::Encryption and its apply path + // (applyWalletEncryptionResult) is authoritative: unlocked_until == 0 => locked (lock screen + tx-cache + // lock); a newer deadline (e.g. a console walletpassphrase, or the lock-screen re-verify) is adopted. If the + // daemon clock is skewed and still reports "unlocked", this retries every 30 s, one cheap-ish getwalletinfo. + unlock_reconcile_retry_at_ = now + std::chrono::seconds(30); + refreshEncryptionState(); +} + // =========================================================================== // Mine when idle — auto-start/stop mining based on system idle state // Supports two modes: @@ -2184,11 +2201,15 @@ void App::renderPinDialogs() { memset(pin_confirm_buf_, 0, sizeof(pin_confirm_buf_)); if (rpc_ && rpc_->isConnected() && worker_) { - worker_->post([this, passphrase, pin]() mutable -> rpc::RPCWorker::MainCb { + // C90: decided on the main thread (state_ is main-thread data). An unlocked wallet is verified + // with the time it has left and stays unlocked, so the GUI and the daemon keep agreeing. + const data::SetPinVerifyPlan verifyPlan = data::planSetPinVerify( + state_.isUnlocked(), state_.unlocked_until, static_cast(std::time(nullptr))); + worker_->post([this, passphrase, pin, verifyPlan]() mutable -> rpc::RPCWorker::MainCb { // Verify passphrase via RPC (worker thread) try { rpc::RPCClient::TraceScope trace("Security / PIN setup"); - rpc_->call("walletpassphrase", {passphrase, 5}); + rpc_->call("walletpassphrase", {passphrase, verifyPlan.timeoutSecs}); } catch (const std::exception& e) { if (!passphrase.empty()) util::SecureVault::secureZero(&passphrase[0], passphrase.size()); if (!pin.empty()) util::SecureVault::secureZero(&pin[0], pin.size()); @@ -2204,13 +2225,17 @@ void App::renderPinDialogs() { if (!passphrase.empty()) util::SecureVault::secureZero(&passphrase[0], passphrase.size()); if (!pin.empty()) util::SecureVault::secureZero(&pin[0], pin.size()); - // Lock wallet back - try { - rpc::RPCClient::TraceScope trace("Security / PIN setup"); - rpc_->call("walletlock"); - } catch (...) {} + // Lock wallet back — only if it was locked before the check (C90). + if (verifyPlan.relockAfter) { + try { + rpc::RPCClient::TraceScope trace("Security / PIN setup"); + rpc_->call("walletlock"); + } catch (...) {} + } return [this, storeOk]() { + // Re-read the daemon's lock state either way: it is the truth and self-heals any drift. + refreshEncryptionState(); if (storeOk) { pin_vault_stale_ = false; // the vault now seals the verified passphrase lock_use_pin_ = true; diff --git a/src/data/wallet_security_reconcile.h b/src/data/wallet_security_reconcile.h index 54b7103..a8f6a55 100644 --- a/src/data/wallet_security_reconcile.h +++ b/src/data/wallet_security_reconcile.h @@ -19,5 +19,32 @@ inline EncryptionPendingAction decideEncryptionPending(bool flagSet, bool rpcOk, return EncryptionPendingAction::Warn; } +// ---- C90 ---- +constexpr int kSetPinProbeSecs = 5; // the verification unlock used when the wallet is locked + +struct SetPinVerifyPlan { + int timeoutSecs; // walletpassphrase timeout for the passphrase check + bool relockAfter; // call walletlock afterwards (only if the wallet was locked before) +}; + +// Set PIN verifies the passphrase with walletpassphrase. If the wallet is unlocked now, re-unlock with the time +// it has left (replaces the daemon's timer with the same deadline) and DO NOT relock; otherwise a 5 s probe + relock. +inline SetPinVerifyPlan planSetPinVerify(bool guiUnlocked, std::int64_t unlockedUntil, std::int64_t now) +{ + const std::int64_t left = unlockedUntil - now; + if (guiUnlocked && left > kSetPinProbeSecs) { + const std::int64_t capped = left > 86400 * 7 ? 86400 * 7 : left; + return {static_cast(capped), false}; + } + return {kSetPinProbeSecs, true}; +} + +// The daemon's absolute unlock window has run out but the GUI still says "unlocked". Wall clock, because the +// deadline is an absolute unix time. unlockedUntil == 0 means "no deadline known" => never expired here. +inline bool unlockWindowExpired(bool encrypted, bool locked, std::int64_t unlockedUntil, std::int64_t now) +{ + return encrypted && !locked && unlockedUntil > 0 && now >= unlockedUntil; +} + } // namespace data } // namespace dragonx diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 75d374e..cf09f7f 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -1169,6 +1169,26 @@ void testEncryptionPendingPolicy() EXPECT_TRUE(decideEncryptionPending(false, true, false, false, false, false) == EncryptionPendingAction::None); } +void testWalletLockReconcilePolicy() +{ + using namespace dragonx::data; + // Set PIN while unlocked keeps the wallet unlocked for the time it has left. + auto p = planSetPinVerify(true, 1000 + 1700, 1000); + EXPECT_EQ(p.timeoutSecs, 1700); EXPECT_FALSE(p.relockAfter); + // Locked, or about to expire (<= 5 s left), or no deadline: 5 s probe + relock as before. + p = planSetPinVerify(false, 0, 1000); EXPECT_EQ(p.timeoutSecs, 5); EXPECT_TRUE(p.relockAfter); + p = planSetPinVerify(true, 1000 + 5, 1000); EXPECT_EQ(p.timeoutSecs, 5); EXPECT_TRUE(p.relockAfter); + p = planSetPinVerify(true, 0, 1000); EXPECT_TRUE(p.relockAfter); + p = planSetPinVerify(true, 1000 + 99999999, 1000); EXPECT_EQ(p.timeoutSecs, 86400 * 7); // capped + // Expiry predicate. + EXPECT_TRUE(unlockWindowExpired(true, false, 1000, 1000)); + EXPECT_TRUE(unlockWindowExpired(true, false, 1000, 5000)); + EXPECT_FALSE(unlockWindowExpired(true, false, 1000, 999)); + EXPECT_FALSE(unlockWindowExpired(true, true, 1000, 5000)); // already locked in the GUI + EXPECT_FALSE(unlockWindowExpired(false, false, 1000, 5000)); // unencrypted + EXPECT_FALSE(unlockWindowExpired(true, false, 0, 5000)); // no deadline known (Lite / unknown) +} + void testRefreshSchedulerDeferFor() { using dragonx::services::RefreshScheduler; @@ -17514,6 +17534,7 @@ int main() testRefreshScheduler(); testIdleTracker(); testEncryptionPendingPolicy(); + testWalletLockReconcilePolicy(); testRefreshSchedulerDeferFor(); testRpcBusyGate(); testIsLongWalletMethod();