From 5d317f6be30e67af3ce4eced387d3aacdc974eff Mon Sep 17 00:00:00 2001 From: DanS Date: Fri, 5 Jun 2026 12:48:44 -0500 Subject: [PATCH] =?UTF-8?q?feat(lite):=20M5a=20=E2=80=94=20wallet=20persis?= =?UTF-8?q?tence=20after=20sync/send/shield?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified against the SDXL Rust source that the backend auto-saves only on new-address / import / rescan; it does NOT save after sync, send, or shield, and litelib_shutdown merely sets a flag. So without intervention a first sync (~30 min) and any sent transaction are lost on restart. The controller now triggers the backend `save` at exactly the right points: - after the detached `sync` completes — and BEFORE syncDone_ is set, so a syncComplete() observer always sees a fully persisted wallet; - after a successful send / shield (the doSend/doShield cores; skipped on failure so a failed broadcast doesn't write); - a guarded best-effort flush in the destructor, only when syncDone_ and no broadcast is in flight, so shutdown never blocks on the wallet lock held by an uninterruptible scan or in-progress proving; - plus a public saveWallet() for explicit/periodic saves. Wallet-file crash recovery (.dat / .dat.bak rotation) is already handled inside the backend. Tests: testLiteWalletControllerM5Persistence proves saves fire after sync/send/shield and explicit saveWallet(), and do NOT fire on a failed send or with no wallet open (fake gains a save counter). Plan doc updated; M5b (packaging/CI/signing/rollout) remains. Co-Authored-By: Claude Opus 4.8 --- ...allet-implementation-plan-v2-2026-06-04.md | 9 ++ src/wallet/lite_wallet_controller.cpp | 31 ++++++- src/wallet/lite_wallet_controller.h | 6 ++ tests/fake_lite_backend.h | 5 ++ tests/test_phase4.cpp | 82 +++++++++++++++++++ 5 files changed, 130 insertions(+), 3 deletions(-) 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 5743c5c..17b374c 100644 --- a/docs/lite-wallet-implementation-plan-v2-2026-06-04.md +++ b/docs/lite-wallet-implementation-plan-v2-2026-06-04.md @@ -147,6 +147,15 @@ Each milestone is independently demoable and gated by a fake-backend test. Order - Runtime kill-switch / feature flag / staged rollout. - **Exit demo / test:** A downloadable `ObsidianDragonLite` that creates, syncs, sends, and persists against a real backend. +**Status (2026-06-05): M5a (durability) DONE; packaging/rollout (M5b) pending.** +Verified against the SDXL Rust source that the backend auto-saves only on new-address / import / rescan — it does **NOT** save after `sync`, `send`, or `shield`, and `litelib_shutdown` only sets a flag (no save). So without intervention a first sync (~30 min) and any sent tx are lost on restart. The controller now triggers `save` at exactly those points: +- after the detached `sync` completes (before `syncDone_` is set, so a `syncComplete()` observer sees a persisted wallet); +- after a successful `send` / `shield` (skipped on failure); +- a guarded best-effort flush in the destructor (only when `syncDone_` and no broadcast in flight, so shutdown never blocks on the scan's wallet lock); +- plus a public `saveWallet()` for explicit saves. +Crash/recovery of the wallet *file* (the `.dat`/`.dat.bak` rotation) is already handled inside the backend. Tests: `testLiteWalletControllerM5Persistence` proves saves fire after sync/send/shield and `saveWallet()`, and do NOT fire on a failed send or with no wallet open (fake tracks a save counter). +- **Pending (M5b):** release packaging (zip/AppImage/exe), CI backend-artifact build + signing, macOS, dynamic-loader sublane, runtime kill-switch/staged rollout, and end-to-end error/retry UX. Mostly build/CI/infra + GUI, not unit-verifiable here. + ## What we explicitly drop from the v1 plan - The "promote one disabled scaffold at a time" methodology and all `promotion → activation → post-closure → custody → handoff → stewardship → receipt` governance layers. diff --git a/src/wallet/lite_wallet_controller.cpp b/src/wallet/lite_wallet_controller.cpp index 4cd6649..70658fd 100644 --- a/src/wallet/lite_wallet_controller.cpp +++ b/src/wallet/lite_wallet_controller.cpp @@ -79,13 +79,19 @@ LiteBroadcastResult doSend(LiteClientBridge& bridge, const LiteSendRequest& requ if (!r.memo.empty()) o["memo"] = r.memo; arr.push_back(std::move(o)); } - return parseBroadcastResponse(bridge.execute("send", arr.dump())); + auto result = parseBroadcastResponse(bridge.execute("send", arr.dump())); + // The backend does NOT auto-save after a send, so persist the new transaction now (so it + // survives a restart). Best-effort: a save failure doesn't undo a broadcast that succeeded. + if (result.ok) bridge.execute("save", ""); + return result; } LiteBroadcastResult doShield(LiteClientBridge& bridge, const std::string& optionalAddress) { // Empty address -> shield all transparent funds; otherwise shield to the given address. - return parseBroadcastResponse(bridge.execute("shield", optionalAddress)); + auto result = parseBroadcastResponse(bridge.execute("shield", optionalAddress)); + if (result.ok) bridge.execute("save", ""); // shield does not auto-save either + return result; } } // namespace @@ -195,6 +201,13 @@ LiteWalletController::LiteWalletController(WalletCapabilities capabilities, LiteWalletController::~LiteWalletController() { stopWorker(); // joins the fast poll worker (short iterations) + // Best-effort flush on shutdown: the mempool monitor's unconfirmed updates aren't persisted + // by the backend (sync/send/shield already save inline). Guarded by syncDone_ and no in-flight + // broadcast so we never block shutdown waiting on the wallet lock held by an uninterruptible + // scan or an in-progress send proving. + if (walletOpen_.load() && syncDone_->load() && !broadcastInProgress() && bridge_) { + bridge_->execute("save", ""); + } // The sync thread may be blocked in an uninterruptible full scan; detach it. It holds // shared refs (bridge_ + syncDone_), so it stays safe and the bridge survives until it // finishes — the process is exiting, so a late litelib_shutdown is harmless. @@ -235,7 +248,13 @@ void LiteWalletController::startSync() auto bridge = bridge_; auto done = syncDone_; syncThread_ = std::thread([bridge, done] { - if (bridge) bridge->execute("sync", ""); // blocks until synced (or errors out) + if (bridge) { + bridge->execute("sync", ""); // blocks until synced (or errors out) + // The backend does NOT auto-save after a sync, so persist the freshly-scanned wallet; + // otherwise the next launch re-scans from the checkpoint (~30 min). Set `done` only + // after the save so a syncComplete() observer sees a fully-persisted wallet. + bridge->execute("save", ""); + } done->store(true); }); } @@ -477,6 +496,12 @@ LiteSeedResult LiteWalletController::exportSeed() return out; } +bool LiteWalletController::saveWallet() +{ + if (!walletOpen_.load() || !bridge_) return false; + return bridge_->execute("save", "").ok; +} + bool LiteWalletController::refreshWalletState(dragonx::WalletState& state) { auto model = refreshModel(); diff --git a/src/wallet/lite_wallet_controller.h b/src/wallet/lite_wallet_controller.h index 3c6ac3d..f289b78 100644 --- a/src/wallet/lite_wallet_controller.h +++ b/src/wallet/lite_wallet_controller.h @@ -173,6 +173,12 @@ public: LiteExportResult exportPrivateKeys(const std::string& optionalAddress = {}); LiteSeedResult exportSeed(); + // Persist the wallet to disk (backend `save`). The backend auto-saves on new-address/import, + // but NOT after sync/send/shield — the controller triggers a save at those points so a scan + // (~30 min on first run) and sent transactions survive a restart. Also callable explicitly. + // Returns false when no wallet is open or the backend save fails. + bool saveWallet(); + // Poll sync status + fetch balance/addresses/transactions, and apply the result into the // app's WalletState. Returns true if state was updated. Safe no-op when no wallet is open. // Synchronous (blocks on the backend); used by tests and as the worker's unit of work. diff --git a/tests/fake_lite_backend.h b/tests/fake_lite_backend.h index 6cfcb82..ac82163 100644 --- a/tests/fake_lite_backend.h +++ b/tests/fake_lite_backend.h @@ -35,6 +35,7 @@ inline bool g_liteFakeShutdownCalled = false; inline std::atomic g_liteFakeSyncBlock{false}; // when true, the "sync" command blocks 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 void resetLiteFakeCounters() { @@ -121,6 +122,10 @@ inline char* liteFakeExecute(const char* command, const char* args) // import (shielded key) / timport (transparent WIF): do_import_* pretty-print JSON. if (std::strcmp(c, "import") == 0 || std::strcmp(c, "timport") == 0) return liteFakeDup("{\"result\":\"success\",\"address\":\"zs1imported\"}"); + if (std::strcmp(c, "save") == 0) { + ++g_liteFakeSaveCount; + return liteFakeDup("{\"result\":\"success\"}"); + } } // Default for any other/unknown command. return liteFakeDup("{\"version\":\"sdxl-fake\"}"); diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 9dd46f1..941eec6 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -4677,6 +4677,87 @@ void testLiteWalletControllerM4() } } +// M5: persistence. The backend auto-saves on new-address/import but NOT after sync/send/shield, +// so the controller must trigger `save` at those points (otherwise a ~30-min scan and sent txs +// are lost on restart). Drives the fake's save counter to prove saves happen exactly when needed. +void testLiteWalletControllerM5Persistence() +{ + using namespace dragonx::wallet; + const auto liteCaps = makeWalletCapabilities(WalletBuildKind::Lite, false, true); + const LiteConnectionSettings conn = defaultLiteConnectionSettings(); + + auto openController = [&]() { + auto c = std::make_unique( + liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); + LiteWalletCreateRequest req; + req.passphrase = "hunter2"; + (void)c->createWallet(req); + return c; + }; + // Open and wait until the background sync (and its persist) has completed, so the save count + // is stable before a sub-test captures its baseline. + auto openSynced = [&]() { + auto c = openController(); + for (int i = 0; i < 400 && !c->syncComplete(); ++i) + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + return c; + }; + + // sync persists: completing a sync saves the freshly-scanned wallet. + { + dragonx::test::g_liteFakeSyncBlock = false; + dragonx::test::g_liteFakeSaveCount = 0; + auto c = openSynced(); + EXPECT_TRUE(c->syncComplete()); + EXPECT_TRUE(dragonx::test::g_liteFakeSaveCount.load() >= 1); + } + + // send persists the new transaction (backend does not auto-save sends) + { + dragonx::test::g_liteFakeSendFails = false; + auto c = openSynced(); + const long before = dragonx::test::g_liteFakeSaveCount.load(); + LiteSendRequest req; + req.recipients.push_back({"zs1dest", 1ULL, ""}); + EXPECT_TRUE(c->sendTransactionBlocking(req).ok); + EXPECT_EQ(dragonx::test::g_liteFakeSaveCount.load(), before + 1); + } + + // a FAILED send does NOT save + { + dragonx::test::g_liteFakeSendFails = true; + auto c = openSynced(); + const long before = dragonx::test::g_liteFakeSaveCount.load(); + LiteSendRequest req; + req.recipients.push_back({"zs1dest", 1ULL, ""}); + EXPECT_FALSE(c->sendTransactionBlocking(req).ok); + EXPECT_EQ(dragonx::test::g_liteFakeSaveCount.load(), before); + dragonx::test::g_liteFakeSendFails = false; + } + + // shield persists + { + auto c = openSynced(); + const long before = dragonx::test::g_liteFakeSaveCount.load(); + EXPECT_TRUE(c->shieldFundsBlocking().ok); + EXPECT_EQ(dragonx::test::g_liteFakeSaveCount.load(), before + 1); + } + + // explicit saveWallet() persists; with no wallet open it is a no-op + { + auto c = openSynced(); + const long before = dragonx::test::g_liteFakeSaveCount.load(); + EXPECT_TRUE(c->saveWallet()); + EXPECT_EQ(dragonx::test::g_liteFakeSaveCount.load(), before + 1); + } + { + LiteWalletController c(liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); + const long before = dragonx::test::g_liteFakeSaveCount.load(); + EXPECT_FALSE(c.saveWallet()); + EXPECT_EQ(dragonx::test::g_liteFakeSaveCount.load(), before); + } +} + // Migration: a saved lite chain_name outside {main,test,regtest} (e.g. the legacy // "DRAGONX" ticker) is rewritten to "main" on load, since the backend hard-panics // on unknown chains. Valid values are preserved. @@ -5010,6 +5091,7 @@ int main() testLiteBackendInjectableFakeBridge(); testLiteWalletControllerLifecycle(); testLiteWalletControllerM4(); + testLiteWalletControllerM5Persistence(); testLiteChainNameMigration(); testLiteRefreshModelAppliesToWalletState(); testLitePerAddressBalances();