From 6a4e98b7ed95fca438033a74b9269d47a5449458 Mon Sep 17 00:00:00 2001 From: DanS Date: Fri, 5 Jun 2026 12:06:19 -0500 Subject: [PATCH] =?UTF-8?q?feat(lite):=20M4=20=E2=80=94=20send/shield/impo?= =?UTF-8?q?rt/export/seed=20via=20controller=20+=20bridge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...allet-implementation-plan-v2-2026-06-04.md | 7 + src/wallet/lite_wallet_controller.cpp | 240 ++++++++++++++++++ src/wallet/lite_wallet_controller.h | 84 ++++++ tests/fake_lite_backend.h | 19 ++ tests/test_phase4.cpp | 130 ++++++++++ 5 files changed, 480 insertions(+) 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 67f355a..5743c5c 100644 --- a/docs/lite-wallet-implementation-plan-v2-2026-06-04.md +++ b/docs/lite-wallet-implementation-plan-v2-2026-06-04.md @@ -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). - **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 **Goal:** Shippable. - Wallet-file durability + crash/recovery + error/retry UX. diff --git a/src/wallet/lite_wallet_controller.cpp b/src/wallet/lite_wallet_controller.cpp index 9148067..4cd6649 100644 --- a/src/wallet/lite_wallet_controller.cpp +++ b/src/wallet/lite_wallet_controller.cpp @@ -19,8 +19,76 @@ namespace wallet { namespace { 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() : 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(); + 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) { if (!secret.empty()) { @@ -131,6 +199,9 @@ LiteWalletController::~LiteWalletController() // 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. 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::createLinked( @@ -237,6 +308,175 @@ LiteNewAddressResult LiteWalletController::newAddress(bool shielded) 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 op) +{ + bool expected = false; + if (!broadcastRunning_->compare_exchange_strong(expected, true)) return false; // one at a time + { + std::lock_guard 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 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 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(); // SECRET + if (j.contains("birthday") && j.at("birthday").is_number_unsigned()) { + out.birthday = j.at("birthday").get(); + } + 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) { auto model = refreshModel(); diff --git a/src/wallet/lite_wallet_controller.h b/src/wallet/lite_wallet_controller.h index d77af15..3c6ac3d 100644 --- a/src/wallet/lite_wallet_controller.h +++ b/src/wallet/lite_wallet_controller.h @@ -27,12 +27,14 @@ #include #include +#include #include #include #include #include #include #include +#include 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 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 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 syncLaunched_{false}; // startSync() guard (set on the main thread) std::shared_ptr> syncDone_ = std::make_shared>(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> broadcastRunning_ = + std::make_shared>(false); + std::shared_ptr broadcastResultMutex_ = std::make_shared(); + std::shared_ptr> broadcastResult_ = + std::make_shared>(); + // Joinable background refresh worker (fast iterations: syncstatus, plus data once synced). std::thread worker_; std::atomic running_{false}; diff --git a/tests/fake_lite_backend.h b/tests/fake_lite_backend.h index 08cfc2a..6cfcb82 100644 --- a/tests/fake_lite_backend.h +++ b/tests/fake_lite_backend.h @@ -34,6 +34,7 @@ inline bool g_liteFakeServerOnline = true; 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 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, "info") == 0) 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. return liteFakeDup("{\"version\":\"sdxl-fake\"}"); diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 2d941d7..9dd46f1 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -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( + 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 // "DRAGONX" ticker) is rewritten to "main" on load, since the backend hard-panics // on unknown chains. Valid values are preserved. @@ -4880,6 +5009,7 @@ int main() testLiteClientBridgeUsesRuntimeOwnedStringCleanup(); testLiteBackendInjectableFakeBridge(); testLiteWalletControllerLifecycle(); + testLiteWalletControllerM4(); testLiteChainNameMigration(); testLiteRefreshModelAppliesToWalletState(); testLitePerAddressBalances();