Skip to content

Commit 48cbcea

Browse files
rolznzclaude
andauthored
feat: surface 402 payment recovery info so interrupted payments are never paid twice (#59)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 97055c4 commit 48cbcea

6 files changed

Lines changed: 425 additions & 20 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
"node": ">=20"
3737
},
3838
"dependencies": {
39-
"@getalby/lightning-tools": "^8.2.0",
39+
"@getalby/lightning-tools": "^9.0.1",
4040
"@getalby/sdk": "^8.0.3",
4141
"@lendasat/lendaswap-sdk-pure": "^0.2.38",
4242
"@noble/hashes": "^2.0.1",

src/commands/fetch.ts

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Command, InvalidArgumentError } from "commander";
2-
import { fetch402 } from "../tools/lightning/fetch.js";
2+
import { fetch402, type Fetch402Resume } from "../tools/lightning/fetch.js";
33
import { getClient, handleError, output } from "../utils.js";
44
import { classifyRail } from "../amount.js";
55

@@ -57,6 +57,36 @@ function parseCredentials(value: string): { header: string; value: string } {
5757
return { header, value: headerValue };
5858
}
5959

60+
/**
61+
* Parse and validate the `--resume` JSON: the `pendingPayment` object from a
62+
* previous fetch's payment-recovery error plus the preimage recovered via
63+
* lookup-invoice. `pendingPayment` is opaque to the CLI (the library builds
64+
* the credential from it), so only its shape is checked here.
65+
*/
66+
function parseResume(value: string): Fetch402Resume {
67+
let parsed: unknown;
68+
try {
69+
parsed = JSON.parse(value);
70+
} catch {
71+
throw new InvalidArgumentError(
72+
"--resume must be valid JSON (e.g. '{\"pendingPayment\":{...},\"preimage\":\"...\"}')",
73+
);
74+
}
75+
if (
76+
typeof parsed !== "object" ||
77+
parsed === null ||
78+
typeof (parsed as Record<string, unknown>).pendingPayment !== "object" ||
79+
(parsed as Record<string, unknown>).pendingPayment === null ||
80+
typeof (parsed as Record<string, unknown>).preimage !== "string" ||
81+
!(parsed as Record<string, unknown>).preimage
82+
) {
83+
throw new InvalidArgumentError(
84+
'--resume must be a JSON object with a "pendingPayment" object and a non-empty string "preimage"',
85+
);
86+
}
87+
return parsed as Fetch402Resume;
88+
}
89+
6090
export function registerFetch402Command(program: Command) {
6191
program
6292
.command("fetch")
@@ -92,13 +122,28 @@ export function registerFetch402Command(program: Command) {
92122
"authorized with it and never pays again — use it to authorize " +
93123
"follow-up requests (e.g. polling) without re-paying.",
94124
)
125+
.option(
126+
"--resume <json>",
127+
"Resume a payment interrupted before its preimage was known " +
128+
'(JSON: {"pendingPayment":{...},"preimage":"..."}). Use the ' +
129+
"pendingPayment from a previous fetch's paymentRecovery error " +
130+
"together with the preimage recovered via lookup-invoice. The " +
131+
"request is authorized with the rebuilt credential and never pays " +
132+
"again.",
133+
)
95134
.addHelpText(
96135
"after",
97136
"\nExample:\n" +
98137
' $ npx @getalby/cli fetch "https://example.com/api" --max-amount 1000 --currency BTC --unit sats --network lightning\n' +
99-
"\nA successful response includes a `payment` object with the fees paid and a\n" +
100-
"reusable `credentials` value. Reuse it to avoid paying again:\n" +
101-
' $ npx @getalby/cli fetch "https://example.com/api" --credentials \'{"header":"Authorization","value":"L402 ..."}\'\n',
138+
"\nA successful response includes a `payment` object with the amount paid\n" +
139+
"(amountSat), routing fees (feesPaidMsat, in millisatoshis) and a reusable\n" +
140+
"`credentials` value. Reuse it to avoid paying again:\n" +
141+
' $ npx @getalby/cli fetch "https://example.com/api" --credentials \'{"header":"Authorization","value":"L402 ..."}\'\n' +
142+
"\nIf a payment is interrupted (e.g. a wallet timeout), the error output includes\n" +
143+
"a `paymentRecovery` object with the payment hash and everything needed to\n" +
144+
"recover - follow its instructions (check lookup-invoice, then re-run with\n" +
145+
"--resume or --credentials) instead of retrying blindly, so the same invoice\n" +
146+
"is never paid twice.\n",
102147
)
103148
.action(async (url, options) => {
104149
await handleError(async () => {
@@ -124,6 +169,14 @@ export function registerFetch402Command(program: Command) {
124169
);
125170
}
126171

172+
if (options.credentials && options.resume) {
173+
throw new Error(
174+
"--credentials and --resume are mutually exclusive - use " +
175+
"--credentials to reuse a completed payment's credential, or " +
176+
"--resume to complete an interrupted one",
177+
);
178+
}
179+
127180
const client = await getClient(program);
128181
const result = await fetch402(client, {
129182
url: url,
@@ -136,6 +189,7 @@ export function registerFetch402Command(program: Command) {
136189
credentials: options.credentials
137190
? parseCredentials(options.credentials)
138191
: undefined,
192+
resume: options.resume ? parseResume(options.resume) : undefined,
139193
});
140194
output(result);
141195
});
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import { describe, test, expect, vi, afterEach } from "vitest";
2+
import { getPaymentHash } from "@getalby/lightning-tools/402";
3+
import { NWCClient } from "@getalby/sdk";
4+
import { fetch402 } from "../tools/lightning/fetch.js";
5+
import { DetailedError } from "../utils.js";
6+
7+
// Real, decodable invoice + macaroon (from the js-lightning-tools test suite)
8+
// so the library can derive the payment hash surfaced for reconciliation.
9+
const MACAROON =
10+
"AgEEbHNhdAJCAAAClGOZrh7C569Yc7UMk8merfnMdIviyXr1qscW7VgpChNl21LkZ8Jex5QiPp+E1VaabeJDuWmlrh/j583axFpNAAIXc2VydmljZXM9cmFuZG9tbnVtYmVyOjAAAiZyYW5kb21udW1iZXJfY2FwYWJpbGl0aZVzPWFkZCxzdWJ0cmFjdAAABiAvFpzXGyc+8d/I9nMKKvAYP8w7kUlhuxS0eFN2sqmqHQ==";
11+
const INVOICE =
12+
"lnbc4020n1p5m6028dq80q6rqvsnp4qt5w34u6kntf5lc50jj27rvs89sgrpcpj7s6vfts042gkhxx2j6swpp5g6tquvmswkv5xf0ru7ju2qvdrf83l2ewha3qzzt0a7vurs5q30rssp54kt5hfzjngjersx8fgt60feuu8e7vnat67f3ksr98twdj7z0m0ls9qyysgqcqzp2xqyz5vqrzjqdc22wfv6lyplagj37n9dmndkrzdz8rh3lxkewvvk6arkjpefats2rf47yqqwysqqcqqqqlgqqqqqqgqfqrzjq26922n6s5n5undqrf78rjjhgpcczafws45tx8237y7pzx3fg8ww8apyqqqqqqqqjyqqqqlgqqqqr4gq2q3z5pu33awfm98ac3ysdhy046xmen4zqval67tccu35x9mxgvl6w3wmq6y03ae7pme6qr20mp5gvuqntnu8yy7nlf6gyt9zshanj2zhgqe4xde3";
13+
const PREIMAGE =
14+
"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
15+
const PAYMENT_HASH = getPaymentHash(INVOICE);
16+
17+
const URL = "https://example.com/protected";
18+
19+
function l402Response() {
20+
return new Response("Payment Required", {
21+
status: 402,
22+
headers: {
23+
"www-authenticate": `L402 macaroon="${MACAROON}", invoice="${INVOICE}"`,
24+
},
25+
});
26+
}
27+
28+
function makeClient(overrides: Record<string, unknown> = {}): NWCClient {
29+
return {
30+
payInvoice: vi
31+
.fn()
32+
.mockResolvedValue({ preimage: PREIMAGE, fees_paid: 3000 }),
33+
...overrides,
34+
} as unknown as NWCClient;
35+
}
36+
37+
function authorizationHeader(fetchMock: ReturnType<typeof vi.fn>, call: number) {
38+
const init = fetchMock.mock.calls[call][1] as RequestInit;
39+
return (init.headers as Headers).get("Authorization");
40+
}
41+
42+
afterEach(() => {
43+
vi.unstubAllGlobals();
44+
});
45+
46+
describe("fetch 402 payment recovery", () => {
47+
test("surfaces recovery info when payInvoice fails (e.g. a reply timeout)", async () => {
48+
// The payment may still settle after the timeout, so the CLI must not
49+
// retry, poll, or guess - it exits with everything needed to reconcile.
50+
const client = makeClient({
51+
payInvoice: vi.fn().mockRejectedValue(new Error("reply timeout")),
52+
});
53+
const fetchMock = vi.fn().mockResolvedValueOnce(l402Response());
54+
vi.stubGlobal("fetch", fetchMock);
55+
56+
const error = await fetch402(client, { url: URL }).catch((e) => e);
57+
58+
expect(error).toBeInstanceOf(DetailedError);
59+
expect(error.message).toContain("reply timeout");
60+
expect(error.message).toContain("do NOT retry");
61+
expect(error.details?.paymentRecovery).toMatchObject({
62+
status: "unknown",
63+
paymentHash: PAYMENT_HASH,
64+
pendingPayment: {
65+
scheme: "l402",
66+
header: "Authorization",
67+
token: MACAROON,
68+
authScheme: "L402",
69+
},
70+
});
71+
expect(error.details?.paymentRecovery.instructions).toContain(
72+
`lookup-invoice --payment-hash ${PAYMENT_HASH}`,
73+
);
74+
expect(error.details?.paymentRecovery.instructions).toContain("--resume");
75+
// No retry happened: one payment attempt, one request.
76+
expect(client.payInvoice).toHaveBeenCalledTimes(1);
77+
expect(fetchMock).toHaveBeenCalledTimes(1);
78+
});
79+
80+
test("surfaces the credential when the request after payment fails", async () => {
81+
const client = makeClient();
82+
const fetchMock = vi
83+
.fn()
84+
.mockResolvedValueOnce(l402Response())
85+
.mockRejectedValueOnce(new Error("network down"));
86+
vi.stubGlobal("fetch", fetchMock);
87+
88+
const error = await fetch402(client, { url: URL }).catch((e) => e);
89+
90+
expect(error).toBeInstanceOf(DetailedError);
91+
expect(error.message).toContain("payment succeeded");
92+
expect(error.message).toContain("network down");
93+
expect(error.details?.paymentRecovery).toMatchObject({
94+
status: "paid",
95+
paymentHash: PAYMENT_HASH,
96+
payment: {
97+
paid: true,
98+
amountSat: 402,
99+
feesPaidMsat: 3000,
100+
preimage: PREIMAGE,
101+
credentials: {
102+
header: "Authorization",
103+
value: `L402 ${MACAROON}:${PREIMAGE}`,
104+
},
105+
},
106+
});
107+
expect(error.details?.paymentRecovery.instructions).toContain(
108+
"--credentials",
109+
);
110+
// The CLI itself does not retry - that is the caller's decision.
111+
expect(client.payInvoice).toHaveBeenCalledTimes(1);
112+
expect(fetchMock).toHaveBeenCalledTimes(2);
113+
});
114+
115+
test("surfaces the credential when the response after payment is non-OK", async () => {
116+
const client = makeClient();
117+
const fetchMock = vi
118+
.fn()
119+
.mockResolvedValueOnce(l402Response())
120+
.mockResolvedValueOnce(new Response("temporary outage", { status: 503 }));
121+
vi.stubGlobal("fetch", fetchMock);
122+
123+
const error = await fetch402(client, { url: URL }).catch((e) => e);
124+
125+
expect(error).toBeInstanceOf(DetailedError);
126+
expect(error.message).toContain("non-OK status: 503");
127+
// The library's full payment metadata is surfaced, not just the credential.
128+
expect(error.details?.paymentRecovery).toMatchObject({
129+
status: "paid",
130+
payment: {
131+
paid: true,
132+
amountSat: 402,
133+
feesPaidMsat: 3000,
134+
preimage: PREIMAGE,
135+
credentials: {
136+
header: "Authorization",
137+
value: `L402 ${MACAROON}:${PREIMAGE}`,
138+
},
139+
},
140+
});
141+
});
142+
143+
test("non-OK response without a payment carries no recovery details", async () => {
144+
const client = makeClient();
145+
const fetchMock = vi
146+
.fn()
147+
.mockResolvedValueOnce(new Response("not found", { status: 404 }));
148+
vi.stubGlobal("fetch", fetchMock);
149+
150+
const error = await fetch402(client, { url: URL }).catch((e) => e);
151+
152+
expect(error.message).toContain("non-OK status: 404");
153+
expect(error.details).toBeUndefined();
154+
expect(client.payInvoice).not.toHaveBeenCalled();
155+
});
156+
157+
test("resume sends the rebuilt credential without paying", async () => {
158+
const client = makeClient({
159+
payInvoice: vi.fn().mockRejectedValue(new Error("should not be called")),
160+
});
161+
const fetchMock = vi
162+
.fn()
163+
.mockResolvedValueOnce(new Response("paid content", { status: 200 }));
164+
vi.stubGlobal("fetch", fetchMock);
165+
166+
const result = await fetch402(client, {
167+
url: URL,
168+
resume: {
169+
pendingPayment: {
170+
scheme: "l402",
171+
header: "Authorization",
172+
token: MACAROON,
173+
authScheme: "L402",
174+
},
175+
preimage: PREIMAGE,
176+
},
177+
});
178+
179+
expect(result.content).toBe("paid content");
180+
expect(client.payInvoice).not.toHaveBeenCalled();
181+
expect(fetchMock).toHaveBeenCalledTimes(1);
182+
expect(authorizationHeader(fetchMock, 0)).toBe(
183+
`L402 ${MACAROON}:${PREIMAGE}`,
184+
);
185+
// This request itself did not pay; the payment happened earlier.
186+
expect(result.payment?.paid).toBe(false);
187+
expect(result.payment?.preimage).toBe(PREIMAGE);
188+
});
189+
190+
test("credentials send the stored credential without paying", async () => {
191+
const client = makeClient({
192+
payInvoice: vi.fn().mockRejectedValue(new Error("should not be called")),
193+
});
194+
const fetchMock = vi
195+
.fn()
196+
.mockResolvedValueOnce(new Response("paid content", { status: 200 }));
197+
vi.stubGlobal("fetch", fetchMock);
198+
199+
const result = await fetch402(client, {
200+
url: URL,
201+
credentials: {
202+
header: "Authorization",
203+
value: `L402 ${MACAROON}:${PREIMAGE}`,
204+
},
205+
});
206+
207+
expect(result.content).toBe("paid content");
208+
expect(client.payInvoice).not.toHaveBeenCalled();
209+
expect(authorizationHeader(fetchMock, 0)).toBe(
210+
`L402 ${MACAROON}:${PREIMAGE}`,
211+
);
212+
expect(result.payment?.paid).toBe(false);
213+
});
214+
});

0 commit comments

Comments
 (0)