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:
@@ -819,6 +819,41 @@ void testClassifyRpcFailure()
|
||||
EXPECT_FALSE(dragonx::rpc::isRpcTimeout(K::QueueFull));
|
||||
}
|
||||
|
||||
// Lost-reply sends: only a transport failure AFTER the request left (no HTTP status) is ambiguous.
|
||||
void testSendMaybeInFlight()
|
||||
{
|
||||
using namespace dragonx::rpc;
|
||||
using K = RpcFailureKind;
|
||||
// Ambiguous: reply timeout, connection cut mid-reply, our own abort - request sent, no HTTP status.
|
||||
EXPECT_TRUE(sendReplyMayBeLost(RpcTransportError(K::ReplyTimeout, "t", CURLE_OPERATION_TIMEDOUT, 0, true)));
|
||||
EXPECT_TRUE(sendReplyMayBeLost(RpcTransportError(K::Other, "t", CURLE_GOT_NOTHING, 0, true)));
|
||||
EXPECT_TRUE(sendReplyMayBeLost(RpcTransportError(K::Aborted, "t", CURLE_ABORTED_BY_CALLBACK, 0, true)));
|
||||
// Definitive: never reached the daemon, or the daemon/HTTP layer answered.
|
||||
EXPECT_FALSE(sendReplyMayBeLost(RpcTransportError(K::ConnectTimeout, "t", CURLE_OPERATION_TIMEDOUT, 0, false)));
|
||||
EXPECT_FALSE(sendReplyMayBeLost(RpcTransportError(K::NotListening, "t", CURLE_COULDNT_CONNECT, 0, false)));
|
||||
EXPECT_FALSE(sendReplyMayBeLost(RpcTransportError(K::QueueFull, "t", CURLE_OK, 500, true)));
|
||||
EXPECT_FALSE(sendReplyMayBeLost(RpcTransportError(K::ShuttingDown, "t", CURLE_OK, 503, true)));
|
||||
EXPECT_FALSE(sendReplyMayBeLost(RpcTransportError(K::Auth, "t", CURLE_OK, 401, true)));
|
||||
EXPECT_FALSE(sendReplyMayBeLost(RpcTransportError(K::Deferred, "t", CURLE_OK, 0, false)));
|
||||
EXPECT_FALSE(sendReplyMayBeLost(RpcError(-6, "Insufficient funds")));
|
||||
EXPECT_FALSE(sendReplyMayBeLost(RpcError(-5, "Invalid address")));
|
||||
EXPECT_FALSE(sendReplyMayBeLost(std::runtime_error("plain")));
|
||||
// Lite text heuristic.
|
||||
EXPECT_TRUE(liteSendErrorMayBeInFlight("status: Unknown, message: \"transport error\""));
|
||||
EXPECT_TRUE(liteSendErrorMayBeInFlight("operation timed out"));
|
||||
EXPECT_FALSE(liteSendErrorMayBeInFlight("error trying to connect: tcp connect error"));
|
||||
EXPECT_FALSE(liteSendErrorMayBeInFlight("Error: SendResponse { error_code: 1, error_message: \"bad-txns\" }"));
|
||||
EXPECT_FALSE(liteSendErrorMayBeInFlight("Insufficient verified funds"));
|
||||
EXPECT_FALSE(liteSendErrorMayBeInFlight("Wallet is locked"));
|
||||
// Marker round trip + idempotence.
|
||||
const std::string marked = markSendMaybeInFlight("RPC request failed: Timeout");
|
||||
EXPECT_TRUE(isSendMaybeInFlight(marked));
|
||||
EXPECT_FALSE(isSendMaybeInFlight("RPC request failed: Timeout"));
|
||||
EXPECT_EQ(markSendMaybeInFlight(marked), marked);
|
||||
EXPECT_EQ(stripSendMaybeInFlight(marked), std::string("RPC request failed: Timeout"));
|
||||
EXPECT_EQ(stripSendMaybeInFlight("plain"), std::string("plain"));
|
||||
}
|
||||
|
||||
void testSecureWipeAndRemove()
|
||||
{
|
||||
namespace fs = std::filesystem;
|
||||
@@ -19135,6 +19170,7 @@ int main()
|
||||
{
|
||||
testConnectionConfig();
|
||||
testClassifyRpcFailure();
|
||||
testSendMaybeInFlight();
|
||||
testClassifyUnlockFailure();
|
||||
testHttpSinkAccept();
|
||||
testReadFileExact();
|
||||
|
||||
Reference in New Issue
Block a user