diff --git a/README.md b/README.md index af457d1..cdd4c9f 100644 --- a/README.md +++ b/README.md @@ -409,3 +409,42 @@ routstrd/ ## License MIT + +### Provider Lightning payments + +Explicit Lightning funding is separate from the default Cashu automatic top-up flow: + +```sh +routstrd payments-lightning create https://provider.example/ 100 +# Pay the returned BOLT11 with an external Lightning wallet, then: +routstrd payments-lightning status https://provider.example/ +routstrd payments-lightning topup https://provider.example/ 100 +routstrd payments-lightning status https://provider.example/ +routstrd payments-lightning refund https://provider.example/ alice@example.com +``` + +`status` imports a paid key or refreshes the existing key's balance. It refuses to +replace a different stored key. `recover ` recovers settlement +from an invoice; `invoices` lists the recovery credentials saved under +`~/.routstrd/lightning-invoices/` (directory 0700, files 0600). Treat the invoice +IDs and BOLT11s as secrets: provider status/recovery can return spend-capable keys. +Save the output yourself if the journal write reports a warning. + +No automatic Lightning/NWC spending occurs. Invoice creation needs a provider +supporting `/v2/lightning`; a provider 404 is surfaced without retry/fallback. +A timeout is not proof that an invoice or refund failed remotely. Recheck invoice +status or recover by BOLT11 rather than paying another invoice blindly. + +Lightning refunds pay the entire available balance to the explicit address; +they do not deposit Cashu into the local wallet. Their returned amount describes +the gross balance, not necessarily the net payout after Lightning fees. The key +is retained even after success so provider claim results can be recovered. An +unresolved refund stays recoverable; it must not trigger key deletion. The +existing delete-key command now also retains credentials when refund fails. +Do not run refunds while requests/top-ups are active; the provider rejects active +reservations and owns refund reconciliation. + +For these paired development worktrees, the SDK dependency points to +`../../../routstr-sdk/.worktrees/lightning-payments`. Build that SDK first +(`bun run build`), then build this daemon (`bun run build`). Restore the normal +SDK dependency path/version when preparing a release from a standard checkout. diff --git a/bun.lock b/bun.lock index 915a6a4..7d5bf67 100644 --- a/bun.lock +++ b/bun.lock @@ -8,7 +8,7 @@ "@cashu/cashu-ts": "^4.3.0", "@cashu/coco-core": "^1.0.1", "@cashu/coco-sqlite-bun": "^1.0.1", - "@routstr/sdk": "^0.4.8", + "@routstr/sdk": "github:Routstr/routstr-sdk#feat/lightning-payments", "@scure/bip39": "^2.2.0", "applesauce-core": "^5.1.0", "applesauce-relay": "^5.1.0", @@ -98,7 +98,7 @@ "@panva/hpke-noble": ["@panva/hpke-noble@1.1.3", "", { "dependencies": { "@noble/ciphers": "^2.2.0", "@noble/curves": "^2.2.0", "@noble/hashes": "^2.2.0", "@noble/post-quantum": "^0.6.1" }, "peerDependencies": { "hpke": "^1.0.0" } }, "sha512-zPG7MR9x7QE7+KdYsKBO9H0vp3AdYt9/4AT3ab7T7W6SL0fdRqhgNRu8q4OGTJNLeKpdbkkRb6LhBDaA9+9xWQ=="], - "@routstr/sdk": ["@routstr/sdk@0.4.8", "", { "dependencies": { "@cashu/cashu-ts": "^4.11.0", "applesauce-core": "^5.1.0", "applesauce-relay": "^5.1.0", "applesauce-sqlite": "^6.0.0", "ehbp": "^0.3.2", "rxjs": "^7.8.1", "tinfoil": "^1.2.1", "zustand": "^5.0.5" }, "optionalDependencies": { "better-sqlite3": "^12.10.0" }, "peerDependencies": { "typescript": ">=5.0.0" } }, "sha512-ThcV9OM4vang6V4L61bIwQCsP3yvWWoHv3MPmgLeR0PUbot3UiWl7tmRAGe66CzGDy9UUd1z/ACSjUtmpb6GfQ=="], + "@routstr/sdk": ["@routstr/sdk@github:Routstr/routstr-sdk#a641e16", { "dependencies": { "@cashu/cashu-ts": "^4.11.0", "applesauce-core": "^5.1.0", "applesauce-relay": "^5.1.0", "applesauce-sqlite": "^6.0.0", "ehbp": "^0.3.2", "rxjs": "^7.8.1", "tinfoil": "^1.2.1", "zustand": "^5.0.5" }, "optionalDependencies": { "better-sqlite3": "^12.10.0" }, "peerDependencies": { "typescript": ">=5.0.0" } }, "Routstr-routstr-sdk-a641e16", "sha512-ScCbi9+XRYzW0M//sjVMh/BBPEYrLsjbKhNWzYHV70zuPpiToY2GGGHNNhC0Mi730B5SXcq71vlYE7lMtEdpxw=="], "@scure/base": ["@scure/base@2.2.0", "", {}, "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg=="], diff --git a/package.json b/package.json index 0a7948b..9b17d0a 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "@cashu/cashu-ts": "^4.3.0", "@cashu/coco-core": "^1.0.1", "@cashu/coco-sqlite-bun": "^1.0.1", - "@routstr/sdk": "^0.4.8", + "@routstr/sdk": "github:Routstr/routstr-sdk#feat/lightning-payments", "@scure/bip39": "^2.2.0", "applesauce-core": "^5.1.0", "applesauce-relay": "^5.1.0", diff --git a/src/cli.ts b/src/cli.ts index c2da59e..4955d8e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -830,6 +830,30 @@ program } }); +// Explicit provider Lightning operations. Payment is made externally; no automatic NWC spending. +const lightningPayments = program.command("payments-lightning").description("Fund provider keys or refund them over Lightning"); +for (const purpose of ["create", "topup"] as const) { + lightningPayments.command(`${purpose} `) + .description("Create a provider Lightning invoice (pay the returned BOLT11 externally)") + .action(async (baseUrl: string, amountSats: string) => { + await handleDaemonCommand(`/payments/lightning/${purpose}`, { method: "POST", body: { baseUrl, amountSats: Number(amountSats) } }); + }); +} +lightningPayments.command("status ").description("Check settlement and store/refresh the paid provider key") + .action(async (baseUrl: string, invoiceId: string) => { + await handleDaemonCommand("/payments/lightning/status", { method: "POST", body: { baseUrl, invoiceId } }); + }); +lightningPayments.command("recover ").description("Recover a paid invoice and store/refresh its provider key") + .action(async (baseUrl: string, bolt11: string) => { + await handleDaemonCommand("/payments/lightning/recover", { method: "POST", body: { baseUrl, bolt11 } }); + }); +lightningPayments.command("invoices").description("List saved provider invoices (contains sensitive recovery credentials)") + .action(async () => { await handleDaemonCommand("/payments/lightning/invoices", { method: "POST", body: {} }); }); +lightningPayments.command("refund ").description("Refund all available provider balance to a Lightning address; retain key for recovery") + .action(async (baseUrl: string, lightningAddress: string) => { + await handleDaemonCommand("/payments/lightning/refund", { method: "POST", body: { baseUrl, lightningAddress } }); + }); + // Balance - get wallet and API key balances program .command("balance") diff --git a/src/daemon/http/index.ts b/src/daemon/http/index.ts index 503d754..1fe9df2 100644 --- a/src/daemon/http/index.ts +++ b/src/daemon/http/index.ts @@ -1,3 +1,4 @@ +import { executeLightningOperation } from "./lightning-payments"; import { randomBytes } from "crypto"; import { type IncomingMessage, type ServerResponse } from "http"; import { Readable } from "stream"; @@ -5,6 +6,7 @@ import { routeRequests, InsufficientBalanceError, ProviderManager, + LightningPaymentError, } from "@routstr/sdk"; import type { UsageTrackingDriver, SdkLogger } from "@routstr/sdk"; import type { RequestResponseLogSink } from "../request-response-log-sink"; @@ -225,6 +227,14 @@ function respondWithError( sendJson(res, error.status, { error: error.message }); return; } + if (error instanceof LightningPaymentError) { + sendJson(res, error.status, { error: error.message, detail: error.detail }); + return; + } + if (error instanceof CocodHttpError) { + sendJson(res, error.status, { error: error.message }); + return; + } sendJson(res, fallbackStatus, { error: toErrorMessage(error) }); } @@ -476,6 +486,17 @@ export function createDaemonRequestHandler(deps: { url.pathname = canonicalPath; } + const lightningAction = /^\/payments\/lightning\/(create|topup|status|recover|refund|invoices)$/.exec(url.pathname)?.[1]; + if (req.method === "POST" && lightningAction) { + try { + const output = await executeLightningOperation(lightningAction, await readJsonBody(req), deps.storageAdapter); + sendJson(res, 200, { output }); + } catch (error) { + respondWithError(res, error); + } + return; + } + if (req.method === "GET" && url.pathname === "/health") { sendJson(res, 200, { ok: true }); return; @@ -1267,9 +1288,10 @@ export function createDaemonRequestHandler(deps: { refundMessage = "No mint available to refund to"; } - // refundApiKey removes the key on success; remove manually on failure. + // A failed/ambiguous refund must retain the credential for recovery. if (!refunded) { - deps.storageAdapter.removeApiKey(existing.baseUrl); + sendJson(res, 409, { output: { baseUrl: existing.baseUrl, removed: false, refunded: false, refundMessage }, error: refundMessage || "Refund failed; API key retained" }); + return; } sendJson(res, 200, { diff --git a/src/daemon/http/lightning-payments.test.ts b/src/daemon/http/lightning-payments.test.ts new file mode 100644 index 0000000..587cb2a --- /dev/null +++ b/src/daemon/http/lightning-payments.test.ts @@ -0,0 +1,126 @@ +import { EventEmitter } from "events"; +import { createDaemonRequestHandler } from "./index"; +import { afterEach, describe, expect, it } from "bun:test"; +import { mkdtempSync, rmSync, statSync, readdirSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { LightningPayments, type StorageAdapter } from "@routstr/sdk"; +import { executeLightningOperation, LightningInvoiceJournal } from "./lightning-payments"; + +const dirs: string[] = []; +const servers: ReturnType[] = []; +afterEach(() => { for (const server of servers.splice(0)) server.stop(true); for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); }); +function setup() { + const dir = mkdtempSync(join(tmpdir(), "routstr-lightning-test-")); + dirs.push(dir); + const journal = new LightningInvoiceJournal(join(dir, "invoices")); + let key: string | undefined; + let balance = 0; + const storage = { + getApiKey: () => key ? { key, balance } : null, + setApiKey: (_url: string, value: string) => { key = value; }, + updateApiKeyBalance: (_url: string, value: number) => { balance = value; }, + } as unknown as StorageAdapter; + return { journal, storage, dir }; +} + +describe("paired SDK/provider Lightning lifecycle", () => { + it("journals invoices, imports only paid keys, tops up and refunds without losing the key", async () => { + const { journal, storage, dir } = setup(); + let paid = false; + let providerBalance = 100000; + let purpose = "create"; + const server = Bun.serve({ port: 0, fetch: async (req) => { + const path = new URL(req.url).pathname; + if (path === "/v2/lightning/invoice") { + const body = await req.json() as any; + purpose = body.purpose; + if (purpose === "topup") expect(req.headers.get("authorization")).toBe("Bearer sk-test"); + else expect(req.headers.has("authorization")).toBe(false); + return Response.json({ invoice_id: purpose, bolt11: `lnbc-${purpose}`, amount_sats: 100, payment_hash: "quote", expires_at: 1000 }); + } + if (path.includes("/status") || path === "/v2/lightning/recover") return Response.json({ status: paid ? "paid" : "pending", api_key: paid ? "sk-test" : null, amount_sats: 100, created_at: 1, expires_at: 1000 }); + if (path === "/v1/wallet/info") return Response.json({ balance: providerBalance, reserved: 0 }); + if (path === "/v1/wallet/refund") { + expect((await req.json() as any).lightning_address).toBe("alice@example.com"); + providerBalance = 5000; // A concurrent top-up: the client must refresh, not blindly zero balance. + return Response.json({ refund_id: "refund-1", status: "paid", recipient: "alice@example.com", sats: "100" }); + } + return new Response(null, { status: 404 }); + } }); + servers.push(server); + const baseUrl = `http://localhost:${server.port}/`; + const payments = new LightningPayments(); + const run = (action: string, body: Record) => executeLightningOperation(action, { baseUrl, ...body }, storage, payments, journal); + await run("create", { amountSats: 100 }); + expect(journal.list()).toHaveLength(1); + const path = join(dir, "invoices", readdirSync(join(dir, "invoices"))[0]!); + expect(statSync(path).mode & 0o777).toBe(0o600); + expect(statSync(join(dir, "invoices")).mode & 0o777).toBe(0o700); + await run("status", { invoiceId: "create" }); + expect(storage.getApiKey(baseUrl)).toBeNull(); + paid = true; + await run("status", { invoiceId: "create" }); + expect(storage.getApiKey(baseUrl)?.key).toBe("sk-test"); + expect(storage.getApiKey(baseUrl)?.balance).toBe(100); + await expect(run("create", { amountSats: 100 })).rejects.toThrow("already"); + await run("topup", { amountSats: 100 }); + providerBalance = 200000; + await run("recover", { bolt11: "lnbc-topup" }); + expect(storage.getApiKey(baseUrl)?.balance).toBe(200); + await run("refund", { lightningAddress: "alice@example.com" }); + expect(storage.getApiKey(baseUrl)?.key).toBe("sk-test"); + expect(storage.getApiKey(baseUrl)?.balance).toBe(5); + expect(new LightningInvoiceJournal(join(dir, "invoices")).list()).toHaveLength(2); + }); + + it("retains the stored key and snapshot after an ambiguous refund", async () => { + const { journal, storage } = setup(); + const baseUrl = "https://provider.example/"; + storage.setApiKey(baseUrl, "sk-test"); + storage.updateApiKeyBalance(baseUrl, 100, 0); + const payments = new LightningPayments((async () => Response.json({ detail: { error: { code: "refund_unresolved" } } }, { status: 409 })) as unknown as typeof fetch); + await expect(executeLightningOperation("refund", { baseUrl, lightningAddress: "alice@example.com" }, storage, payments, journal)).rejects.toMatchObject({ status: 409 }); + expect(storage.getApiKey(baseUrl)?.balance).toBe(100); + expect(storage.getApiKey(baseUrl)?.key).toBe("sk-test"); + }); +}); + + +describe("HTTP refund recovery", () => { + it("retains credentials when delete-key refund fails", async () => { + let removed = false; + const handler = createDaemonRequestHandler({ + storageAdapter: { getApiKey: () => ({ baseUrl: "https://provider.example/", key: "sk-test" }), removeApiKey: () => { removed = true; } }, + refundClient: { getBalanceManager: () => ({ refundApiKey: async () => ({ success: false, message: "Refund unresolved" }) }) }, + } as any); + const req = new EventEmitter() as any; + req.method = "DELETE"; + req.url = "/keys/api/delete?baseUrl=https%3A%2F%2Fprovider.example%2F&mintUrl=https%3A%2F%2Fmint.example"; + req.headers = { host: "localhost" }; + let status = 0; + let body = ""; + const res = { writeHead: (code: number) => { status = code; }, end: (chunk: string) => { body = chunk; } } as any; + await handler(req, res); + expect(status).toBe(409); + expect(removed).toBe(false); + expect(JSON.parse(body).output.removed).toBe(false); + }); + + it("routes journal listing through the daemon HTTP handler", async () => { + const handler = createDaemonRequestHandler({} as any); + const req = new EventEmitter() as any; + req.method = "POST"; + req.url = "/payments/lightning/invoices"; + req.headers = { host: "localhost" }; + let status = 0; + let body = ""; + const res = { writeHead: (code: number) => { status = code; }, end: (chunk: string) => { body = chunk; } } as any; + const request = handler(req, res); + req.emit("data", "{}"); + req.emit("end"); + await request; + expect(status).toBe(200); + expect(Array.isArray(JSON.parse(body).output)).toBe(true); + }); +}); diff --git a/src/daemon/http/lightning-payments.ts b/src/daemon/http/lightning-payments.ts new file mode 100644 index 0000000..7d9661f --- /dev/null +++ b/src/daemon/http/lightning-payments.ts @@ -0,0 +1,89 @@ +import { mkdirSync, readFileSync, writeFileSync, renameSync, readdirSync, chmodSync } from "fs"; +import { createHash, randomUUID } from "crypto"; +import { join } from "path"; +import { LightningPayments, type StorageAdapter, type LightningInvoice } from "@routstr/sdk"; +import { CONFIG_DIR } from "../../utils/config"; + +/** Invoice IDs/BOLT11s are bearer recovery credentials. Store with wallet-level permissions. */ +export class LightningInvoiceJournal { + constructor(private readonly directory = join(CONFIG_DIR, "lightning-invoices")) {} + + save(baseUrl: string, purpose: string, invoice: LightningInvoice): void { + mkdirSync(this.directory, { recursive: true, mode: 0o700 }); + chmodSync(this.directory, 0o700); + const path = this.path(baseUrl, invoice.invoice_id); + const temp = `${path}.${randomUUID()}.tmp`; + writeFileSync(temp, JSON.stringify({ baseUrl, purpose, ...invoice }), { mode: 0o600 }); + renameSync(temp, path); + } + + list(): unknown[] { + // Loaded only by an explicit operator command; never log recovery credentials. + try { + return readdirSync(this.directory).filter((name) => name.endsWith(".json")) + .map((name) => JSON.parse(readFileSync(join(this.directory, name), "utf8"))); + } catch (error: any) { + if (error.code === "ENOENT") return []; + throw error; + } + } + + private path(baseUrl: string, invoiceId: string): string { + return join(this.directory, `${createHash("sha256").update(`${baseUrl}:${invoiceId}`).digest("hex")}.json`); + } +} + +function stringField(body: Record, field: string): string { + if (typeof body[field] !== "string" || !body[field].trim()) throw new Error(`${field} is required`); + return body[field]; +} + +export async function executeLightningOperation( + action: string, + body: Record, + storage: StorageAdapter, + payments = new LightningPayments(), + journal = new LightningInvoiceJournal(), +): Promise { + if (action === "invoices") return journal.list(); + const url = new URL(stringField(body, "baseUrl")); + if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw new Error("Invalid provider URL"); + const baseUrl = `${url.href.replace(/\/$/, "")}/`; + if (action === "create" || action === "topup") { + if (action === "create" && storage.getApiKey(baseUrl)) throw new Error("Provider already has a stored key; use topup"); + const key = action === "topup" ? storage.getApiKey(baseUrl)?.key : undefined; + if (action === "topup" && !key) throw new Error("No stored provider key"); + const invoice = await payments.createInvoice({ baseUrl, purpose: action, amountSats: body.amountSats as number, apiKey: key }); + try { + journal.save(baseUrl, action, invoice); + } catch { + // The remote quote exists even if local persistence fails. Return its recovery credentials. + return { ...invoice, warning: "Invoice journal write failed; save this invoice ID and BOLT11 before paying." }; + } + return invoice; + } + if (action === "status" || action === "recover") { + const status = action === "status" + ? await payments.getInvoiceStatus(baseUrl, stringField(body, "invoiceId")) + : await payments.recoverInvoice(baseUrl, stringField(body, "bolt11")); + if (status.status === "paid") await payments.acceptPaidInvoice(baseUrl, status, storage); + return status; + } + if (action === "refund") { + const key = storage.getApiKey(baseUrl)?.key; + if (!key) throw new Error("No stored provider key"); + const refund = await payments.refundToLightning(baseUrl, key, stringField(body, "lightningAddress")); + // A concurrent top-up may have added balance since the refund began. Do not blindly zero it. + if (refund.status === "paid" && storage.getApiKey(baseUrl)?.key === key) { + try { + await payments.refreshKeyBalance(baseUrl, key, storage); + } catch { + // The payout has succeeded even if the follow-up balance request fails. + return { ...refund, warning: "Refund paid; local balance refresh failed. Key retained for recovery." }; + } + } + // Keep the key even on success so repeated requests can recover the provider's refund claim. + return refund; + } + throw new Error("Unknown Lightning operation"); +}