diff --git a/README.md b/README.md index 4c731fe..c7fca27 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/package.json b/package.json index 2ad78e7..8006074 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@getalby/lightning-tools", - "version": "9.0.0", + "version": "9.0.1", "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", diff --git a/src/402/l402/l402.test.ts b/src/402/l402/l402.test.ts index 11271c5..ee36f22 100644 --- a/src/402/l402/l402.test.ts +++ b/src/402/l402/l402.test.ts @@ -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 = @@ -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). @@ -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, @@ -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}`, diff --git a/src/402/mpp/mpp.test.ts b/src/402/mpp/mpp.test.ts index 09f4133..363d870 100644 --- a/src/402/mpp/mpp.test.ts +++ b/src/402/mpp/mpp.test.ts @@ -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, @@ -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( diff --git a/src/402/utils.ts b/src/402/utils.ts index 997d7d7..71c91a4 100644 --- a/src/402/utils.ts +++ b/src/402/utils.ts @@ -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 @@ -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; /** @@ -257,6 +261,7 @@ export class Fetch402PaymentError extends Error { paid: boolean; pendingPayment: PendingPayment; preimage?: string; + feesPaidMsat?: number; credentials?: PaymentCredentials; cause?: unknown; }, @@ -264,8 +269,10 @@ export class Fetch402PaymentError extends Error { 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; @@ -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. */ @@ -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 }, ); @@ -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, }, diff --git a/src/402/x402/x402.test.ts b/src/402/x402/x402.test.ts index e868448..751ccd9 100644 --- a/src/402/x402/x402.test.ts +++ b/src/402/x402/x402.test.ts @@ -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 = @@ -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);