diff --git a/src/wallet/lite_client_bridge.cpp b/src/wallet/lite_client_bridge.cpp index 7ce1be7..f897110 100644 --- a/src/wallet/lite_client_bridge.cpp +++ b/src/wallet/lite_client_bridge.cpp @@ -94,7 +94,8 @@ LiteClientBridge LiteClientBridge::linkedSdxl() LiteClientBridge::LiteClientBridge(LiteClientBridge&& other) noexcept : api_(other.api_), unavailableReason_(std::move(other.unavailableReason_)), - shutdownCalled_(other.shutdownCalled_) + shutdownCalled_(other.shutdownCalled_), + mutationEpoch_(other.mutationEpoch_.load(std::memory_order_acquire)) { other.api_ = {}; other.shutdownCalled_ = true; @@ -107,6 +108,8 @@ LiteClientBridge& LiteClientBridge::operator=(LiteClientBridge&& other) noexcept api_ = other.api_; unavailableReason_ = std::move(other.unavailableReason_); shutdownCalled_ = other.shutdownCalled_; + mutationEpoch_.store(other.mutationEpoch_.load(std::memory_order_acquire), + std::memory_order_release); other.api_ = {}; other.shutdownCalled_ = true; return *this; @@ -158,11 +161,29 @@ LiteBridgeStringResult LiteClientBridge::initializeExisting(bool dangerous, cons return takeOwnedString(api_.initializeExisting(dangerous, server.c_str())); } +bool LiteClientBridge::commandIsReadOnly(const std::string& c) +{ + // Allow-list of commands that never alter wallet-visible data. sync/rescan are included: their + // effect (new blocks) is observed through the worker's height signal, and the worker's own sync + // probe must not self-dirty. UNKNOWN or omitted commands default to "mutating" (bump), so a + // missing entry only ever costs one extra refresh -- never staleness. + static const char* const kReadOnly[] = { + "balance", "list", "notes", "addresses", "info", "height", "syncstatus", + "encryptionstatus", "seed", "export", "save", "sync", "rescan", "help", + "coinsupply", "saplingtree"}; + for (const char* r : kReadOnly) + if (c == r) return true; + return false; +} + LiteBridgeStringResult LiteClientBridge::execute(const std::string& command, const std::string& args) { if (!available()) return unavailableResult(); if (command.empty()) return {false, {}, "lite command is empty"}; - return takeOwnedString(api_.execute(command.c_str(), args.c_str())); + auto result = takeOwnedString(api_.execute(command.c_str(), args.c_str())); + if (!commandIsReadOnly(command)) // bump AFTER the command returns, even on failure (be conservative) + mutationEpoch_.fetch_add(1, std::memory_order_acq_rel); + return result; } void LiteClientBridge::shutdown() diff --git a/src/wallet/lite_client_bridge.h b/src/wallet/lite_client_bridge.h index d7cf292..a340282 100644 --- a/src/wallet/lite_client_bridge.h +++ b/src/wallet/lite_client_bridge.h @@ -5,6 +5,8 @@ #pragma once #include +#include +#include namespace dragonx { namespace wallet { @@ -69,15 +71,24 @@ public: LiteBridgeStringResult execute(const std::string& command, const std::string& args); void shutdown(); + // Monotonic counter bumped AFTER every execute() of a command that could change wallet-visible + // data (anything not on the read-only allow-list). The refresh worker samples this to detect + // local mutations made on ANY thread without hooking each controller method. Compare-only (never + // cleared): the worker stores a baseline and refreshes whenever it differs, so a mutation can + // never be "consumed"/lost in a race. + std::uint64_t mutationEpoch() const { return mutationEpoch_.load(std::memory_order_acquire); } + private: LiteClientBridge(LiteClientBridgeApi api, std::string unavailableReason); LiteBridgeStringResult unavailableResult() const; LiteBridgeStringResult takeOwnedString(char* rawValue) const; + static bool commandIsReadOnly(const std::string& command); LiteClientBridgeApi api_; std::string unavailableReason_; bool shutdownCalled_ = false; + std::atomic mutationEpoch_{0}; }; } // namespace wallet diff --git a/src/wallet/lite_wallet_controller.cpp b/src/wallet/lite_wallet_controller.cpp index c8ce46c..d86083d 100644 --- a/src/wallet/lite_wallet_controller.cpp +++ b/src/wallet/lite_wallet_controller.cpp @@ -1587,25 +1587,105 @@ void LiteWalletController::stopWorker() void LiteWalletController::workerLoop() { - int failStreak = 0; // consecutive cycles whose syncing reads hit a backend error + int failStreak = 0; // consecutive cycles whose syncing reads hit a backend error + RefreshGate gate; // worker-local; haveFull=false => the FIRST cycle is always a full refresh while (running_.load()) { if (walletOpen_.load()) { - bool syncReadFailed = false; - auto model = refreshModelImpl(/*honorStop=*/true, &syncReadFailed); - if (model && running_.load()) { - std::lock_guard lock(modelMutex_); - pendingModel_ = std::move(model); + const GateDecision decision = evaluateRefreshGate(gate, failStreak); + if (decision == GateDecision::Skip) { + // Nothing changed since the last full model: no gateway refresh, no re-publish. + // pendingModel_ is left untouched (the UI keeps the last applied model). + } else if (decision == GateDecision::ProbeFailed) { + // The cheap `sync` probe failed (server unreachable / behind / terminal reorg). Do NOT + // run a second do_sync this cycle; treat it as a failed cycle so the existing back-off + // applies and we don't double the dead-server stall. Next cycle takes the full path. + failStreak += 1; + } else { // DoFull + // Sample the change baselines BEFORE the data commands (race rule). + const std::uint64_t epoch0 = bridge_ ? bridge_->mutationEpoch() : 0; + const auto height0 = readScannedHeight(); + bool syncReadFailed = false; + auto model = refreshModelImpl(/*honorStop=*/true, &syncReadFailed); + if (model && running_.load()) { + const auto& m = *model; + const bool full = m.hasBalance && m.hasSyncStatus && m.sync.complete + && m.syncError.empty() && !syncReadFailed && height0.has_value(); + gate.haveFull = full; + gate.height = height0.value_or(0); + gate.epoch = epoch0; + gate.encOk = m.hasEncryptionStatus; + gate.encrypted = m.encrypted; + gate.locked = m.locked; + gate.fullAt = std::chrono::steady_clock::now(); + std::lock_guard lock(modelMutex_); + pendingModel_ = std::move(model); + } else { + gate.haveFull = false; // syncing / failed / cancelled: next cycle is a full path + } + failStreak = syncReadFailed ? failStreak + 1 : 0; } - failStreak = syncReadFailed ? failStreak + 1 : 0; } - // Healthy: every 2 s. After a failed syncing read: 4 s, 8 s, then 15 s, so a dead server isn't retried - // back-to-back (each attempt is one do_sync with up to 62 s of backend retry back-off). + // Healthy: every 2 s. After a failed read (or a failed probe): 4 s, 8 s, then 15 s, so a dead + // server isn't retried back-to-back (each attempt is one do_sync with up to 62 s of back-off). std::unique_lock lock(wakeMutex_); - wakeCv_.wait_for(lock, std::chrono::milliseconds(liteRefreshDelayMs(failStreak, kRefreshIntervalMs)), + wakeCv_.wait_for(lock, + std::chrono::milliseconds(liteRefreshDelayMs(failStreak, refreshIntervalMs_.load())), [this] { return !running_.load(); }); } } +// Worker-thread only. Decide whether this cycle can skip the full refresh. A cycle is a Skip ONLY when +// the wallet is synced, healthy, and every cheap signal proves nothing changed since the last published +// full model. ANY failed/unparsable read, or any doubt, returns DoFull (fail toward freshness). Runs +// exactly one server-bound `sync` probe (the mandatory confirmed-block ingest); a probe failure returns +// ProbeFailed so the caller backs off without a second sync. +LiteWalletController::GateDecision +LiteWalletController::evaluateRefreshGate(RefreshGate& g, int failStreak) +{ + if (!g.haveFull || failStreak != 0 || !bridge_ || !running_.load()) + return GateDecision::DoFull; + if (!syncDone_->load() || scan_->failed.load()) { + g.haveFull = false; + return GateDecision::DoFull; + } + if (std::chrono::steady_clock::now() - g.fullAt + >= std::chrono::milliseconds(gateHeartbeatMs_.load())) + return GateDecision::DoFull; // heartbeat: bounded-staleness backstop + if (bridge_->mutationEpoch() != g.epoch) // local mutation since the baseline + return GateDecision::DoFull; + + // The mandatory one-connection confirmed-block ingest. Without it the wallet never advances. + const auto probe = bridge_->execute("sync", ""); + if (!running_.load()) return GateDecision::DoFull; + if (!probe.ok || !parseLiteSyncOpResponse(probe.value).ok) { + g.haveFull = false; + return GateDecision::ProbeFailed; + } + + const auto h = readScannedHeight(); + if (!h.has_value() || *h == 0 || *h != g.height) + return GateDecision::DoFull; + + const auto enc = encryptionStatus(); + if (!enc.ok || !g.encOk || enc.encrypted != g.encrypted || enc.locked != g.locked) + return GateDecision::DoFull; + + if (bridge_->mutationEpoch() != g.epoch) return GateDecision::DoFull; + if (!syncDone_->load() || !running_.load()) { g.haveFull = false; return GateDecision::DoFull; } + + return GateDecision::Skip; // provably unchanged +} + +std::optional LiteWalletController::readScannedHeight() +{ + if (!bridge_) return std::nullopt; + const auto h = bridge_->execute("height", ""); // local read in the backend (no TLS connection) + if (!h.ok) return std::nullopt; + const auto parsed = parseLiteHeightResponse(h.value); + if (!parsed.ok || parsed.height.height == 0) return std::nullopt; + return static_cast(parsed.height.height); +} + LiteWalletLifecycleResult LiteWalletController::createWallet(LiteWalletCreateRequest request) { auto result = lifecycle_.createWallet(request); diff --git a/src/wallet/lite_wallet_controller.h b/src/wallet/lite_wallet_controller.h index e992e98..5dc4261 100644 --- a/src/wallet/lite_wallet_controller.h +++ b/src/wallet/lite_wallet_controller.h @@ -259,6 +259,8 @@ public: std::string scanError() const { return scan_->errorCopy(); } // Tests only: first sync-retry delay in ms (default 5000); call before createWallet()/startSync(). void setSyncRetryBaseMsForTest(int ms) { scan_->retryBaseMs.store(ms); } + void setRefreshIntervalMsForTest(int ms) { refreshIntervalMs_.store(ms); } + void setGateHeartbeatMsForTest(int ms) { gateHeartbeatMs_.store(ms); } // Launch the backend sync on a detached background thread (NON-blocking; the backend's // `sync` command runs a full, uninterruptible chain scan). Auto-invoked when a lifecycle @@ -513,7 +515,23 @@ private: std::condition_variable wakeCv_; std::mutex modelMutex_; std::optional pendingModel_; // guarded by modelMutex_ - static constexpr int kRefreshIntervalMs = 2000; + std::atomic refreshIntervalMs_{2000}; + // C23: max time an UNCHANGED synced wallet may go without a full refresh+publish. Bounds the + // staleness of same-height changes the cheap signals can't see (incoming mempool txs; pending + // t-address scans) and backstops any external state reset. Atomic for test override. + std::atomic gateHeartbeatMs_{10000}; + + // Worker-thread-only snapshot of the last published FULL synced model (only workerLoop() touches it). + struct RefreshGate { + bool haveFull = false; + std::uint64_t height = 0; + std::uint64_t epoch = 0; + bool encOk = false, encrypted = false, locked = false; + std::chrono::steady_clock::time_point fullAt{}; + }; + enum class GateDecision { Skip, DoFull, ProbeFailed }; + GateDecision evaluateRefreshGate(RefreshGate& gate, int failStreak); + std::optional readScannedHeight(); }; } // namespace wallet diff --git a/tests/fake_lite_backend.h b/tests/fake_lite_backend.h index 8e89980..f2fac70 100644 --- a/tests/fake_lite_backend.h +++ b/tests/fake_lite_backend.h @@ -43,6 +43,7 @@ inline std::atomic g_liteFakeSyncCalls{0}; inline std::atomic g_liteFakeBadBalance{false}; // when true, "balance" returns invalid JSON inline std::atomic g_liteFakeSendFails{false}; // when true, "send"/"shield" return {"error":..} inline std::atomic g_liteFakeSaveCount{0}; // counts "save" commands (persistence checks) +inline std::atomic g_liteFakeHeight{1000}; // settable last-scanned height (C23 gate test) inline std::atomic g_liteFakeEncrypted{false}; // wallet-encryption state machine (encrypt/...) inline std::atomic g_liteFakeLocked{false}; // spending-keys-locked state inline std::atomic g_liteFakeBlockNew{false}; // "new" blocks (models do_save waiting on sync_lock) @@ -87,6 +88,7 @@ inline void resetLiteFakeCounters() { g_liteFakeAlloc = 0; g_liteFakeFreed = 0; + g_liteFakeHeight = 1000; g_liteFakeShutdownCalled = false; g_liteFakeDeadServerSubstr.clear(); g_liteFakeWarmupServerSubstr.clear(); @@ -215,7 +217,8 @@ inline char* liteFakeExecute(const char* command, const char* args) if (std::strcmp(c, "list") == 0) return liteFakeDup("[{\"txid\":\"faketx\",\"datetime\":1700000000,\"block_height\":990," "\"unconfirmed\":false,\"address\":\"zs1fakeaddr\",\"amount\":150000000,\"memo\":\"\"}]"); - if (std::strcmp(c, "height") == 0) return liteFakeDup("{\"height\":1000}"); + if (std::strcmp(c, "height") == 0) + return liteFakeDup(("{\"height\":" + std::to_string(g_liteFakeHeight.load()) + "}").c_str()); if (std::strcmp(c, "info") == 0) return liteFakeDup("{\"chain_name\":\"main\",\"version\":\"sdxl-fake\",\"latest_block_height\":1000}"); // M4 spend/backup commands. send/shield report failure via {"error":..} (NOT an diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 80d0e61..83b7309 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -11762,6 +11762,98 @@ void testLiteWalletControllerWorkerProducesModel() EXPECT_FALSE(idle.takeRefreshedModel(none)); } +// C23: the background worker change-gates the refresh -- an unchanged, synced cycle skips the full +// gateway refresh + re-publish, but any real change (height advance, local mutation, encryption flip) +// forces a full refresh within a cycle. Confirmed-funds freshness is the overriding rule. +void testLiteWalletControllerWorkerChangeGate() +{ + using namespace dragonx::wallet; + namespace t = dragonx::test; + const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, false, true); + const auto conn = defaultLiteConnectionSettings(); + t::resetLiteFakeCounters(); + t::g_liteFakeLogEnabled = true; + + LiteWalletController controller(caps, conn, LiteClientBridge::fromApi(t::makeFakeLiteApi())); + controller.setGateHeartbeatMsForTest(600000); // disable the heartbeat so only real signals refresh + controller.setRefreshIntervalMsForTest(20); // fast cycles + EXPECT_TRUE(controller.createWallet(LiteWalletCreateRequest{}).walletReady); + + // Arm: wait for the first full (balance-bearing) model. + auto waitFull = [&](int maxMs) { + LiteWalletAppRefreshModel m; bool got = false; + for (int i = 0; i < maxMs / 5 && !got; ++i) { + if (controller.takeRefreshedModel(m) && m.hasBalance) got = true; + else std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } + return got; + }; + EXPECT_TRUE(waitFull(3000)); + + // (1) Skip when nothing changed: settle, then over a window no data commands run and nothing republishes. + std::this_thread::sleep_for(std::chrono::milliseconds(120)); // settle into the Skip steady state + (void)t::liteFakeTakeLog(); + { LiteWalletAppRefreshModel drain; while (controller.takeRefreshedModel(drain)) {} } + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + auto idleLog = t::liteFakeTakeLog(); + EXPECT_EQ(t::liteFakeCount(idleLog, "balance"), 0L); + EXPECT_EQ(t::liteFakeCount(idleLog, "list"), 0L); + EXPECT_EQ(t::liteFakeCount(idleLog, "notes"), 0L); + EXPECT_TRUE(t::liteFakeCount(idleLog, "sync") >= 1L); // the one-connection probe still runs each idle cycle + LiteWalletAppRefreshModel noPub; + EXPECT_FALSE(controller.takeRefreshedModel(noPub)); // no re-publish on idle cycles + + // (2) Height advance -> full refresh + publish. + (void)t::liteFakeTakeLog(); + t::g_liteFakeHeight = 1001; + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + auto advLog = t::liteFakeTakeLog(); + EXPECT_TRUE(t::liteFakeCount(advLog, "balance") >= 1L); + EXPECT_TRUE(waitFull(2000)); + + // (3) Local mutation (new address) bumps the bridge epoch -> full refresh, even at the same height. + std::this_thread::sleep_for(std::chrono::milliseconds(120)); + (void)t::liteFakeTakeLog(); + { LiteWalletAppRefreshModel drain; while (controller.takeRefreshedModel(drain)) {} } + controller.newAddress(/*shielded*/ true); + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + EXPECT_TRUE(t::liteFakeCount(t::liteFakeTakeLog(), "balance") >= 1L); + + // (4) Encryption state change with no bridge mutation (signal 7, epoch unchanged) -> full refresh. + // The fake reports `locked` only while `encrypted`, so flip `encrypted` directly (not via a bridge + // command, so the mutation epoch stays put and ONLY the enc-status compare can trigger the refresh). + std::this_thread::sleep_for(std::chrono::milliseconds(120)); + (void)t::liteFakeTakeLog(); + { LiteWalletAppRefreshModel drain; while (controller.takeRefreshedModel(drain)) {} } + t::g_liteFakeEncrypted = true; + t::g_liteFakeLocked = true; + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + EXPECT_TRUE(t::liteFakeCount(t::liteFakeTakeLog(), "balance") >= 1L); + + t::g_liteFakeEncrypted = false; + t::g_liteFakeLocked = false; + t::g_liteFakeLogEnabled = false; +} + +// C23: the lite bridge bumps its mutation epoch for wallet-mutating commands and leaves it unchanged +// for read-only ones, so the refresh worker can detect local mutations from any thread. +void testLiteClientBridgeMutationEpoch() +{ + using namespace dragonx::wallet; + auto bridge = LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()); + const auto e0 = bridge.mutationEpoch(); + for (const char* ro : {"balance", "height", "sync", "syncstatus", "encryptionstatus", + "save", "addresses", "list", "notes"}) + bridge.execute(ro, ""); + EXPECT_TRUE(bridge.mutationEpoch() == e0); // no read-only command bumps + for (const char* mut : {"send", "shield", "new", "import", "timport", + "encrypt", "unlock", "lock", "decrypt", "frobnicate"}) { + const auto before = bridge.mutationEpoch(); + bridge.execute(mut, ""); + EXPECT_TRUE(bridge.mutationEpoch() == before + 1); // each mutating (or unknown) command bumps exactly once + } +} + // M2b-3 hardening: the backend `sync` is a blocking, uninterruptible full scan. Destroying the // controller while a sync is in flight must NOT hang (the sync thread is detached, not joined). void testLiteWalletControllerShutdownDoesNotHangDuringSync() @@ -18944,6 +19036,8 @@ int main() testLiteWalletControllerRefreshPopulatesState(); testLiteWalletGatewayRefreshSkipsFailedCommand(); testLiteWalletControllerWorkerProducesModel(); + testLiteWalletControllerWorkerChangeGate(); + testLiteClientBridgeMutationEpoch(); testLiteWalletControllerShutdownDoesNotHangDuringSync(); testSerialJobLane(); testLiteGatewayRefreshPolicyHelpers();