From 5c883d4b9142aaf74cda4234fc955ce93464a7a2 Mon Sep 17 00:00:00 2001 From: DanS Date: Sun, 7 Jun 2026 14:18:05 -0500 Subject: [PATCH] fix(lite): faithful tx list, balances, and persistence on partial results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A Send record carries its recipient in outgoing_metadata, not the top-level address/memo, so sent txs showed a blank destination + memo. Surface the first recipient (single-recipient case) into the transaction list. - A tolerated partial refresh where the notes/utxo command failed (addresses present, spendable outputs absent) zeroed every per-address balance, which looks like fund loss. Preserve the last-known per-address balances in that case. - Retry the post-send/shield save once on transient failure instead of ignoring the result (the backend does not auto-save after send/shield). - An unparseable broadcast response now uses cautious wording ("status could not be confirmed — check Transactions before retrying") rather than implying a hard failure, avoiding a blind double-spend retry. Co-Authored-By: Claude Opus 4.8 --- src/wallet/lite_wallet_controller.cpp | 52 ++++++++++++++++++++++----- 1 file changed, 43 insertions(+), 9 deletions(-) diff --git a/src/wallet/lite_wallet_controller.cpp b/src/wallet/lite_wallet_controller.cpp index 2fa1427..1672c72 100644 --- a/src/wallet/lite_wallet_controller.cpp +++ b/src/wallet/lite_wallet_controller.cpp @@ -51,12 +51,27 @@ LiteBroadcastResult parseBroadcastResponse(const LiteBridgeStringResult& bridgeC } } } catch (...) { - // Non-JSON (e.g. the command's plain-text help on bad args) -> generic error below. + // Non-JSON (e.g. the command's plain-text help on bad args) -> ambiguous error below. } - out.error = "could not parse transaction response"; + // The bridge call itself succeeded but the response is unrecognizable, so we genuinely + // can't tell whether the tx was broadcast. Use cautious wording (don't claim a hard + // failure) so the user verifies in Transactions before retrying — avoiding a double-spend. + out.error = "Transaction status could not be confirmed — check Transactions before retrying"; return out; } +// The backend does not auto-save after send/shield. Persist the new transaction now so it +// survives a restart; retry once on a transient failure (disk lock, etc.). +bool persistAfterBroadcast(LiteClientBridge& bridge) +{ + for (int attempt = 0; attempt < 2; ++attempt) { + if (bridge.execute("save", "").ok) return true; + } + // Persistent failure: the spent note will be re-derived from the chain on the next sync, + // so this is a robustness gap, not fund loss. (Retry handles the common transient case.) + return false; +} + // 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. @@ -80,9 +95,7 @@ LiteBroadcastResult doSend(LiteClientBridge& bridge, const LiteSendRequest& requ arr.push_back(std::move(o)); } auto result = parseBroadcastResponse(bridge.execute("send", arr.dump())); - // The backend does NOT auto-save after a send, so persist the new transaction now (so it - // survives a restart). Best-effort: a save failure doesn't undo a broadcast that succeeded. - if (result.ok) bridge.execute("save", ""); + if (result.ok) persistAfterBroadcast(bridge); return result; } @@ -90,7 +103,7 @@ LiteBroadcastResult doShield(LiteClientBridge& bridge, const std::string& option { // Empty address -> shield all transparent funds; otherwise shield to the given address. auto result = parseBroadcastResponse(bridge.execute("shield", optionalAddress)); - if (result.ok) bridge.execute("save", ""); // shield does not auto-save either + if (result.ok) persistAfterBroadcast(bridge); // shield does not auto-save either return result; } } // namespace @@ -126,6 +139,15 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model, } } + // If the notes/utxo command failed this cycle (a tolerated partial refresh), we don't + // actually know per-address balances. Preserve the previously displayed ones instead + // of zeroing every address — a zeroed breakdown next to a correct nonzero total looks + // like fund loss and breaks "send from this address". + std::unordered_map priorBalances; + if (!model.hasSpendableOutputs) { + for (const auto& a : state.addresses) priorBalances[a.address] = a.balance; + } + state.addresses.clear(); state.z_addresses.clear(); state.t_addresses.clear(); @@ -133,9 +155,14 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model, AddressInfo info; info.address = addr.address; const auto it = perAddressZatoshis.find(addr.address); - info.balance = it != perAddressZatoshis.end() - ? static_cast(it->second) / kZatoshisPerCoin - : 0.0; + if (it != perAddressZatoshis.end()) { + info.balance = static_cast(it->second) / kZatoshisPerCoin; + } else if (!model.hasSpendableOutputs) { + const auto pit = priorBalances.find(addr.address); // keep last-known on notes failure + info.balance = pit != priorBalances.end() ? pit->second : 0.0; + } else { + info.balance = 0.0; // notes succeeded and address has no spendable outputs + } info.type = (addr.kind == LiteWalletAppAddressKind::Shielded) ? "shielded" : "transparent"; info.has_spending_key = addr.spendabilityKnown ? addr.spendable : true; if (addr.kind == LiteWalletAppAddressKind::Shielded) { @@ -165,6 +192,13 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model, tx.timestamp = record.timestamp; tx.address = record.address; tx.memo = record.memo; + // For a Send the recipient address/memo live in outgoingOutputs — the top-level + // address/memo are only filled for Receives. Surface the first recipient so the + // list shows the destination + memo instead of blanks (single-recipient case). + if (tx.type == "send" && tx.address.empty() && !record.outgoingOutputs.empty()) { + tx.address = record.outgoingOutputs.front().address; + if (tx.memo.empty()) tx.memo = record.outgoingOutputs.front().memo; + } if (record.unconfirmed || !record.blockHeight.has_value() || chainHeight == 0) { tx.confirmations = record.unconfirmed ? 0 : 1; } else {