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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user