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

@@ -132,6 +132,13 @@ Each milestone is independently demoable and gated by a fake-backend test. Order
- Import (keys), export (wallet backup), shield (t→z). - Import (keys), export (wallet backup), shield (t→z).
- **Exit demo / test:** Send a transaction and watch it confirm; export a backup. Fake-backend test drives a send and asserts the result/tx-status flow. - **Exit demo / test:** Send a transaction and watch it confirm; export a backup. Fake-backend test drives a send and asserts the result/tx-status flow.
**Status (2026-06-05): controller/bridge layer DONE; GUI wiring pending.**
`LiteWalletController` now exposes the full M4 surface, with the real backend contracts verified against the SDXL Rust source:
- `sendTransaction` / `shieldFunds` — ASYNC (detached thread + `takeBroadcastResult()`; proving can take seconds), plus `*Blocking` cores. Send uses the **JSON-array form** (`[{address,amount,memo}]`) because `litelib_execute` passes the whole arg string as ONE argument (no whitespace split). Failures arrive as `{"error":..}` in the body (NOT an `"Error:"` prefix), so results are derived from the parsed JSON.
- `importKey` (auto-detects transparent WIF `U/5/K/L``timport` vs shielded → `import`; wipes the key by value), `exportPrivateKeys`, `exportSeed` — synchronous local ops; export/seed return SECRET material flagged for no-log + wipe.
- 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.
- **Pending:** GUI wiring (`send_tab` send button → `sendTransaction` + result/confirmation surfacing; Settings/backup UI → export/seed/import; shield button). GUI-unverifiable here, so deferred like the M3 UI hop.
### M5 — Persistence, recovery, packaging, production enablement ### M5 — Persistence, recovery, packaging, production enablement
**Goal:** Shippable. **Goal:** Shippable.
- Wallet-file durability + crash/recovery + error/retry UX. - Wallet-file durability + crash/recovery + error/retry UX.

View File

