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
+5 -1
View File
@@ -1050,7 +1050,11 @@ void App::update()
case LiteOpKind::None:
default:
if (lite_send_callback_) {
lite_send_callback_(broadcast.ok, broadcast.ok ? broadcast.txid : broadcast.error);
// A transport-style failure may mean the server accepted the tx but the reply was
// lost: tag it so the Send tab says "check History" instead of inviting a resend.
const std::string liteErr = (!broadcast.ok && rpc::liteSendErrorMayBeInFlight(broadcast.error))
? rpc::markSendMaybeInFlight(broadcast.error) : broadcast.error;
lite_send_callback_(broadcast.ok, broadcast.ok ? broadcast.txid : liteErr);
lite_send_callback_ = nullptr;
}
break;