mirror of
https://github.com/Routstr/routstrd.git
synced 2026-10-05 20:38:22 +00:00
feat(payments): expose provider Lightning lifecycle commands
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user