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

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions