feat(payments): expose provider Lightning lifecycle commands

This commit is contained in:
redshift
2026-10-04 20:50:15 +08:00
parent 5eb2ee68c7
commit 1fe8ecb9da
7 changed files with 305 additions and 5 deletions
+39
View File
@@ -409,3 +409,42 @@ routstrd/
## License ## License
MIT 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/ <invoice-id>
routstrd payments-lightning topup https://provider.example/ 100
routstrd payments-lightning status https://provider.example/ <topup-invoice-id>
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 <provider> <bolt11>` 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.
+2 -2
View File
@@ -8,7 +8,7 @@
"@cashu/cashu-ts": "^4.3.0", "@cashu/cashu-ts": "^4.3.0",
"@cashu/coco-core": "^1.0.1", "@cashu/coco-core": "^1.0.1",
"@cashu/coco-sqlite-bun": "^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", "@scure/bip39": "^2.2.0",
"applesauce-core": "^5.1.0", "applesauce-core": "^5.1.0",
"applesauce-relay": "^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=="], "@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=="], "@scure/base": ["@scure/base@2.2.0", "", {}, "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg=="],
+1 -1
View File
@@ -39,7 +39,7 @@
"@cashu/cashu-ts": "^4.3.0", "@cashu/cashu-ts": "^4.3.0",
"@cashu/coco-core": "^1.0.1", "@cashu/coco-core": "^1.0.1",
"@cashu/coco-sqlite-bun": "^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", "@scure/bip39": "^2.2.0",
"applesauce-core": "^5.1.0", "applesauce-core": "^5.1.0",
"applesauce-relay": "^5.1.0", "applesauce-relay": "^5.1.0",
+24
View File
@@ -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} <baseUrl> <amountSats>`)
.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 <baseUrl> <invoiceId>").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 <baseUrl> <bolt11>").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 <baseUrl> <lightningAddress>").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 // Balance - get wallet and API key balances
program program
.command("balance") .command("balance")
+24 -2
View File
@@ -1,3 +1,4 @@
import { executeLightningOperation } from "./lightning-payments";
import { randomBytes } from "crypto"; import { randomBytes } from "crypto";
import { type IncomingMessage, type ServerResponse } from "http"; import { type IncomingMessage, type ServerResponse } from "http";
import { Readable } from "stream"; import { Readable } from "stream";
@@ -5,6 +6,7 @@ import {
routeRequests, routeRequests,
InsufficientBalanceError, InsufficientBalanceError,
ProviderManager, ProviderManager,
LightningPaymentError,
} from "@routstr/sdk"; } from "@routstr/sdk";
import type { UsageTrackingDriver, SdkLogger } from "@routstr/sdk"; import type { UsageTrackingDriver, SdkLogger } from "@routstr/sdk";
import type { RequestResponseLogSink } from "../request-response-log-sink"; import type { RequestResponseLogSink } from "../request-response-log-sink";
@@ -225,6 +227,14 @@ function respondWithError(
sendJson(res, error.status, { error: error.message }); sendJson(res, error.status, { error: error.message });
return; 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) }); sendJson(res, fallbackStatus, { error: toErrorMessage(error) });
} }
@@ -476,6 +486,17 @@ export function createDaemonRequestHandler(deps: {
url.pathname = canonicalPath; 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") { if (req.method === "GET" && url.pathname === "/health") {
sendJson(res, 200, { ok: true }); sendJson(res, 200, { ok: true });
return; return;
@@ -1267,9 +1288,10 @@ export function createDaemonRequestHandler(deps: {
refundMessage = "No mint available to refund to"; 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) { 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, { sendJson(res, 200, {
+126
View File
@@ -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<typeof Bun.serve>[] = [];
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<string, unknown>) => 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);
});
});
+89
View File
@@ -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<string, unknown>, 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<string, unknown>,
storage: StorageAdapter,
payments = new LightningPayments(),
journal = new LightningInvoiceJournal(),
): Promise<unknown> {
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");
}