fix(send): treat a lost send reply as "outcome unknown", not a plain failure, to prevent a double-send

Resilience audit, highest-value finding: a z_sendmany (full node) or lite broadcast whose REPLY is
lost — a transport timeout / connection cut AFTER the request already left us — was reported as an
ordinary "Transaction failed". The user then retries and can broadcast the same payment twice, because
the daemon may already hold the operation (z_sendmany returns an opid and runs async).

Classify the ambiguous case and surface it distinctly, without touching the send itself:
- New pure helpers in rpc/rpc_failure.h: sendReplyMayBeLost() (an RpcTransportError that was requestSent
  with no HTTP status and a non-OK curl code — reply-timeout / got-nothing / our-own-abort; definitive
  rejections and any HTTP status are excluded), liteSendErrorMayBeInFlight() (a transport-style gRPC text
  heuristic, vetoing connect-phase and server-rejection text), and a readable-English marker tag.
- app_network.cpp: the z_sendmany catch records the flag; the failure branch (non-background only) tags
  the callback text and forces a wallet re-read so a committed send shows in History. onDisconnected()
  tags already-pending send callbacks the same way (they exist only after an opid was returned).
- Lite UserSend (app.cpp): a transport-style broadcast error is tagged identically.
- Send tab: a tagged outcome shows a non-success warning pointing at History and clears the compose form
  so any resend must be retyped deliberately; it is NOT presented as a retryable failure.

The actual rpc_->call("z_sendmany", {from, recipients, 1, fee}), the lite send/broadcast call, and all
amounts/fees/recipients/memo bytes are byte-identical; only result-handling and presentation change.
Pure classifier/marker regression tests added (testSendMaybeInFlight).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 14:30:16 -05:00
co-authored by Claude Opus 4.8
parent f4460beddd
commit 08a4427e31
6 changed files with 121 additions and 4 deletions
+17 -3
View File
@@ -891,8 +891,10 @@ void App::onDisconnected(const std::string& reason)
pending_opids_.clear();
pending_send_info_.clear();
// Resolve any deferred send callbacks so their UI doesn't spin forever on disconnect.
// Each callback here exists only because z_sendmany already returned an opid, so the op may still
// commit (or have committed) while we lost the connection: report "outcome unknown", not a failure.
for (auto& entry : pending_send_callbacks_) {
if (entry.second) entry.second(false, reason);
if (entry.second) entry.second(false, rpc::markSendMaybeInFlight(reason));
}
pending_send_callbacks_.clear();
consecutive_core_failures_ = 0;
@@ -7209,6 +7211,7 @@ void App::submitZSendMany(const std::string& from, const std::string& to, double
worker_->post([this, from, to, amount, fee, memo, recipients, callback, traceLabel,
markFeeGapRetry, background]() -> rpc::RPCWorker::MainCb {
bool ok = false;
bool maybeInFlight = false; // reply lost after the request left: the node may already hold the op
std::string result_str;
try {
rpc::RPCClient::TraceScope trace(traceLabel);
@@ -7217,12 +7220,13 @@ void App::submitZSendMany(const std::string& from, const std::string& to, double
ok = true;
} catch (const std::exception& e) {
result_str = e.what();
maybeInFlight = rpc::sendReplyMayBeLost(e);
} catch (...) {
// Non-std throw must not escape into the worker — the main-thread callback
// below would never run, leaving a stuck "Sending…" spinner.
result_str = "Send failed (unknown error)";
}
return [this, callback, ok, result_str, from, to, amount, fee, memo, markFeeGapRetry, background]() {
return [this, callback, ok, maybeInFlight, result_str, from, to, amount, fee, memo, markFeeGapRetry, background]() {
if (send_submissions_in_flight_ > 0) --send_submissions_in_flight_;
if (ok) {
// z_sendmany only returned an opid: the transaction is built/signed/
@@ -7245,7 +7249,17 @@ void App::submitZSendMany(const std::string& from, const std::string& to, double
}
} else {
if (!background) send_progress_active_ = false;
if (callback) callback(false, result_str);
if (maybeInFlight && !background) {
// Ambiguous: z_sendmany's reply never arrived, so the op may be running. Surface the
// distinct "check History before resending" outcome and re-read the wallet so a
// committed send shows up promptly. Definitive rejections stay plain failures below.
DEBUG_LOGF("[send] z_sendmany reply lost (%s): outcome unknown, not a definitive failure\n",
result_str.c_str());
markWalletMutated(/*forceBalance=*/true);
if (callback) callback(false, rpc::markSendMaybeInFlight(result_str));
} else if (callback) {
callback(false, result_str);
}
}
};
});