fix(lite): faithful tx list, balances, and persistence on partial results

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 14:18:05 -05:00
parent 274f7ea1af
commit 5c883d4b91

View File

@@ -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<std::string, double> 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<double>(it->second) / kZatoshisPerCoin
: 0.0;
if (it != perAddressZatoshis.end()) {
info.balance = static_cast<double>(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 {