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

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