feat(lite): M5a — wallet persistence after sync/send/shield
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 <noreply@anthropic.com>
This commit is contained in:
@@ -147,6 +147,15 @@ Each milestone is independently demoable and gated by a fake-backend test. Order
|
|||||||
- Runtime kill-switch / feature flag / staged rollout.
|
- Runtime kill-switch / feature flag / staged rollout.
|
||||||
- **Exit demo / test:** A downloadable `ObsidianDragonLite` that creates, syncs, sends, and persists against a real backend.
|
- **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
|
## 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.
|
- The "promote one disabled scaffold at a time" methodology and all `promotion → activation → post-closure → custody → handoff → stewardship → receipt` governance layers.
|
||||||
|
|||||||
@@ -79,13 +79,19 @@ LiteBroadcastResult doSend(LiteClientBridge& bridge, const LiteSendRequest& requ
|
|||||||
if (!r.memo.empty()) o["memo"] = r.memo;
|
if (!r.memo.empty()) o["memo"] = r.memo;
|
||||||
arr.push_back(std::move(o));
|
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)
|
LiteBroadcastResult doShield(LiteClientBridge& bridge, const std::string& optionalAddress)
|
||||||
{
|
{
|
||||||
// Empty address -> shield all transparent funds; otherwise shield to the given address.
|
// 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
|
} // namespace
|
||||||
|
|
||||||
@@ -195,6 +201,13 @@ LiteWalletController::LiteWalletController(WalletCapabilities capabilities,
|
|||||||
LiteWalletController::~LiteWalletController()
|
LiteWalletController::~LiteWalletController()
|
||||||
{
|
{
|
||||||
stopWorker(); // joins the fast poll worker (short iterations)
|
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
|
// 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
|
// 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.
|
// finishes — the process is exiting, so a late litelib_shutdown is harmless.
|
||||||
@@ -235,7 +248,13 @@ void LiteWalletController::startSync()
|
|||||||
auto bridge = bridge_;
|
auto bridge = bridge_;
|
||||||
auto done = syncDone_;
|
auto done = syncDone_;
|
||||||
syncThread_ = std::thread([bridge, done] {
|
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);
|
done->store(true);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -477,6 +496,12 @@ LiteSeedResult LiteWalletController::exportSeed()
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool LiteWalletController::saveWallet()
|
||||||
|
{
|
||||||
|
if (!walletOpen_.load() || !bridge_) return false;
|
||||||
|
return bridge_->execute("save", "").ok;
|
||||||
|
}
|
||||||
|
|
||||||
bool LiteWalletController::refreshWalletState(dragonx::WalletState& state)
|
bool LiteWalletController::refreshWalletState(dragonx::WalletState& state)
|
||||||
{
|
{
|
||||||
auto model = refreshModel();
|
auto model = refreshModel();
|
||||||
|
|||||||
@@ -173,6 +173,12 @@ public:
|
|||||||
LiteExportResult exportPrivateKeys(const std::string& optionalAddress = {});
|
LiteExportResult exportPrivateKeys(const std::string& optionalAddress = {});
|
||||||
LiteSeedResult exportSeed();
|
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
|
// 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.
|
// 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.
|
// Synchronous (blocks on the backend); used by tests and as the worker's unit of work.
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ inline bool g_liteFakeShutdownCalled = false;
|
|||||||
inline std::atomic<bool> g_liteFakeSyncBlock{false}; // when true, the "sync" command blocks
|
inline std::atomic<bool> g_liteFakeSyncBlock{false}; // when true, the "sync" command blocks
|
||||||
inline std::atomic<bool> g_liteFakeBadBalance{false}; // when true, "balance" returns invalid JSON
|
inline std::atomic<bool> g_liteFakeBadBalance{false}; // when true, "balance" returns invalid JSON
|
||||||
inline std::atomic<bool> g_liteFakeSendFails{false}; // when true, "send"/"shield" return {"error":..}
|
inline std::atomic<bool> g_liteFakeSendFails{false}; // when true, "send"/"shield" return {"error":..}
|
||||||
|
inline std::atomic<long> g_liteFakeSaveCount{0}; // counts "save" commands (persistence checks)
|
||||||
|
|
||||||
inline void resetLiteFakeCounters()
|
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.
|
// import (shielded key) / timport (transparent WIF): do_import_* pretty-print JSON.
|
||||||
if (std::strcmp(c, "import") == 0 || std::strcmp(c, "timport") == 0)
|
if (std::strcmp(c, "import") == 0 || std::strcmp(c, "timport") == 0)
|
||||||
return liteFakeDup("{\"result\":\"success\",\"address\":\"zs1imported\"}");
|
return liteFakeDup("{\"result\":\"success\",\"address\":\"zs1imported\"}");
|
||||||
|
if (std::strcmp(c, "save") == 0) {
|
||||||
|
++g_liteFakeSaveCount;
|
||||||
|
return liteFakeDup("{\"result\":\"success\"}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Default for any other/unknown command.
|
// Default for any other/unknown command.
|
||||||
return liteFakeDup("{\"version\":\"sdxl-fake\"}");
|
return liteFakeDup("{\"version\":\"sdxl-fake\"}");
|
||||||
|
|||||||
@@ -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<LiteWalletController>(
|
||||||
|
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
|
// 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
|
// "DRAGONX" ticker) is rewritten to "main" on load, since the backend hard-panics
|
||||||
// on unknown chains. Valid values are preserved.
|
// on unknown chains. Valid values are preserved.
|
||||||
@@ -5010,6 +5091,7 @@ int main()
|
|||||||
testLiteBackendInjectableFakeBridge();
|
testLiteBackendInjectableFakeBridge();
|
||||||
testLiteWalletControllerLifecycle();
|
testLiteWalletControllerLifecycle();
|
||||||
testLiteWalletControllerM4();
|
testLiteWalletControllerM4();
|
||||||
|
testLiteWalletControllerM5Persistence();
|
||||||
testLiteChainNameMigration();
|
testLiteChainNameMigration();
|
||||||
testLiteRefreshModelAppliesToWalletState();
|
testLiteRefreshModelAppliesToWalletState();
|
||||||
testLitePerAddressBalances();
|
testLitePerAddressBalances();
|
||||||
|
|||||||
Reference in New Issue
Block a user