From 612149abc6fedfaacb66cf88178682bfeb09a42a Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Wed, 22 Jul 2026 13:23:25 +0700 Subject: [PATCH 1/2] feat: surface 402 payment recovery info so interrupted payments are never paid twice Fixes #55 and #58 by updating @getalby/lightning-tools to v9.0.0 and using its new payment recovery API. The CLI deliberately does not recover on its own (no wallet polling, no automatic retries) - the calling agent must have the context of what happened, so the CLI exits immediately with a structured error carrying everything needed to recover: - If pay_invoice fails (e.g. an NWC reply timeout), the payment may still settle. The error output includes a paymentRecovery object with the payment hash, the pendingPayment token, and instructions: check lookup-invoice, then re-run the fetch with the new --resume flag to get the content without paying again, or retry normally if the wallet confirms the payment failed. - If the request after a successful payment fails (network error or non-OK response), the error surfaces the full payment metadata (amountSat, preimage, credentials) with instructions to re-run with --credentials instead of re-paying. - v9.0.0 also renames the payment output fields to unit-explicit amountSat / feesPaidMsat, so agents no longer misread the msat fee as sats (#58). Co-Authored-By: Claude Fable 5 --- package.json | 2 +- src/commands/fetch.ts | 62 +++++++- src/test/fetch-402-recovery.test.ts | 210 ++++++++++++++++++++++++++++ src/tools/lightning/fetch.ts | 137 ++++++++++++++++-- src/utils.ts | 22 ++- yarn.lock | 8 +- 6 files changed, 421 insertions(+), 20 deletions(-) create mode 100644 src/test/fetch-402-recovery.test.ts diff --git a/package.json b/package.json index cb40b60..9dae76d 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "node": ">=20" }, "dependencies": { - "@getalby/lightning-tools": "^8.2.0", + "@getalby/lightning-tools": "^9.0.0", "@getalby/sdk": "^8.0.3", "@lendasat/lendaswap-sdk-pure": "^0.2.38", "@noble/hashes": "^2.0.1", diff --git a/src/commands/fetch.ts b/src/commands/fetch.ts index 4d7c60c..e42dd0c 100644 --- a/src/commands/fetch.ts +++ b/src/commands/fetch.ts @@ -1,5 +1,5 @@ import { Command, InvalidArgumentError } from "commander"; -import { fetch402 } from "../tools/lightning/fetch.js"; +import { fetch402, type Fetch402Resume } from "../tools/lightning/fetch.js"; import { getClient, handleError, output } from "../utils.js"; import { classifyRail } from "../amount.js"; @@ -57,6 +57,36 @@ function parseCredentials(value: string): { header: string; value: string } { return { header, value: headerValue }; } +/** + * Parse and validate the `--resume` JSON: the `pendingPayment` object from a + * previous fetch's payment-recovery error plus the preimage recovered via + * lookup-invoice. `pendingPayment` is opaque to the CLI (the library builds + * the credential from it), so only its shape is checked here. + */ +function parseResume(value: string): Fetch402Resume { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + throw new InvalidArgumentError( + "--resume must be valid JSON (e.g. '{\"pendingPayment\":{...},\"preimage\":\"...\"}')", + ); + } + if ( + typeof parsed !== "object" || + parsed === null || + typeof (parsed as Record).pendingPayment !== "object" || + (parsed as Record).pendingPayment === null || + typeof (parsed as Record).preimage !== "string" || + !(parsed as Record).preimage + ) { + throw new InvalidArgumentError( + '--resume must be a JSON object with a "pendingPayment" object and a non-empty string "preimage"', + ); + } + return parsed as Fetch402Resume; +} + export function registerFetch402Command(program: Command) { program .command("fetch") @@ -92,13 +122,28 @@ export function registerFetch402Command(program: Command) { "authorized with it and never pays again — use it to authorize " + "follow-up requests (e.g. polling) without re-paying.", ) + .option( + "--resume ", + "Resume a payment interrupted before its preimage was known " + + '(JSON: {"pendingPayment":{...},"preimage":"..."}). Use the ' + + "pendingPayment from a previous fetch's paymentRecovery error " + + "together with the preimage recovered via lookup-invoice. The " + + "request is authorized with the rebuilt credential and never pays " + + "again.", + ) .addHelpText( "after", "\nExample:\n" + ' $ npx @getalby/cli fetch "https://example.com/api" --max-amount 1000 --currency BTC --unit sats --network lightning\n' + - "\nA successful response includes a `payment` object with the fees paid and a\n" + - "reusable `credentials` value. Reuse it to avoid paying again:\n" + - ' $ npx @getalby/cli fetch "https://example.com/api" --credentials \'{"header":"Authorization","value":"L402 ..."}\'\n', + "\nA successful response includes a `payment` object with the amount paid\n" + + "(amountSat), routing fees (feesPaidMsat, in millisatoshis) and a reusable\n" + + "`credentials` value. Reuse it to avoid paying again:\n" + + ' $ npx @getalby/cli fetch "https://example.com/api" --credentials \'{"header":"Authorization","value":"L402 ..."}\'\n' + + "\nIf a payment is interrupted (e.g. a wallet timeout), the error output includes\n" + + "a `paymentRecovery` object with the payment hash and everything needed to\n" + + "recover - follow its instructions (check lookup-invoice, then re-run with\n" + + "--resume or --credentials) instead of retrying blindly, so the same invoice\n" + + "is never paid twice.\n", ) .action(async (url, options) => { await handleError(async () => { @@ -124,6 +169,14 @@ export function registerFetch402Command(program: Command) { ); } + if (options.credentials && options.resume) { + throw new Error( + "--credentials and --resume are mutually exclusive - use " + + "--credentials to reuse a completed payment's credential, or " + + "--resume to complete an interrupted one", + ); + } + const client = await getClient(program); const result = await fetch402(client, { url: url, @@ -136,6 +189,7 @@ export function registerFetch402Command(program: Command) { credentials: options.credentials ? parseCredentials(options.credentials) : undefined, + resume: options.resume ? parseResume(options.resume) : undefined, }); output(result); }); diff --git a/src/test/fetch-402-recovery.test.ts b/src/test/fetch-402-recovery.test.ts new file mode 100644 index 0000000..2ac7955 --- /dev/null +++ b/src/test/fetch-402-recovery.test.ts @@ -0,0 +1,210 @@ +import { describe, test, expect, vi, afterEach } from "vitest"; +import { getPaymentHash } from "@getalby/lightning-tools/402"; +import { NWCClient } from "@getalby/sdk"; +import { fetch402 } from "../tools/lightning/fetch.js"; +import { DetailedError } from "../utils.js"; + +// Real, decodable invoice + macaroon (from the js-lightning-tools test suite) +// so the library can derive the payment hash surfaced for reconciliation. +const MACAROON = + "AgEEbHNhdAJCAAAClGOZrh7C569Yc7UMk8merfnMdIviyXr1qscW7VgpChNl21LkZ8Jex5QiPp+E1VaabeJDuWmlrh/j583axFpNAAIXc2VydmljZXM9cmFuZG9tbnVtYmVyOjAAAiZyYW5kb21udW1iZXJfY2FwYWJpbGl0aZVzPWFkZCxzdWJ0cmFjdAAABiAvFpzXGyc+8d/I9nMKKvAYP8w7kUlhuxS0eFN2sqmqHQ=="; +const INVOICE = + "lnbc4020n1p5m6028dq80q6rqvsnp4qt5w34u6kntf5lc50jj27rvs89sgrpcpj7s6vfts042gkhxx2j6swpp5g6tquvmswkv5xf0ru7ju2qvdrf83l2ewha3qzzt0a7vurs5q30rssp54kt5hfzjngjersx8fgt60feuu8e7vnat67f3ksr98twdj7z0m0ls9qyysgqcqzp2xqyz5vqrzjqdc22wfv6lyplagj37n9dmndkrzdz8rh3lxkewvvk6arkjpefats2rf47yqqwysqqcqqqqlgqqqqqqgqfqrzjq26922n6s5n5undqrf78rjjhgpcczafws45tx8237y7pzx3fg8ww8apyqqqqqqqqjyqqqqlgqqqqr4gq2q3z5pu33awfm98ac3ysdhy046xmen4zqval67tccu35x9mxgvl6w3wmq6y03ae7pme6qr20mp5gvuqntnu8yy7nlf6gyt9zshanj2zhgqe4xde3"; +const PREIMAGE = + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; +const PAYMENT_HASH = getPaymentHash(INVOICE); + +const URL = "https://example.com/protected"; + +function l402Response() { + return new Response("Payment Required", { + status: 402, + headers: { + "www-authenticate": `L402 macaroon="${MACAROON}", invoice="${INVOICE}"`, + }, + }); +} + +function makeClient(overrides: Record = {}): NWCClient { + return { + payInvoice: vi.fn().mockResolvedValue({ preimage: PREIMAGE }), + ...overrides, + } as unknown as NWCClient; +} + +function authorizationHeader(fetchMock: ReturnType, call: number) { + const init = fetchMock.mock.calls[call][1] as RequestInit; + return (init.headers as Headers).get("Authorization"); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("fetch 402 payment recovery", () => { + test("surfaces recovery info when payInvoice fails (e.g. a reply timeout)", async () => { + // The payment may still settle after the timeout, so the CLI must not + // retry, poll, or guess - it exits with everything needed to reconcile. + const client = makeClient({ + payInvoice: vi.fn().mockRejectedValue(new Error("reply timeout")), + }); + const fetchMock = vi.fn().mockResolvedValueOnce(l402Response()); + vi.stubGlobal("fetch", fetchMock); + + const error = await fetch402(client, { url: URL }).catch((e) => e); + + expect(error).toBeInstanceOf(DetailedError); + expect(error.message).toContain("reply timeout"); + expect(error.message).toContain("do NOT retry"); + expect(error.details?.paymentRecovery).toMatchObject({ + status: "unknown", + paymentHash: PAYMENT_HASH, + pendingPayment: { + scheme: "l402", + header: "Authorization", + token: MACAROON, + authScheme: "L402", + }, + }); + expect(error.details?.paymentRecovery.instructions).toContain( + `lookup-invoice --payment-hash ${PAYMENT_HASH}`, + ); + expect(error.details?.paymentRecovery.instructions).toContain("--resume"); + // No retry happened: one payment attempt, one request. + expect(client.payInvoice).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + test("surfaces the credential when the request after payment fails", async () => { + const client = makeClient(); + const fetchMock = vi + .fn() + .mockResolvedValueOnce(l402Response()) + .mockRejectedValueOnce(new Error("network down")); + vi.stubGlobal("fetch", fetchMock); + + const error = await fetch402(client, { url: URL }).catch((e) => e); + + expect(error).toBeInstanceOf(DetailedError); + expect(error.message).toContain("payment succeeded"); + expect(error.message).toContain("network down"); + expect(error.details?.paymentRecovery).toMatchObject({ + status: "paid", + paymentHash: PAYMENT_HASH, + payment: { + paid: true, + amountSat: 402, + preimage: PREIMAGE, + credentials: { + header: "Authorization", + value: `L402 ${MACAROON}:${PREIMAGE}`, + }, + }, + }); + expect(error.details?.paymentRecovery.instructions).toContain( + "--credentials", + ); + // The CLI itself does not retry - that is the caller's decision. + expect(client.payInvoice).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + test("surfaces the credential when the response after payment is non-OK", async () => { + const client = makeClient(); + const fetchMock = vi + .fn() + .mockResolvedValueOnce(l402Response()) + .mockResolvedValueOnce(new Response("temporary outage", { status: 503 })); + vi.stubGlobal("fetch", fetchMock); + + const error = await fetch402(client, { url: URL }).catch((e) => e); + + expect(error).toBeInstanceOf(DetailedError); + expect(error.message).toContain("non-OK status: 503"); + // The library's full payment metadata is surfaced, not just the credential. + expect(error.details?.paymentRecovery).toMatchObject({ + status: "paid", + payment: { + paid: true, + amountSat: 402, + preimage: PREIMAGE, + credentials: { + header: "Authorization", + value: `L402 ${MACAROON}:${PREIMAGE}`, + }, + }, + }); + }); + + test("non-OK response without a payment carries no recovery details", async () => { + const client = makeClient(); + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response("not found", { status: 404 })); + vi.stubGlobal("fetch", fetchMock); + + const error = await fetch402(client, { url: URL }).catch((e) => e); + + expect(error.message).toContain("non-OK status: 404"); + expect(error.details).toBeUndefined(); + expect(client.payInvoice).not.toHaveBeenCalled(); + }); + + test("resume sends the rebuilt credential without paying", async () => { + const client = makeClient({ + payInvoice: vi.fn().mockRejectedValue(new Error("should not be called")), + }); + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response("paid content", { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + + const result = await fetch402(client, { + url: URL, + resume: { + pendingPayment: { + scheme: "l402", + header: "Authorization", + token: MACAROON, + authScheme: "L402", + }, + preimage: PREIMAGE, + }, + }); + + expect(result.content).toBe("paid content"); + expect(client.payInvoice).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(authorizationHeader(fetchMock, 0)).toBe( + `L402 ${MACAROON}:${PREIMAGE}`, + ); + // This request itself did not pay; the payment happened earlier. + expect(result.payment?.paid).toBe(false); + expect(result.payment?.preimage).toBe(PREIMAGE); + }); + + test("credentials send the stored credential without paying", async () => { + const client = makeClient({ + payInvoice: vi.fn().mockRejectedValue(new Error("should not be called")), + }); + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response("paid content", { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + + const result = await fetch402(client, { + url: URL, + credentials: { + header: "Authorization", + value: `L402 ${MACAROON}:${PREIMAGE}`, + }, + }); + + expect(result.content).toBe("paid content"); + expect(client.payInvoice).not.toHaveBeenCalled(); + expect(authorizationHeader(fetchMock, 0)).toBe( + `L402 ${MACAROON}:${PREIMAGE}`, + ); + expect(result.payment?.paid).toBe(false); + }); +}); diff --git a/src/tools/lightning/fetch.ts b/src/tools/lightning/fetch.ts index bc7aeb9..72f8c50 100644 --- a/src/tools/lightning/fetch.ts +++ b/src/tools/lightning/fetch.ts @@ -1,11 +1,21 @@ import { + Fetch402PaymentError, fetch402 as fetch402Lib, + getInvoiceAmount, + type PaymentInfo, type PaymentCredentials, + type PendingPayment, } from "@getalby/lightning-tools/402"; import { NWCClient } from "@getalby/sdk"; +import { DetailedError } from "../../utils.js"; const DEFAULT_MAX_AMOUNT_SATS = 5000; +export interface Fetch402Resume { + pendingPayment: PendingPayment; + preimage: string; +} + export interface Fetch402Params { url: string; method?: string; @@ -18,6 +28,13 @@ export interface Fetch402Params { * follow-up requests (e.g. polling a long-running job) without re-paying. */ credentials?: PaymentCredentials; + /** + * Resume a payment that was interrupted before its preimage was known (the + * `pendingPayment` from a previous fetch error, plus the preimage recovered + * via lookup-invoice). The request is authorized with the rebuilt credential + * and NEVER pays again. + */ + resume?: Fetch402Resume; } export async function fetch402(client: NWCClient, params: Fetch402Params) { @@ -45,26 +62,126 @@ export async function fetch402(client: NWCClient, params: Fetch402Params) { const maxAmountSats = params.maxAmountSats ?? DEFAULT_MAX_AMOUNT_SATS; - const result = await fetch402Lib(params.url, requestOptions, { - wallet: client, - maxAmount: maxAmountSats, - credentials: params.credentials, - }); + let result; + try { + result = await fetch402Lib(params.url, requestOptions, { + wallet: client, + maxAmount: maxAmountSats, + credentials: params.credentials, + resume: params.resume, + }); + } catch (error) { + if (isFetch402PaymentError(error)) { + throw toRecoveryError(error); + } + throw error; + } const responseContent = await result.text(); if (!result.ok) { - throw new Error( + // A non-OK response after a payment must not lose the payment metadata - + // with the credential the request can be retried without paying the same + // invoice again. + throw new DetailedError( `fetch returned non-OK status: ${result.status} ${responseContent}`, + result.payment?.credentials + ? paidRecoveryDetails(result.payment) + : undefined, ); } return { content: responseContent, // Payment metadata attached by the 402 helper: whether a payment was made, - // the amount, routing fees (feesPaid, in millisatoshis), and the reusable - // credential. Pass `credentials` back via --credentials on a follow-up - // request to authorize it without paying again. Absent when no 402 payment - // was involved (e.g. an already-open resource). + // the amount (amountSat), routing fees (feesPaidMsat, in millisatoshis), + // and the reusable credential. Pass `credentials` back via --credentials + // on a follow-up request to authorize it without paying again. Absent when + // no 402 payment was involved (e.g. an already-open resource). payment: result.payment, }; } + +/** + * Convert the library's Fetch402PaymentError into a structured CLI error. + * + * A payment was attempted but the flow failed before a response was obtained. + * The CLI deliberately does NOT recover on its own (no wallet polling, no + * automatic retries) - whether and when to reconcile is the caller's call, and + * a CLI silently waiting on an in-flight payment would just look hung. Instead + * the error output carries everything the caller needs to recover without ever + * paying the same invoice twice. + */ +function toRecoveryError(error: Fetch402PaymentError): DetailedError { + if (error.paid && error.credentials) { + // The invoice was paid but the request after it failed (e.g. a network + // error). The credential is already built - a retry must reuse it. The + // error doesn't carry the library's PaymentInfo, so rebuild the same shape + // from what it does carry (routing fees were lost with the response). + return new DetailedError( + `payment succeeded but the request failed: ${errorMessage(error.cause ?? error)}`, + paidRecoveryDetails( + { + paid: true, + amountSat: getInvoiceAmount(error.invoice), + preimage: error.preimage, + credentials: error.credentials, + }, + error.paymentHash, + ), + ); + } + + // payInvoice itself failed (e.g. an NWC reply timeout). The payment may + // still settle after we exit, so retrying now could pay the same invoice + // twice - surface everything needed to check and resume without re-paying. + return new DetailedError( + `payment did not complete: ${errorMessage(error.cause ?? error)}. ` + + "The payment may still settle - do NOT retry this fetch yet.", + { + paymentRecovery: { + status: "unknown", + paymentHash: error.paymentHash, + pendingPayment: error.pendingPayment, + instructions: + "The payment may still be in flight; retrying this fetch now could " + + "pay twice. First run: lookup-invoice --payment-hash " + + `${error.paymentHash} - if it returns a preimage, the payment ` + + "settled: re-run the same fetch adding: --resume " + + `'{"pendingPayment":,"preimage":""}' ` + + "to get the content without paying again. If it shows state " + + '"failed", no funds moved and it is safe to re-run the fetch ' + + "normally. If it is still pending or not found, wait and check again.", + }, + }, + ); +} + +function paidRecoveryDetails(payment: PaymentInfo, paymentHash?: string) { + return { + paymentRecovery: { + status: "paid", + ...(paymentHash ? { paymentHash } : {}), + payment, + instructions: + "The payment already succeeded - do NOT re-run this fetch without " + + "--credentials, or you will pay again. If the failure looks " + + "temporary, re-run the same fetch adding: --credentials " + + `'${JSON.stringify(payment.credentials)}'`, + }, + }; +} + +// The error normally arrives via instanceof, but match on `name` too so a +// serialized/re-thrown copy (the class documents this) is still recognized. +function isFetch402PaymentError( + error: unknown, +): error is Fetch402PaymentError { + return ( + error instanceof Fetch402PaymentError || + (error instanceof Error && error.name === "Fetch402PaymentError") + ); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/utils.ts b/src/utils.ts index 1125a95..4337993 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -308,14 +308,34 @@ export function output(data: unknown) { console.log(JSON.stringify(data, null, 2)); } +/** + * An error carrying structured data to include in the JSON error output + * alongside the message - e.g. machine-readable payment recovery info so the + * calling agent can resume an interrupted 402 payment instead of re-paying. + * The details' own keys appear at the top level of the output, next to + * `error`, so the thrower names them (e.g. `paymentRecovery`). + */ +export class DetailedError extends Error { + constructor( + message: string, + public readonly details?: Record, + ) { + super(message); + } +} + export async function handleError(fn: () => Promise) { try { await fn(); process.exit(0); } catch (error) { + const details = error instanceof DetailedError ? error.details : undefined; console.error( JSON.stringify( - { error: error instanceof Error ? error.message : String(error) }, + { + error: error instanceof Error ? error.message : String(error), + ...details, + }, null, 2, ), diff --git a/yarn.lock b/yarn.lock index 041c965..a9855ad 100644 --- a/yarn.lock +++ b/yarn.lock @@ -78,10 +78,10 @@ resolved "https://registry.yarnpkg.com/@getalby/lightning-tools/-/lightning-tools-8.1.1.tgz#4bf8be9f30dd76f7dd082262e862e63c16591fae" integrity sha512-gB+3fvTGz9bbl31JNgKMWqG4dZWgCxSB51fYn+F4tZ3x2h5oI/yHjvb6W9GBo2Zscr5gMP64gs0LlzKYoETlvQ== -"@getalby/lightning-tools@^8.2.0": - version "8.2.0" - resolved "https://registry.yarnpkg.com/@getalby/lightning-tools/-/lightning-tools-8.2.0.tgz#7bcd7c24149f5dfe59e4660aebcda22637d1f207" - integrity sha512-eL+cnHyzeUARKVzNRuFBRBppAU5RBJOLjhFVzSWt8hDibRzL5+e4hq8I79+RGHfrWWMUDv382Jx8ewOazrBj7w== +"@getalby/lightning-tools@^9.0.0": + version "9.0.0" + resolved "https://registry.yarnpkg.com/@getalby/lightning-tools/-/lightning-tools-9.0.0.tgz#5b323a1873ebd4b29f8df471f4b3b30301236685" + integrity sha512-YcYDE+u4H5Zbj2rkn7KhMEQdrvdga4AbNEVLdpj5cXVA4gGE1NZOg0LOKDCW0Z7vIqNdyfJhs3EcUBd9lw+JyQ== "@getalby/sdk@^8.0.3": version "8.0.3" From 21b827880ddcce84f8a629b40ec110cd67961024 Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Wed, 22 Jul 2026 15:34:42 +0700 Subject: [PATCH 2/2] feat: use lightning-tools 9.0.1 for complete payment info on interrupted payments Consumes the renamed Fetch402InterruptedError and its new amountSat / feesPaidMsat fields, so the paymentRecovery error output now includes the routing fee instead of omitting it, and the amount no longer needs to be re-derived from the invoice. Co-Authored-By: Claude Fable 5 --- package.json | 2 +- src/test/fetch-402-recovery.test.ts | 6 +++++- src/tools/lightning/fetch.ts | 22 +++++++++++----------- yarn.lock | 8 ++++---- 4 files changed, 21 insertions(+), 17 deletions(-) diff --git a/package.json b/package.json index 9dae76d..17ae5a0 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "node": ">=20" }, "dependencies": { - "@getalby/lightning-tools": "^9.0.0", + "@getalby/lightning-tools": "^9.0.1", "@getalby/sdk": "^8.0.3", "@lendasat/lendaswap-sdk-pure": "^0.2.38", "@noble/hashes": "^2.0.1", diff --git a/src/test/fetch-402-recovery.test.ts b/src/test/fetch-402-recovery.test.ts index 2ac7955..4063d21 100644 --- a/src/test/fetch-402-recovery.test.ts +++ b/src/test/fetch-402-recovery.test.ts @@ -27,7 +27,9 @@ function l402Response() { function makeClient(overrides: Record = {}): NWCClient { return { - payInvoice: vi.fn().mockResolvedValue({ preimage: PREIMAGE }), + payInvoice: vi + .fn() + .mockResolvedValue({ preimage: PREIMAGE, fees_paid: 3000 }), ...overrides, } as unknown as NWCClient; } @@ -94,6 +96,7 @@ describe("fetch 402 payment recovery", () => { payment: { paid: true, amountSat: 402, + feesPaidMsat: 3000, preimage: PREIMAGE, credentials: { header: "Authorization", @@ -127,6 +130,7 @@ describe("fetch 402 payment recovery", () => { payment: { paid: true, amountSat: 402, + feesPaidMsat: 3000, preimage: PREIMAGE, credentials: { header: "Authorization", diff --git a/src/tools/lightning/fetch.ts b/src/tools/lightning/fetch.ts index 72f8c50..09d7208 100644 --- a/src/tools/lightning/fetch.ts +++ b/src/tools/lightning/fetch.ts @@ -1,7 +1,6 @@ import { - Fetch402PaymentError, + Fetch402InterruptedError, fetch402 as fetch402Lib, - getInvoiceAmount, type PaymentInfo, type PaymentCredentials, type PendingPayment, @@ -71,7 +70,7 @@ export async function fetch402(client: NWCClient, params: Fetch402Params) { resume: params.resume, }); } catch (error) { - if (isFetch402PaymentError(error)) { + if (isFetch402InterruptedError(error)) { throw toRecoveryError(error); } throw error; @@ -102,7 +101,7 @@ export async function fetch402(client: NWCClient, params: Fetch402Params) { } /** - * Convert the library's Fetch402PaymentError into a structured CLI error. + * Convert the library's Fetch402InterruptedError into a structured CLI error. * * A payment was attempted but the flow failed before a response was obtained. * The CLI deliberately does NOT recover on its own (no wallet polling, no @@ -111,18 +110,19 @@ export async function fetch402(client: NWCClient, params: Fetch402Params) { * the error output carries everything the caller needs to recover without ever * paying the same invoice twice. */ -function toRecoveryError(error: Fetch402PaymentError): DetailedError { +function toRecoveryError(error: Fetch402InterruptedError): DetailedError { if (error.paid && error.credentials) { // The invoice was paid but the request after it failed (e.g. a network // error). The credential is already built - a retry must reuse it. The // error doesn't carry the library's PaymentInfo, so rebuild the same shape - // from what it does carry (routing fees were lost with the response). + // from what it does carry. return new DetailedError( `payment succeeded but the request failed: ${errorMessage(error.cause ?? error)}`, paidRecoveryDetails( { paid: true, - amountSat: getInvoiceAmount(error.invoice), + amountSat: error.amountSat, + feesPaidMsat: error.feesPaidMsat, preimage: error.preimage, credentials: error.credentials, }, @@ -173,12 +173,12 @@ function paidRecoveryDetails(payment: PaymentInfo, paymentHash?: string) { // The error normally arrives via instanceof, but match on `name` too so a // serialized/re-thrown copy (the class documents this) is still recognized. -function isFetch402PaymentError( +function isFetch402InterruptedError( error: unknown, -): error is Fetch402PaymentError { +): error is Fetch402InterruptedError { return ( - error instanceof Fetch402PaymentError || - (error instanceof Error && error.name === "Fetch402PaymentError") + error instanceof Fetch402InterruptedError || + (error instanceof Error && error.name === "Fetch402InterruptedError") ); } diff --git a/yarn.lock b/yarn.lock index a9855ad..711bbca 100644 --- a/yarn.lock +++ b/yarn.lock @@ -78,10 +78,10 @@ resolved "https://registry.yarnpkg.com/@getalby/lightning-tools/-/lightning-tools-8.1.1.tgz#4bf8be9f30dd76f7dd082262e862e63c16591fae" integrity sha512-gB+3fvTGz9bbl31JNgKMWqG4dZWgCxSB51fYn+F4tZ3x2h5oI/yHjvb6W9GBo2Zscr5gMP64gs0LlzKYoETlvQ== -"@getalby/lightning-tools@^9.0.0": - version "9.0.0" - resolved "https://registry.yarnpkg.com/@getalby/lightning-tools/-/lightning-tools-9.0.0.tgz#5b323a1873ebd4b29f8df471f4b3b30301236685" - integrity sha512-YcYDE+u4H5Zbj2rkn7KhMEQdrvdga4AbNEVLdpj5cXVA4gGE1NZOg0LOKDCW0Z7vIqNdyfJhs3EcUBd9lw+JyQ== +"@getalby/lightning-tools@^9.0.1": + version "9.0.1" + resolved "https://registry.yarnpkg.com/@getalby/lightning-tools/-/lightning-tools-9.0.1.tgz#8b73c001e4e3598684dab594a1eeed986d9b3d9d" + integrity sha512-pTCuJB13V8qq5pDyDSCB5NVxUaT5DmLHLg2jGINYRbU06PM+KXmHoJ3zN9C6Jk31aFil28vsdO6z7OwFhEybLA== "@getalby/sdk@^8.0.3": version "8.0.3"