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