@@ -19,8 +19,76 @@ namespace wallet {
namespace { namespace {
constexpr double kZatoshisPerCoin = 100000000.0; // DRGX has 1e8 zatoshis per coin constexpr double kZatoshisPerCoin = 100000000.0; // DRGX has 1e8 zatoshis per coin
// Extract a backend {"error":..} message (string or arbitrary JSON) into a plain string.
std::string extractJsonError(const nlohmann::json& value)
{
const auto& e = value.at("error");
return e.is_string() ? e.get<std::string>() : e.dump();
} }
// Parse a send/shield response: success is {"txid":".."}, failure is {"error":".."} (NOT an
// "Error:"-prefixed string), and malformed args make the backend return plain-text help.
LiteBroadcastResult parseBroadcastResponse(const LiteBridgeStringResult& bridgeCall)
{
LiteBroadcastResult out;
if (!bridgeCall.ok) {
out.error = bridgeCall.error.empty() ? "backend call failed" : bridgeCall.error;
return out;
}
try {
const auto j = nlohmann::json::parse(bridgeCall.value);
if (j.is_object()) {
if (j.contains("error")) {
out.error = extractJsonError(j);
return out;
}
if (j.contains("txid") && j.at("txid").is_string()) {
out.txid = j.at("txid").get<std::string>();
out.ok = !out.txid.empty();
if (!out.ok) out.error = "backend returned an empty txid";
return out;
}
}
} catch (...) {
// Non-JSON (e.g. the command's plain-text help on bad args) -> generic error below.
}
out.error = "could not parse transaction response";
return out;
}
// Build the JSON-array send payload and broadcast it. litelib_execute passes the whole args
// string as ONE argument (no whitespace splitting), so send MUST use the JSON-array form
// ([{address,amount,memo},..]); the space-separated CLI form would never parse.
LiteBroadcastResult doSend(LiteClientBridge& bridge, const LiteSendRequest& request)
{
LiteBroadcastResult out;
if (request.recipients.empty()) {
out.error = "no recipients";
return out;
}
nlohmann::json arr = nlohmann::json::array();
for (const auto& r : request.recipients) {
if (r.address.empty()) {
out.error = "recipient address is empty";
return out;
}
nlohmann::json o;
o["address"] = r.address;
o["amount"] = r.amountZatoshis; // zatoshis (puposhis)
if (!r.memo.empty()) o["memo"] = r.memo;
arr.push_back(std::move(o));
}
return parseBroadcastResponse(bridge.execute("send", arr.dump()));
}
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));
}
} // namespace
void secureWipeLiteSecret(std::string& secret) void secureWipeLiteSecret(std::string& secret)
{ {
if (!secret.empty()) { if (!secret.empty()) {
@@ -131,6 +199,9 @@ LiteWalletController::~LiteWalletController()
// 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.
if (syncThread_.joinable()) syncThread_.detach(); if (syncThread_.joinable()) syncThread_.detach();
// Likewise the broadcast thread (send/shield proving): it captures shared refs (bridge +
// running flag + result slot), never `this`, so detaching is safe.
if (broadcastThread_.joinable()) broadcastThread_.detach();
} }
std::unique_ptr<LiteWalletController> LiteWalletController::createLinked( std::unique_ptr<LiteWalletController> LiteWalletController::createLinked(
@@ -237,6 +308,175 @@ LiteNewAddressResult LiteWalletController::newAddress(bool shielded)
return out; return out;
} }
LiteBroadcastResult LiteWalletController::sendTransactionBlocking(const LiteSendRequest& request)
{
LiteBroadcastResult out;
if (!walletOpen_.load() || !bridge_) {
out.error = "no wallet is open";
return out;
}
return doSend(*bridge_, request);
}
LiteBroadcastResult LiteWalletController::shieldFundsBlocking(const std::string& optionalAddress)
{
LiteBroadcastResult out;
if (!walletOpen_.load() || !bridge_) {
out.error = "no wallet is open";
return out;
}
return doShield(*bridge_, optionalAddress);
}
bool LiteWalletController::startBroadcast(std::function<LiteBroadcastResult()> op)
{
bool expected = false;
if (!broadcastRunning_->compare_exchange_strong(expected, true)) return false; // one at a time
{
std::lock_guard<std::mutex> lock(*broadcastResultMutex_);
broadcastResult_->reset(); // drop any already-consumed prior result
}
// A previous broadcast thread (already finished) may still be joinable; detach before reuse.
if (broadcastThread_.joinable()) broadcastThread_.detach();
auto running = broadcastRunning_;
auto mutex = broadcastResultMutex_;
auto slot = broadcastResult_;
broadcastThread_ = std::thread([op = std::move(op), running, mutex, slot] {
LiteBroadcastResult result = op();
{
std::lock_guard<std::mutex> lock(*mutex);
*slot = std::move(result);
}
running->store(false);
});
return true;
}
bool LiteWalletController::sendTransaction(const LiteSendRequest& request)
{
if (!walletOpen_.load() || !bridge_) return false;
auto bridge = bridge_; // shared copy; the op must not capture `this`
return startBroadcast([bridge, request]() { return doSend(*bridge, request); });
}
bool LiteWalletController::shieldFunds(const std::string& optionalAddress)
{
if (!walletOpen_.load() || !bridge_) return false;
auto bridge = bridge_;
return startBroadcast([bridge, optionalAddress]() { return doShield(*bridge, optionalAddress); });
}
bool LiteWalletController::takeBroadcastResult(LiteBroadcastResult& out)
{
std::lock_guard<std::mutex> lock(*broadcastResultMutex_);
if (!broadcastResult_->has_value()) return false;
out = std::move(**broadcastResult_);
broadcastResult_->reset();
return true;
}
LiteImportResult LiteWalletController::importKey(std::string spendingOrViewingKey)
{
LiteImportResult out;
if (!walletOpen_.load() || !bridge_) {
secureWipeLiteSecret(spendingOrViewingKey);
out.error = "no wallet is open";
return out;
}
if (spendingOrViewingKey.empty()) {
out.error = "no key provided";
return out;
}
// Transparent WIFs begin with U/5/K/L (TImportCommand); shielded keys begin with
// "secret-..." / viewing keys "zxview...", so this prefix check won't collide.
const char first = spendingOrViewingKey[0];
const bool transparentWif = (first == 'U' || first == '5' || first == 'K' || first == 'L');
const auto result = bridge_->execute(transparentWif ? "timport" : "import", spendingOrViewingKey);
secureWipeLiteSecret(spendingOrViewingKey); // wipe our copy ASAP
if (!result.ok) { // do_import_* failures come back "Error:"-prefixed (bridge -> ok=false)
out.error = result.error.empty() ? "key import failed" : result.error;
return out;
}
try {
const auto j = nlohmann::json::parse(result.value);
if (j.is_object() && j.contains("error")) {
out.error = extractJsonError(j);
return out;
}
} catch (...) {
// A non-JSON success payload is acceptable; fall through.
}
out.detail = result.value;
out.ok = true;
return out;
}
LiteExportResult LiteWalletController::exportPrivateKeys(const std::string& optionalAddress)
{
LiteExportResult out;
if (!walletOpen_.load() || !bridge_) {
out.error = "no wallet is open";
return out;
}
// Empty address -> export keys for all addresses; otherwise just the given address.
const auto result = bridge_->execute("export", optionalAddress);
if (!result.ok) {
out.error = result.error.empty() ? "export failed" : result.error;
return out;
}
try {
const auto j = nlohmann::json::parse(result.value);
if (j.is_object() && j.contains("error")) {
out.error = extractJsonError(j);
return out;
}
} catch (...) {
out.error = "could not parse export response";
return out;
}
out.privateKeysJson = result.value; // SECRET — caller must not log; wipe after use
out.ok = true;
return out;
}
LiteSeedResult LiteWalletController::exportSeed()
{
LiteSeedResult out;
if (!walletOpen_.load() || !bridge_) {
out.error = "no wallet is open";
return out;
}
const auto result = bridge_->execute("seed", "");
if (!result.ok) {
out.error = result.error.empty() ? "seed export failed" : result.error;
return out;
}
try {
const auto j = nlohmann::json::parse(result.value);
if (j.is_object()) {
if (j.contains("error")) {
out.error = extractJsonError(j);
return out;
}
if (j.contains("seed") && j.at("seed").is_string()) {
out.seedPhrase = j.at("seed").get<std::string>(); // SECRET
if (j.contains("birthday") && j.at("birthday").is_number_unsigned()) {
out.birthday = j.at("birthday").get<std::uint64_t>();
}
out.ok = !out.seedPhrase.empty();
if (!out.ok) out.error = "backend returned an empty seed";
return out;
}
}
} catch (...) {
// fall through to the generic parse error
}
out.error = "could not parse seed response";
return out;
}
bool LiteWalletController::refreshWalletState(dragonx::WalletState& state) bool LiteWalletController::refreshWalletState(dragonx::WalletState& state)
{ {
auto model = refreshModel(); auto model = refreshModel();

View File

@@ -27,12 +27,14 @@
#include <atomic> #include <atomic>
#include <condition_variable> #include <condition_variable>
#include <cstdint>
#include <functional> #include <functional>
#include <memory> #include <memory>
#include <mutex> #include <mutex>
#include <optional> #include <optional>
#include <string> #include <string>
#include <thread> #include <thread>
#include <vector>
namespace dragonx { namespace dragonx {
struct WalletState; // data/wallet_state.h struct WalletState; // data/wallet_state.h
@@ -59,6 +61,51 @@ struct LiteNewAddressResult {
std::string error; 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 { class LiteWalletController {
public: public:
LiteWalletController(WalletCapabilities capabilities, LiteWalletController(WalletCapabilities capabilities,
@@ -102,6 +149,30 @@ public:
// key derivation), safe to call on the UI thread; the next refresh lists the new address. // key derivation), safe to call on the UI thread; the next refresh lists the new address.
LiteNewAddressResult newAddress(bool shielded); 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 // 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.
@@ -121,6 +192,9 @@ private:
void startWorker(); void startWorker();
void stopWorker(); void stopWorker();
void workerLoop(); 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 // 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 // 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::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); 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). // Joinable background refresh worker (fast iterations: syncstatus, plus data once synced).
std::thread worker_; std::thread worker_;
std::atomic<bool> running_{false}; std::atomic<bool> running_{false};

View File

@@ -34,6 +34,7 @@ inline bool g_liteFakeServerOnline = true;
inline bool g_liteFakeShutdownCalled = false; 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 void resetLiteFakeCounters() inline void resetLiteFakeCounters()
{ {
@@ -102,6 +103,24 @@ inline char* liteFakeExecute(const char* command, const char* args)
if (std::strcmp(c, "height") == 0) return liteFakeDup("{\"height\":1000}"); if (std::strcmp(c, "height") == 0) return liteFakeDup("{\"height\":1000}");
if (std::strcmp(c, "info") == 0) if (std::strcmp(c, "info") == 0)
return liteFakeDup("{\"chain_name\":\"main\",\"version\":\"sdxl-fake\",\"latest_block_height\":1000}"); return liteFakeDup("{\"chain_name\":\"main\",\"version\":\"sdxl-fake\",\"latest_block_height\":1000}");
// M4 spend/backup commands. send/shield report failure via {"error":..} (NOT an
// "Error:" prefix), matching the real backend's object!{ "error" => e } shape.
if (std::strcmp(c, "send") == 0) {
if (g_liteFakeSendFails.load()) return liteFakeDup("{\n \"error\": \"insufficient funds\"\n}");
return liteFakeDup("{\n \"txid\": \"faketxid123\"\n}");
}
if (std::strcmp(c, "shield") == 0) {
if (g_liteFakeSendFails.load()) return liteFakeDup("{\n \"error\": \"nothing to shield\"\n}");
return liteFakeDup("{\n \"txid\": \"fakeshieldtxid\"\n}");
}
if (std::strcmp(c, "export") == 0)
return liteFakeDup("[{\"address\":\"zs1fakeaddr\",\"private_key\":\"SECRET-ZKEY\","
"\"viewing_key\":\"zxviewsFAKE\"}]");
if (std::strcmp(c, "seed") == 0)
return liteFakeDup("{\"seed\":\"fake seed phrase words\",\"birthday\":0}");
// 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\"}");
} }
// Default for any other/unknown command. // Default for any other/unknown command.
return liteFakeDup("{\"version\":\"sdxl-fake\"}"); return liteFakeDup("{\"version\":\"sdxl-fake\"}");

View File

@@ -4548,6 +4548,135 @@ void testLiteWalletControllerLifecycle()
} }
} }
// M4: spend & backup. The controller drives send/shield/import/export/seed through the
// (injected fake) bridge with the real backend's arg/response contracts: send uses the
// JSON-array form (litelib_execute takes one arg), failures arrive as {"error":..} in the
// body, and the async send/shield path delivers a txid via takeBroadcastResult().
void testLiteWalletControllerM4()
{
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;
};
// send (blocking core): JSON-array payload -> {"txid":..}
{
dragonx::test::g_liteFakeSendFails = false;
auto c = openController();
LiteSendRequest req;
req.recipients.push_back({"zs1dest", 150000000ULL, "hello memo with spaces"});
const auto r = c->sendTransactionBlocking(req);
EXPECT_TRUE(r.ok);
EXPECT_EQ(r.txid, std::string("faketxid123"));
EXPECT_TRUE(r.error.empty());
}
// send with no recipients -> error, backend never reached
{
auto c = openController();
const auto r = c->sendTransactionBlocking(LiteSendRequest{});
EXPECT_FALSE(r.ok);
EXPECT_FALSE(r.error.empty());
}
// send failure surfaces the backend's {"error":..} body (not an "Error:" prefix)
{
dragonx::test::g_liteFakeSendFails = true;
auto c = openController();
LiteSendRequest req;
req.recipients.push_back({"zs1dest", 1ULL, ""});
const auto r = c->sendTransactionBlocking(req);
EXPECT_FALSE(r.ok);
EXPECT_EQ(r.error, std::string("insufficient funds"));
dragonx::test::g_liteFakeSendFails = false;
}
// shield (blocking core) -> txid
{
dragonx::test::g_liteFakeSendFails = false;
auto c = openController();
const auto r = c->shieldFundsBlocking();
EXPECT_TRUE(r.ok);
EXPECT_EQ(r.txid, std::string("fakeshieldtxid"));
}
// async send: delivers the result to the main-thread slot
{
dragonx::test::g_liteFakeSendFails = false;
auto c = openController();
LiteSendRequest req;
req.recipients.push_back({"zs1dest", 42ULL, ""});
EXPECT_TRUE(c->sendTransaction(req));
LiteBroadcastResult r;
bool got = false;
for (int i = 0; i < 400 && !got; ++i) { // bounded wait (<= ~2s)
got = c->takeBroadcastResult(r);
if (!got) std::this_thread::sleep_for(std::chrono::milliseconds(5));
}
EXPECT_TRUE(got);
EXPECT_TRUE(r.ok);
EXPECT_EQ(r.txid, std::string("faketxid123"));
}
// send before a wallet is open -> rejected
{
LiteWalletController c(liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
LiteSendRequest req;
req.recipients.push_back({"zs1dest", 1ULL, ""});
EXPECT_FALSE(c.sendTransaction(req));
const auto r = c.sendTransactionBlocking(req);
EXPECT_FALSE(r.ok);
}
// export private keys (SECRET) -> parsed, non-empty
{
auto c = openController();
const auto r = c->exportPrivateKeys();
EXPECT_TRUE(r.ok);
EXPECT_TRUE(r.privateKeysJson.find("private_key") != std::string::npos);
}
// export seed (SECRET) -> seed phrase + birthday
{
auto c = openController();
const auto r = c->exportSeed();
EXPECT_TRUE(r.ok);
EXPECT_EQ(r.seedPhrase, std::string("fake seed phrase words"));
EXPECT_EQ(r.birthday, 0ULL);
}
// import shielded key -> "import"; the key argument is wiped on return
{
auto c = openController();
std::string key = "secret-extended-key-main1fakeshieldedkey";
const auto r = c->importKey(std::move(key));
EXPECT_TRUE(r.ok);
EXPECT_TRUE(r.detail.find("zs1imported") != std::string::npos);
}
// import transparent WIF (begins with K) -> "timport"
{
auto c = openController();
const auto r = c->importKey("Kx1faketransparentwif");
EXPECT_TRUE(r.ok);
}
// import with no wallet open -> rejected (and key still wiped)
{
LiteWalletController c(liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
const auto r = c.importKey("secret-extended-key-main1abc");
EXPECT_FALSE(r.ok);
}
}
// 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.
@@ -4880,6 +5009,7 @@ int main()
testLiteClientBridgeUsesRuntimeOwnedStringCleanup(); testLiteClientBridgeUsesRuntimeOwnedStringCleanup();
testLiteBackendInjectableFakeBridge(); testLiteBackendInjectableFakeBridge();
testLiteWalletControllerLifecycle(); testLiteWalletControllerLifecycle();
testLiteWalletControllerM4();
testLiteChainNameMigration(); testLiteChainNameMigration();
testLiteRefreshModelAppliesToWalletState(); testLiteRefreshModelAppliesToWalletState();
testLitePerAddressBalances(); testLitePerAddressBalances();