Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,15 +248,17 @@ if (pollRes.status === 402) {

If the invoice is paid but the flow then fails (the wallet times out, or the
request _after_ payment hits a network error), the helper throws a
`Fetch402PaymentError` instead of a bare `Error`. It carries everything needed
`Fetch402InterruptedError` instead of a bare `Error`. It carries everything needed
to reconcile the payment **without paying the same invoice again**:

```ts
class Fetch402PaymentError extends Error {
class Fetch402InterruptedError extends Error {
invoice: string; // the invoice that was paid (or attempted)
paymentHash: string; // decoded from the invoice — use it to look up settlement
amountSat: number; // invoice amount in satoshis, decoded from the invoice
paid: boolean; // whether the wallet reported the payment succeeded
preimage?: string; // present when paid
feesPaidMsat?: number; // routing fees in millisatoshis, present when paid (if reported)
credentials?: PaymentCredentials; // present when paid — retry with these
pendingPayment: PendingPayment; // opaque token to resume via options.resume
cause?: unknown; // the underlying wallet/fetch error
Expand All @@ -265,14 +267,14 @@ class Fetch402PaymentError extends Error {

Every field is plain data, so the error survives `JSON.stringify` and can be
forwarded across process/CLI boundaries. (After a round-trip it's a plain
object, so match on `e.name === "Fetch402PaymentError"` rather than
object, so match on `e.name === "Fetch402InterruptedError"` rather than
`instanceof`.)

```js
try {
const res = await fetch402(url, { method: "POST", body }, { wallet: nwc });
} catch (e) {
if (e.name !== "Fetch402PaymentError") throw e;
if (e.name !== "Fetch402InterruptedError") throw e;

if (e.paid) {
// Payment succeeded but the follow-up request failed. The credential is
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@getalby/lightning-tools",
"version": "9.0.0",
"version": "9.0.1",

@coderabbitai coderabbitai Bot Jul 22, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n## package.json version\n'
rg -n '"version"\s*:' package.json || true

printf '\n## Fetch402 names\n'
rg -n 'Fetch402(Payment|Interrupted)Error|name\s*=\s*[\'"]Fetch402' . || true

printf '\n## exports / public API surfaces\n'
rg -n 'export .*Fetch402|Fetch402.*export|from .*Fetch402' . || true

Repository: getAlby/js-lightning-tools

Length of output: 292


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo 'package.json:'
cat package.json | sed -n '1,40p'

echo
echo 'matches for Fetch402PaymentError / Fetch402InterruptedError:'
rg -n 'Fetch402PaymentError|Fetch402InterruptedError' .

Repository: getAlby/js-lightning-tools

Length of output: 3046


🏁 Script executed:

#!/bin/bash
set -euo pipefail
pwd
ls

Repository: getAlby/js-lightning-tools

Length of output: 381


Keep Fetch402PaymentError compatibility, or bump to 10.0.0. If this change replaces the public export and serialized name discriminator, it breaks existing imports and JSON-based recovery; add a legacy alias/handler or ship it as a major release.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@package.json` at line 3, Preserve backward compatibility for the public
Fetch402PaymentError export and its serialized name discriminator by adding a
legacy alias or deserialization handler for existing consumers; otherwise update
the package version from 9.0.1 to 10.0.0 to mark the breaking change.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

v9.0.0 was released today, it's a quick follow-up fix.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rolznz, understood. Since 9.0.0 is already published, consumers can still receive 9.0.1 as a patch update and have existing Fetch402PaymentError imports break. I’d retain a deprecated compatibility alias (and accept the legacy name where relevant) for this release, or publish the rename as 10.0.0.

"description": "Collection of helpful building blocks and tools to develop Bitcoin Lightning web apps",
"type": "module",
"repository": "https://github.com/getAlby/js-lightning-tools.git",
Expand Down
21 changes: 15 additions & 6 deletions src/402/l402/l402.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import fetchMock from "jest-fetch-mock";
import { fetchWithL402 } from "./l402";
import { parseL402 } from "./utils";
import { makeL402AuthenticateHeader } from "./server/utils";
import { Fetch402PaymentError } from "../utils";
import { Fetch402InterruptedError } from "../utils";
import { Invoice } from "../../bolt11";

const MACAROON =
Expand Down Expand Up @@ -243,13 +243,15 @@ describe("fetchWithL402", () => {
});

const error = await fetchWithL402(L402_URL, {}, { wallet }).catch((e) => e);
expect(error).toBeInstanceOf(Fetch402PaymentError);
expect(error).toBeInstanceOf(Fetch402InterruptedError);
expect(error.paid).toBe(false);
expect(error.invoice).toBe(REAL_INVOICE);
expect(error.paymentHash).toBe(
new Invoice({ pr: REAL_INVOICE }).paymentHash,
);
expect(error.amountSat).toBe(402);
expect(error.preimage).toBeUndefined();
expect(error.feesPaidMsat).toBeUndefined();
expect(error.credentials).toBeUndefined();
// The pendingPayment carries the macaroon so the credential can be rebuilt
// once the preimage is recovered (see the resume test below).
Expand Down Expand Up @@ -334,10 +336,15 @@ describe("fetchWithL402", () => {
});
});

test("throws recoverable Fetch402PaymentError when the request after payment fails", async () => {
test("throws recoverable Fetch402InterruptedError when the request after payment fails", async () => {
// The invoice is already paid; a network error on the retry must NOT lose
// the preimage/credential (else the caller would pay the same invoice again).
const wallet = makeWallet();
// the preimage/credential (else the caller would pay the same invoice again)
// nor the payment's cost details (amount and routing fees).
const wallet = {
payInvoice: jest
.fn()
.mockResolvedValue({ preimage: PREIMAGE, fees_paid: 3000 }),
};

fetchMock.mockResponseOnce("Payment Required", {
status: 402,
Expand All @@ -353,13 +360,15 @@ describe("fetchWithL402", () => {

const error = await fetchWithL402(L402_URL, {}, { wallet }).catch((e) => e);
expect(wallet.payInvoice).toHaveBeenCalledTimes(1);
expect(error).toBeInstanceOf(Fetch402PaymentError);
expect(error).toBeInstanceOf(Fetch402InterruptedError);
expect(error.paid).toBe(true);
expect(error.invoice).toBe(REAL_INVOICE);
expect(error.paymentHash).toBe(
new Invoice({ pr: REAL_INVOICE }).paymentHash,
);
expect(error.amountSat).toBe(402);
expect(error.preimage).toBe(PREIMAGE);
expect(error.feesPaidMsat).toBe(3000);
expect(error.credentials).toEqual({
header: "Authorization",
value: `L402 ${MACAROON}:${PREIMAGE}`,
Expand Down
4 changes: 2 additions & 2 deletions src/402/mpp/mpp.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import fetchMock from "jest-fetch-mock";
import { fetchWithMpp } from "./mpp";
import { Fetch402PaymentError } from "../utils";
import { Fetch402InterruptedError } from "../utils";
import { Invoice } from "../../bolt11";
import {
buildMppCredential,
Expand Down Expand Up @@ -451,7 +451,7 @@ describe("fetchWithMpp", () => {
});

const error = await fetchWithMpp(MPP_URL, {}, { wallet }).catch((e) => e);
expect(error).toBeInstanceOf(Fetch402PaymentError);
expect(error).toBeInstanceOf(Fetch402InterruptedError);
expect(error.paid).toBe(false);
expect(error.invoice).toBe(REAL_INVOICE);
expect(error.paymentHash).toBe(
Expand Down
22 changes: 15 additions & 7 deletions src/402/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ export interface Fetch402Options {
credentials?: PaymentCredentials;
/**
* Resume a payment that was interrupted by a timeout. When `payInvoice` throws
* (see {@link Fetch402PaymentError} with `paid: false`) the payment may still
* (see {@link Fetch402InterruptedError} with `paid: false`) the payment may still
* have settled; look the payment up in your wallet by `paymentHash`, and if it
* settled pass the recovered `preimage` back here together with the error's
* `pendingPayment`. The helper rebuilds the credential and sends the request
Expand Down Expand Up @@ -226,20 +226,24 @@ export const getPaymentHash = (invoice: string): string => {
*
* All fields are plain data so the error survives `JSON.stringify` and can be
* forwarded across process/CLI boundaries. Note: after such a round-trip the
* value is a plain object, so match on `name === "Fetch402PaymentError"` (or
* value is a plain object, so match on `name === "Fetch402InterruptedError"` (or
* the presence of `paymentHash`) rather than `instanceof`.
*/
export class Fetch402PaymentError extends Error {
export class Fetch402InterruptedError extends Error {
/** Discriminator that survives serialization (`instanceof` does not). */
readonly name = "Fetch402PaymentError";
readonly name = "Fetch402InterruptedError";
/** The invoice that was paid (or attempted). */
readonly invoice: string;
/** Payment hash decoded from the invoice; use it to look up settlement. */
readonly paymentHash: string;
/** Amount of the invoice in satoshis, decoded from it (0 when it cannot be decoded). */
readonly amountSat: number;
/** Whether `wallet.payInvoice` reported success before the failure. */
readonly paid: boolean;
/** Payment preimage, present when the invoice was paid. */
readonly preimage?: string;
/** Routing fees in millisatoshis, present when `paid` and reported by the wallet. */
readonly feesPaidMsat?: number;
/** Reusable credential, present when `paid` (already built for you). */
readonly credentials?: PaymentCredentials;
/**
Expand All @@ -257,15 +261,18 @@ export class Fetch402PaymentError extends Error {
paid: boolean;
pendingPayment: PendingPayment;
preimage?: string;
feesPaidMsat?: number;
credentials?: PaymentCredentials;
cause?: unknown;
},
) {
super(message);
this.invoice = details.invoice;
this.paymentHash = getPaymentHash(details.invoice);
this.amountSat = getInvoiceAmount(details.invoice);
this.paid = details.paid;
this.preimage = details.preimage;
this.feesPaidMsat = details.feesPaidMsat;
this.credentials = details.credentials;
this.pendingPayment = details.pendingPayment;
this.cause = details.cause;
Expand All @@ -275,7 +282,7 @@ export class Fetch402PaymentError extends Error {
/**
* Shared tail of every 402 handler: pay the invoice, apply the resulting
* credential, retry the request, and attach payment metadata. Any failure
* during or after payment is rethrown as {@link Fetch402PaymentError} so a
* during or after payment is rethrown as {@link Fetch402InterruptedError} so a
* thrown error never loses the paid invoice/credential and the caller can
* reconcile instead of paying twice.
*/
Expand Down Expand Up @@ -307,7 +314,7 @@ export const payAndFetch = async (args: {
// Payment may or may not have settled (e.g. a wallet timeout). Surface the
// paymentHash (to look up settlement) and the pendingPayment (to resume from
// a recovered preimage via options.resume) so the caller need never re-pay.
throw new Fetch402PaymentError(
throw new Fetch402InterruptedError(
"402: payInvoice failed; look up paymentHash before retrying to avoid double payment",
{ invoice, paid: false, pendingPayment, cause },
);
Expand All @@ -322,13 +329,14 @@ export const payAndFetch = async (args: {
} catch (cause) {
// The invoice is already paid — a retry MUST reuse these credentials rather
// than pay again.
throw new Fetch402PaymentError(
throw new Fetch402InterruptedError(
"402: request after payment failed; retry with credentials instead of paying again",
{
invoice,
paid: true,
pendingPayment,
preimage: invResp.preimage,
feesPaidMsat: invResp.fees_paid,
credentials,
cause,
},
Expand Down
4 changes: 2 additions & 2 deletions src/402/x402/x402.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import fetchMock from "jest-fetch-mock";
import { fetchWithX402 } from "./x402";
import { Fetch402PaymentError } from "../utils";
import { Fetch402InterruptedError } from "../utils";
import { Invoice } from "../../bolt11";

const INVOICE =
Expand Down Expand Up @@ -289,7 +289,7 @@ describe("fetchWithX402", () => {
});

const error = await fetchWithX402(X402_URL, {}, { wallet }).catch((e) => e);
expect(error).toBeInstanceOf(Fetch402PaymentError);
expect(error).toBeInstanceOf(Fetch402InterruptedError);
expect(error.paid).toBe(false);
expect(error.invoice).toBe(INVOICE);
expect(error.paymentHash).toBe(new Invoice({ pr: INVOICE }).paymentHash);
Expand Down
Loading