feat(lite): M4 — send/shield/import/export/seed via controller + bridge

Add the spend & backup surface to LiteWalletController, with the real SDXL
backend contracts verified against the Rust source:

- send / shield: ASYNC (detached broadcast thread + takeBroadcastResult() slot,
  mirroring the sync thread's shared-lifetime pattern, since sapling proving can
  take seconds), plus synchronous *Blocking cores for tests. send uses the
  JSON-array form ([{address,amount,memo}]) because litelib_execute passes the
  whole args string as ONE argument (no whitespace split) — the space-separated
  CLI form would never parse. send/shield report failure via {"error":..} in the
  body (NOT an "Error:" prefix), so the result is derived from the parsed JSON.
- importKey: auto-detects transparent WIF (U/5/K/L -> timport) vs shielded key
  (-> import); takes the key by value and securely wipes it before returning.
- exportPrivateKeys / exportSeed: synchronous local reads returning SECRET
  material (flagged: no logging; caller wipes after the user saves the backup).
- broadcast thread is detached in the dtor (captures shared bridge + flag + slot,
  never `this`), so it is safe to outlive the controller.

Tests: testLiteWalletControllerM4 drives send (success / no-recipients /
{"error":..} / async-slot delivery / pre-open rejection), shield, export, seed,
and import (shielded + WIF + pre-open). Fake backend returns the real command
shapes + a g_liteFakeSendFails error toggle.

GUI wiring (send_tab button, backup/import UI) is deferred like the M3 UI hop
(GUI-unverifiable here). Plan doc updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 12:06:19 -05:00
parent 4b9d6f7db5
commit 6a4e98b7ed
5 changed files with 480 additions and 0 deletions

View File

@@ -27,12 +27,14 @@
#include <atomic>
#include <condition_variable>
#include <cstdint>
#include <functional>
#include <memory>
#include <mutex>
#include <optional>
#include <string>
#include <thread>
#include <vector>
namespace dragonx {
struct WalletState; // data/wallet_state.h
@@ -59,6 +61,51 @@ struct LiteNewAddressResult {
std::string error;
};
// A single send recipient. amountZatoshis is in zatoshis (1e-8 DRGX); memo is ignored by the
// backend for transparent destination addresses.
struct LiteSendRecipient {
std::string address;
std::uint64_t amountZatoshis = 0;
std::string memo;
};
struct LiteSendRequest {
std::vector<LiteSendRecipient> recipients;
};
// send and shield both broadcast a transaction and yield its txid (or an error). NOTE: the
// backend reports failure via {"error":..} in the response body, NOT the bridge's "Error:"
// prefix, so the result is derived by inspecting the parsed JSON.
struct LiteBroadcastResult {
bool ok = false;
std::string txid;
std::string error;
};
// importKey outcome. `detail` is the backend's (non-secret) confirmation payload.
struct LiteImportResult {
bool ok = false;
std::string detail;
std::string error;
};
// SECRET RESULT: holds exported per-address private keys ({address, private_key, viewing_key}).
// Treat privateKeysJson as sensitive: never log it; wipe it after the user has saved the backup.
struct LiteExportResult {
bool ok = false;
std::string privateKeysJson;
std::string error;
};
// SECRET RESULT: holds the wallet seed phrase. Treat seedPhrase as sensitive: never log it;
// wipe it after the user has saved the backup.
struct LiteSeedResult {
bool ok = false;
std::string seedPhrase;
std::uint64_t birthday = 0;
std::string error;
};
class LiteWalletController {
public:
LiteWalletController(WalletCapabilities capabilities,
@@ -102,6 +149,30 @@ public:
// key derivation), safe to call on the UI thread; the next refresh lists the new address.
LiteNewAddressResult newAddress(bool shielded);
// --- M4: spend & backup ---------------------------------------------------------------
// Build & broadcast a transaction (send) or shield all transparent funds. Proving can take
// seconds, so the public entry points are ASYNCHRONOUS: they run the blocking core on a
// detached thread and deliver the result to a main-thread slot drained by
// takeBroadcastResult(). Each returns false if a broadcast is already in flight or no wallet
// is open. Only one broadcast runs at a time. After a successful broadcast the periodic
// refresh surfaces the new (unconfirmed) transaction and tracks its confirmations.
bool sendTransaction(const LiteSendRequest& request);
bool shieldFunds(const std::string& optionalAddress = {});
bool broadcastInProgress() const { return broadcastRunning_ && broadcastRunning_->load(); }
bool takeBroadcastResult(LiteBroadcastResult& out);
// Synchronous cores for send/shield (block on the backend; safe off the UI thread). Used by
// the async entry points above and directly by tests.
LiteBroadcastResult sendTransactionBlocking(const LiteSendRequest& request);
LiteBroadcastResult shieldFundsBlocking(const std::string& optionalAddress = {});
// Fast, local secret operations (synchronous). importKey takes the key BY VALUE and securely
// wipes it before returning (transparent WIF vs shielded key is auto-detected). exportPrivate
// Keys / exportSeed return SECRET material the caller must treat as sensitive and wipe.
LiteImportResult importKey(std::string spendingOrViewingKey);
LiteExportResult exportPrivateKeys(const std::string& optionalAddress = {});
LiteSeedResult exportSeed();
// 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.
@@ -121,6 +192,9 @@ private:
void startWorker();
void stopWorker();
void workerLoop();
// Launch `op` (a self-contained broadcast that must NOT capture `this`) on the detached
// broadcast thread. Returns false if a broadcast is already running.
bool startBroadcast(std::function<LiteBroadcastResult()> op);
// The bridge is shared (not just owned) so the detached, uninterruptible sync thread can
// safely outlive the controller: it holds a ref, so the underlying bridge is destroyed
@@ -147,6 +221,16 @@ private:
std::atomic<bool> syncLaunched_{false}; // startSync() guard (set on the main thread)
std::shared_ptr<std::atomic<bool>> syncDone_ = std::make_shared<std::atomic<bool>>(false);
// Detached background broadcast (send/shield): proving can take seconds. Mirrors the sync
// thread's shared-lifetime pattern (shared running flag + result slot captured by the
// thread, never `this`) so the thread can safely outlive the controller.
std::thread broadcastThread_;
std::shared_ptr<std::atomic<bool>> broadcastRunning_ =
std::make_shared<std::atomic<bool>>(false);
std::shared_ptr<std::mutex> broadcastResultMutex_ = std::make_shared<std::mutex>();
std::shared_ptr<std::optional<LiteBroadcastResult>> broadcastResult_ =
std::make_shared<std::optional<LiteBroadcastResult>>();
// Joinable background refresh worker (fast iterations: syncstatus, plus data once synced).
std::thread worker_;
std::atomic<bool> running_{false};