pay(): don't report a plain send failure while an MPP trusted leg is in flight - #110
Open
hash-money wants to merge 1 commit into
Open
hash-money wants to merge 1 commit into
hash-money wants to merge 1 commit into
Conversation
When the MPP fallback dispatches its trusted leg and the lightning leg
then fails, try_mpp_bolt11 already knows the trusted leg is in flight
(it re-records it so its terminal event surfaces), but it returned the
lightning leg's plain NodeError. pay() then preferred last_lightning_err
over it. A caller got e.g. LdkNodeFailure(PaymentSendingFailed), the
same value as a send where nothing left the wallet, so retrying on it
could pay the trusted portion twice.
Add WalletError::PartialPaymentPending { payment_id, error }, return it
from the MPP path when the trusted leg was dispatched, and have pay()
return it ahead of any other recorded error. The FFI maps it to the
existing LdkNodeFailure(String), so the FFI surface is unchanged.
Adds test_pay_error_reports_dispatched_mpp_leg covering the
lightning-leg-failure path of the MPP fallback.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
When the MPP fallback sends its trusted leg and the lightning leg then fails,
pay()returns thelightning leg's plain
NodeError(e.g.LdkNodeFailure(PaymentSendingFailed)), although the trustedleg is still in flight. A caller can't tell this apart from a send where nothing left the wallet. A
caller that retries on "sending failed" pays the trusted portion twice. This PR returns a distinct
error in that case.
What happens today
try_mpp_bolt11dispatches the trusted leg first (trusted.pay_partial(...),lib.rs:1537-1550),then attempts the lightning leg (
lib.rs:1554). If the lightning leg fails, the code already knowsthe trusted leg is in flight: it re-records it "so its eventual (failed) terminal event surfaces
normally" (
lib.rs:1558-1561). It then returns the lightning leg's error unchanged(
lib.rs:1580,return Err(e.into())).pay()then reports, after all fallbacks:(
lib.rs:1476-1479)So the same value comes back in two situations that need opposite handling:
The precedence makes the second case invisible even to a caller inspecting the error. The
lightning-only attempt runs (and fails) before MPP, so
last_lightning_erris set and wins over theMPP error.
The existing coverage for the MPP fallback (
test_pay_mpp_trusted_and_lightning, added with it in5664333) exercises the success path. This PR adds the lightning-leg-failure path.
Why the first row really is "nothing sent" (pinned sources)
ldk-nodesend_using_amount/send_using_amount_underpaying→send_internal(ldk-node0cea341,src/payment/bolt11.rs:268).PaymentSendingFailedis returned only from theErr(Bolt11PaymentError::SendingFailed(_))arm(
bolt11.rs:344-365), i.e. the synchronous error fromChannelManager::pay_for_bolt11_invoice(
bolt11.rs:306).506cb91,lightning/src/ln/outbound_payment.rs), every synchronousRetryableSendFailurethere comes fromfind_initial_route(:1553-1593:PaymentExpired,OnionPacketSizeExceeded,RouteNotFound) oradd_new_pending_payment(:1618-1625:DuplicatePayment). Both run beforepay_route_internal(:1627), after which the functionreturns
Ok(())and path failures arrive as events (:1632-1639; type doc:540-546).So a lightning
PaymentSendingFailedmeans no lightning HTLC left the node. Whether anything leftthe wallet depends only on whether an MPP trusted leg was dispatched, and that is what this PR makes
visible.
Change
WalletError::PartialPaymentPending { payment_id: PaymentId, error: NodeError }, returned bytry_mpp_bolt11when the lightning leg fails after the trusted leg was dispatched.payment_idisthe id the trusted leg is already tracked under (
surface_id), so the caller can follow it throughthe existing payment events.
pay()returns it ahead of any other recorded error, so the in-flight leg is never masked.Control flow is otherwise unchanged; the later fallbacks run exactly as before.
From<WalletError>maps it to the existingLdkNodeFailure(String)with adescriptive message, so the FFI surface is unchanged. Happy to add a typed FFI variant instead.
It doesn't depend on any particular trusted backend: it applies wherever
supports_partial_payments()is true and is a no-op where the MPP path never runs.On adding an enum variant
WalletErroris not#[non_exhaustive], so this is a breaking change for exhaustive matches. Thatis intentional. The case it introduces is one existing callers are already exposed to (they can't
see it), and a compile error is the right way to make them handle it. Reusing an existing variant
would keep the ambiguity. If you'd prefer, this could carry
#[non_exhaustive]onWalletErroratthe same time, or take another shape you like better; the requirement is only that the in-flight
case is distinguishable.
Test
test_pay_error_reports_dispatched_mpp_leg(integration, dummy trusted wallet; ignored under_cashu-testsliketest_pay_mpp_trusted_and_lightning, since CDK's test mint can't do partialmelts):
Lightning-only then fails with no route. The MPP split sends the 100-sat trusted leg, and its
lightning leg fails with no route.
pay()returnsErr(LdkNodeFailure(PaymentSendingFailed)), identical to thenothing-sent case; the log shows "Failed to send lightning MPP portion: PaymentSendingFailed" after
the trusted leg was dispatched.
pay()returnsPartialPaymentPending { payment_id: PaymentId::Trusted(_), .. }.cargo test --features _test-utils -p orange-sdk: 35 unit + 34 integration (incl. the new one) pass.ci/check-lint.sh(clippy + rustdoc-D warnings) andcargo fmt --checkare clean.How we hit it
We build a wallet on orange-sdk. Our send path offers a one-tap retry only when it knows nothing was
sent; that is our rule against double payments. Since
pay()can returnPaymentSendingFailedwith aleg in flight, we must treat every lightning
PaymentSendingFailedas "may be in flight".In testing, with our LSP briefly unreachable, a send failed with no route (
RouteNotFound→PaymentSendingFailed, nothing dispatched, no MPP split attempted). Our app still had to hold the userbehind a "payment pending" state for a payment that never left the wallet. The only alternative is to
encode on our side which backends reach the MPP path and how
pay()orders its fallbacks. Thatassumption would break silently if either changed, so we'd rather the error carry it.
🤖 Generated with Claude Code