Skip to content

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
lightningdevkit:masterfrom
emergent-money:pay-error-reports-dispatched-leg
Open

hash-money wants to merge 1 commit into
lightningdevkit:masterfrom
emergent-money:pay-error-reports-dispatched-leg

Conversation

@hash-money

Copy link
Copy Markdown
Contributor

Summary

When the MPP fallback sends its trusted leg and the lightning leg then fails, pay() returns the
lightning leg's plain NodeError (e.g. LdkNodeFailure(PaymentSendingFailed)), although the trusted
leg 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_bolt11 dispatches 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 knows
the 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:

Err(last_lightning_err
    .or(last_mpp_err)
    .or(last_trusted_err)
    .unwrap_or(WalletError::LdkNodeFailure(NodeError::InsufficientFunds)))

(lib.rs:1476-1479)

So the same value comes back in two situations that need opposite handling:

What left the wallet Safe to retry?
Lightning-only attempt fails (e.g. no route) nothing yes
MPP: trusted leg sent, then lightning leg fails the trusted leg, still in flight no, a retry double-pays it

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_err is set and wins over the
MPP error.

The existing coverage for the MPP fallback (test_pay_mpp_trusted_and_lightning, added with it in
5664333) exercises the success path. This PR adds the lightning-leg-failure path.

Why the first row really is "nothing sent" (pinned sources)

  • orange-sdk's lightning send goes to ldk-node send_using_amount /
    send_using_amount_underpaying → send_internal (ldk-node 0cea341, src/payment/bolt11.rs:268).
    PaymentSendingFailed is returned only from the Err(Bolt11PaymentError::SendingFailed(_)) arm
    (bolt11.rs:344-365), i.e. the synchronous error from ChannelManager::pay_for_bolt11_invoice
    (bolt11.rs:306).
  • In LDK (506cb91, lightning/src/ln/outbound_payment.rs), every synchronous
    RetryableSendFailure there comes from find_initial_route (:1553-1593: PaymentExpired,
    OnionPacketSizeExceeded, RouteNotFound) or add_new_pending_payment (:1618-1625:
    DuplicatePayment). Both run before pay_route_internal (:1627), after which the function
    returns Ok(()) and path failures arrive as events (:1632-1639; type doc :540-546).

So a lightning PaymentSendingFailed means no lightning HTLC left the node. Whether anything left
the wallet depends only on whether an MPP trusted leg was dispatched, and that is what this PR makes
visible.

Change

  • New WalletError::PartialPaymentPending { payment_id: PaymentId, error: NodeError }, returned by
    try_mpp_bolt11 when the lightning leg fails after the trusted leg was dispatched. payment_id is
    the id the trusted leg is already tracked under (surface_id), so the caller can follow it through
    the 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.
  • FFI: the uniffi From<WalletError> maps it to the existing LdkNodeFailure(String) with a
    descriptive 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

WalletError is not #[non_exhaustive], so this is a breaking change for exhaustive matches. That
is 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] on WalletError at
the 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-tests like test_pay_mpp_trusted_and_lightning, since CDK's test mint can't do partial
melts):

  1. Fund trusted (100 sats).
  2. Open an LSP channel.
  3. Have the LSP drop the wallet's lightning peer. The trusted node keeps its own connection.
  4. Pay a 200-sat invoice.

Lightning-only then fails with no route. The MPP split sends the 100-sat trusted leg, and its
lightning leg fails with no route.

  • Before (a3aa1c5): pay() returns Err(LdkNodeFailure(PaymentSendingFailed)), identical to the
    nothing-sent case; the log shows "Failed to send lightning MPP portion: PaymentSendingFailed" after
    the trusted leg was dispatched.
  • After: pay() returns PartialPaymentPending { 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) and cargo fmt --check are 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 return PaymentSendingFailed with a
leg in flight, we must treat every lightning PaymentSendingFailed as "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 user
behind 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. That
assumption would break silently if either changed, so we'd rather the error carry it.

🤖 Generated with Claude Code

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant