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 {