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