diff --git a/docs/lite-wallet-implementation-plan-v2-2026-06-04.md b/docs/lite-wallet-implementation-plan-v2-2026-06-04.md index 9f508ab..5892f5e 100644 --- a/docs/lite-wallet-implementation-plan-v2-2026-06-04.md +++ b/docs/lite-wallet-implementation-plan-v2-2026-06-04.md @@ -97,7 +97,7 @@ Each milestone is independently demoable and gated by a fake-backend test. Order > - ✅ **M2b-2 — sync + controller refresh (done + tested).** `LiteSyncService::startSync` now executes the `sync` command (was a stub). `LiteWalletController` gained `startSync()` (auto-invoked when a wallet becomes ready) and `refreshWalletState(WalletState&)` which polls `syncstatus`, runs `gateway.refresh()`, maps the bundle, and applies it into `WalletState`. `testLiteWalletControllerRefreshPopulatesState()` drives the full path against the real-shape fake (balances/addresses/transactions/sync populated; no-op when no wallet open). The fake harness now returns command-shaped JSON per `tests/fixtures/lite/result_parsers.json`. (Surfaced a real bug: `info` requires `latest_block_height`, and the gateway aborts the whole refresh on the first command's parse failure — fixed in the fake; worth noting the gateway's abort-on-first-failure is fragile against partial backend responses.) > - ✅ **M2b-3 — threaded App hook (done + tested).** `LiteWalletController` owns a background worker (`std::thread`) that, once a wallet is ready, refreshes every ~4s and publishes a copyable `LiteWalletAppRefreshModel` under a mutex; `App::update()` calls `takeRefreshedModel()` and applies it into `state_` on the main thread (WalletState is non-copyable, so the model crosses the thread boundary, not the state). Worker auto-starts on lifecycle-ready and is stopped+joined in the controller destructor. `status_` is written only on the main thread to avoid races; `walletOpen_`/`syncStarted_` are atomic. `testLiteWalletControllerWorkerProducesModel()` opens a wallet and asserts the worker publishes a populated model (stable across repeated runs). Builds clean in all configs. > **Real-backend refresh smoke (2026-06-04): ran `lite_smoke --create --refresh` against the live backend — found two real bugs** the fake/fixture couldn't (smoke now links `lite_result_parsers` and runs each command's real output through the parser): -> 1. **OPEN — `syncstatus` parser mismatch.** `parseLiteSyncStatusResponse` hard-requires `synced_blocks`/`total_blocks`, but the real backend (per `commands.rs:83-87`) returns **idle = `{"syncing":"false"}`** (string!) and only **in-progress = `{"syncing":"true","synced_blocks":N,"total_blocks":M}`**. So syncstatus fails to parse whenever the wallet isn't actively syncing → sync/progress never updates in the real app. Fix: read `syncing` as a string, make the block fields optional. (info/balance/addresses parsers verified OK against real output.) +> 1. **FIXED — `syncstatus` parser mismatch.** `parseLiteSyncStatusResponse` hard-required `synced_blocks`/`total_blocks`, but the real backend (per `commands.rs:83-87`) returns **idle = `{"syncing":"false"}`** (string!) and only **in-progress = `{"syncing":"true","synced_blocks":N,"total_blocks":M}`**. The parser now reads `syncing` as a string and treats the block fields as in-progress-only (idle → complete, synced/total 0). Covered by `testLiteSyncStatusParserRealShapes()` and **verified against the live backend** (`syncstatus parse_ok=1`). (info/balance/addresses parsers also verified OK against real output.) > 2. **OPEN — first data query blocks on a full chain sync.** `execute("balance"/"list")` on a fresh wallet triggers a synchronous multi-million-block sync (observed "Syncing 1.76M/2.99M…"). On the M2b-3 worker thread that means the controller's destructor `join()` would hang at app shutdown. Needs: a cancel/timeout path for in-flight refresh (e.g., don't block shutdown on the worker), and likely gating data fetches until sync has progressed. **This is the main blocker for a usable real lite wallet** and should lead M2 polish / M3. > > - ⏳ **Remaining for M2 polish:** fix the syncstatus parser (above), address the blocking-sync/worker-shutdown issue (above), per-address balances (notes-correlation; currently aggregate-only), and harden the gateway's abort-on-first-failure (skip-and-continue per command). diff --git a/src/wallet/lite_result_parsers.cpp b/src/wallet/lite_result_parsers.cpp index 6959dea..1ff3556 100644 --- a/src/wallet/lite_result_parsers.cpp +++ b/src/wallet/lite_result_parsers.cpp @@ -557,11 +557,27 @@ LiteSyncStatusParseResult parseLiteSyncStatusResponse(const json& value) { auto result = makeResult(LiteResultCommand::SyncStatus); if (!requireObject(value, "$", result)) return result; - if (!readRequiredUnsignedField(value, "synced_blocks", result.syncStatus.syncedBlocks, result)) return result; - if (!readRequiredUnsignedField(value, "total_blocks", result.syncStatus.totalBlocks, result)) return result; - if (result.syncStatus.totalBlocks > 0) { - result.syncStatus.progress = std::min(1.0, static_cast(result.syncStatus.syncedBlocks) / static_cast(result.syncStatus.totalBlocks)); - result.syncStatus.complete = result.syncStatus.syncedBlocks >= result.syncStatus.totalBlocks; + + // The backend reports "syncing" as a STRING ("true"/"false"). synced_blocks/total_blocks + // are present only while actively syncing; an idle wallet returns just {"syncing":"false"}. + std::string syncing = "false"; + if (!readOptionalStringField(value, "syncing", syncing, result)) return result; + const bool isSyncing = (syncing == "true" || syncing == "1"); + + if (isSyncing) { + if (!readRequiredUnsignedField(value, "synced_blocks", result.syncStatus.syncedBlocks, result)) return result; + if (!readRequiredUnsignedField(value, "total_blocks", result.syncStatus.totalBlocks, result)) return result; + result.syncStatus.complete = false; // actively syncing + result.syncStatus.progress = result.syncStatus.totalBlocks > 0 + ? std::min(1.0, static_cast(result.syncStatus.syncedBlocks) / + static_cast(result.syncStatus.totalBlocks)) + : 0.0; + } else { + // Not actively syncing: the backend reports no block counts. Treat as idle/caught-up. + result.syncStatus.syncedBlocks = 0; + result.syncStatus.totalBlocks = 0; + result.syncStatus.complete = true; + result.syncStatus.progress = 1.0; } succeed(result); return result; diff --git a/tests/fake_lite_backend.h b/tests/fake_lite_backend.h index 2263909..6990006 100644 --- a/tests/fake_lite_backend.h +++ b/tests/fake_lite_backend.h @@ -65,8 +65,8 @@ inline char* liteFakeExecute(const char* command, const char*) if (command) { const char* c = command; if (std::strcmp(c, "sync") == 0) return liteFakeDup("{\"result\":\"success\"}"); - if (std::strcmp(c, "syncstatus") == 0) - return liteFakeDup("{\"synced_blocks\":1000,\"total_blocks\":1000}"); + if (std::strcmp(c, "syncstatus") == 0) // real backend shape: "syncing" is a string + return liteFakeDup("{\"syncing\":\"true\",\"synced_blocks\":1000,\"total_blocks\":1000}"); if (std::strcmp(c, "balance") == 0) return liteFakeDup("{\"tbalance\":100000000,\"zbalance\":200000000,\"unconfirmed\":50000000," "\"verified_zbalance\":180000000,\"spendable_zbalance\":170000000}"); diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index a114908..390717e 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -4651,6 +4651,36 @@ void testLiteWalletControllerRefreshPopulatesState() EXPECT_FALSE(fresh.refreshWalletState(empty)); } +// syncstatus parser must accept both real backend shapes: idle {"syncing":"false"} and +// in-progress {"syncing":"true","synced_blocks":N,"total_blocks":M} ("syncing" is a string). +void testLiteSyncStatusParserRealShapes() +{ + using namespace dragonx::wallet; + + // Idle: no block counts, syncing is the string "false". + { + const auto p = parseLiteSyncStatusResponse(std::string("{\"syncing\":\"false\"}")); + EXPECT_TRUE(p.ok); + EXPECT_EQ(static_cast(p.syncStatus.syncedBlocks), 0LL); + EXPECT_TRUE(p.syncStatus.complete); + } + // In-progress: block counts present. + { + const auto p = parseLiteSyncStatusResponse( + std::string("{\"syncing\":\"true\",\"synced_blocks\":900,\"total_blocks\":1000}")); + EXPECT_TRUE(p.ok); + EXPECT_EQ(static_cast(p.syncStatus.syncedBlocks), 900LL); + EXPECT_EQ(static_cast(p.syncStatus.totalBlocks), 1000LL); + EXPECT_FALSE(p.syncStatus.complete); + EXPECT_NEAR(p.syncStatus.progress, 0.9, 1e-9); + } + // While syncing, missing block counts is still an error. + { + const auto p = parseLiteSyncStatusResponse(std::string("{\"syncing\":\"true\"}")); + EXPECT_FALSE(p.ok); + } +} + // M2b-3: opening a wallet auto-starts the background worker, which produces a refresh model // the main thread can pick up via takeRefreshedModel() and apply to WalletState. void testLiteWalletControllerWorkerProducesModel() @@ -4719,6 +4749,7 @@ int main() testLiteWalletControllerLifecycle(); testLiteChainNameMigration(); testLiteRefreshModelAppliesToWalletState(); + testLiteSyncStatusParserRealShapes(); testLiteWalletControllerRefreshPopulatesState(); testLiteWalletControllerWorkerProducesModel(); testLiteBridgeRuntimeShutdownIsIdempotent();