Skip to content

fetch: pay_invoice NWC reply-timeout can settle async → silent double-payment #55

Description

@reneaaron

Summary

During a real end-to-end payment test of fetch, an L402 payment threw Nip47ReplyTimeoutError: reply timeout and the CLI exited non-zero with {"error":"reply timeout..."} — but the payment settled anyway a few seconds later. Because the failure looked clean (non-zero exit, balance still unchanged at check time, lookup-invoice returned NOT_FOUND), a retry was issued and produced a second real charge for the same invoice. Net result: the service was paid twice and no content was returned either time.

Evidence

Two identical settled transactions for one fetch, 13 sats each (10 + 3 fee):

1783433306  10 + 3 fee = 13 | L402 Apps — Get APIs directory
1783433187  10 + 3 fee = 13 | L402 Apps — Get APIs directory

CLI output on each attempt:

$ npx @getalby/cli fetch "https://www.l402apps.com/api/apis" -X GET --max-amount 100 --currency BTC --unit sats --network lightning
Failed to request pay_invoice Nip47ReplyTimeoutError: reply timeout: event b455443377...
{"error":"reply timeout: event b455443377b65a128c92fb30cf296977b4e4bff5a97797d7c0e325141dfa5472"}

Likely root cause

The SDK's default NWC pay_invoice reply timeout is shorter than this wallet's actual pay_invoice round-trip. On timeout the CLI reports failure and exits, but the payment keeps routing and settles asynchronously. There is no idempotency or post-timeout reconciliation, so:

  1. The caller sees a failure for a payment that actually succeeded.
  2. The purchased content is lost (the L402 flow errors out before re-presenting the macaroon + preimage, and surfaces no reusable credential).
  3. A retry — which is a natural reaction, and which the payments skill's "Common Issues" table explicitly recommends ("402 payment failed → Retry") — double-pays.

Notably the timeout was specific to pay_invoice: get-balance, list-transactions, lookup-invoice, and parse-invoice all replied instantly against the same wallet/relay, so this is the long-running pay call outrunning the reply timeout, not a dead relay. Observed on the hub wallet; reproduced once (L402). x402 and MPP paid cleanly on the first attempt in the same session.

Impact

Silent, real money loss. An agent (or human) that retries on a fetch/pay failure can be charged multiple times for a single request, with nothing to show for it.

Suggested directions (for discussion)

  • Raise the pay_invoice NWC reply timeout well above expected settlement latency (and/or make it configurable).
  • On a pay_invoice reply timeout, reconcile before reporting failure: look up the invoice/payment status and, if it settled, treat it as success (and complete the L402 flow so the content isn't lost) rather than returning a bare error.
  • Add idempotency so a retried fetch for the same invoice can't pay twice.
  • Separately: the payments-skill "retry on payment failure" guidance should warn that a pay_invoice timeout may have still settled — check balance / lookup-invoice before retrying. (Skill-side; tracked with the CLI fix.)

Context

Found while consumer-testing the CLI end-to-end (discover → fetch across L402/x402/MPP) alongside #49. This is in the fetch payment flow and is independent of the discover/bridge work in that PR.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions