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:
2026-06-05 12:48:44 -05:00
parent a677c09984
commit 5d317f6be3
5 changed files with 130 additions and 3 deletions

View File

@@ -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();

View File

@@ -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.