// Thin wrapper around BTCPay's Greenfield API for the Recap credit- // purchase flow. Three things this module owns: // // 1. createInvoice() — POST /api/v1/stores/{storeId}/invoices to // mint an invoice priced in sats. Embeds install_id + credits // in invoice metadata so the webhook later knows which install // and how many credits to grant. // // 2. getInvoice() — GET /api/v1/stores/{storeId}/invoices/{id} for // polling settlement state from the Recap UI side. // // 3. validateWebhookSignature() — HMAC-SHA256 check on the // BTCPay-Sig header so we only trust webhook posts that came // from BTCPay with the operator-configured secret. // // Operator config (set via StartOS "Set BTCPay Connection" action): // relay_btcpay_base_url e.g. https://btcpay.keysat.xyz // relay_btcpay_store_id uuid-shaped store id from BTCPay // relay_btcpay_api_key Greenfield token (scope: btcpay.store.canCreateInvoice + canViewInvoices) // relay_btcpay_webhook_secret shared secret for webhook HMAC import crypto from "crypto"; // All sats here are quoted in the BTCPay store's display currency // (which the operator set to "SATS"). BTCPay accepts the integer // amount + currency code "SATS" and prices the invoice in sats // directly — no fiat conversion involved. const CURRENCY = "SATS"; export class BtcPayError extends Error { constructor(status, message, body) { super(message); this.name = "BtcPayError"; this.status = status; this.body = body; } } // Create a new invoice for `sats` SATS, tagging it with the install // + credit-package metadata so the webhook (later) knows what to // grant. Returns the raw Greenfield invoice object — caller picks // out `id`, `checkoutLink`, `expirationTime`, etc. export async function createInvoice({ baseURL, storeId, apiKey, sats, credits, installId, // Optional license fingerprint captured at buy time. When the buyer // has an active Pro/Max license, the credit-grant should land on // their license-keyed credit pool (which is shared across every // install that uses the license) rather than the install-keyed pool. // Stashed in invoice metadata so the webhook handler can route the // credit to the right pool even after a restart. licenseFingerprint = null, packageLabel = "", redirectURL = null, redirectAutomatically = false, }) { assertConfigured({ baseURL, storeId, apiKey }); const body = { amount: String(sats), currency: CURRENCY, metadata: { // Top-level metadata fields are surfaced in webhook events and // are what the webhook handler reads to credit the install. install_id: installId, license_fingerprint: licenseFingerprint || null, credits, package_label: packageLabel, product: "recap_credits", }, checkout: { // Buyers should be able to leave the checkout page back to // Recap after a successful payment. The Recap UI is also // polling so the modal will update even if they don't follow // this redirect; this is a courtesy that closes the loop // when the buyer left the BTCPay tab in the foreground. redirectURL: redirectURL || undefined, // Auto-redirect once the invoice settles. Without this the // buyer has to click "Return to merchant" themselves; with it // the checkout page navigates back to the redirectURL as // soon as payment confirms. redirectAutomatically: redirectAutomatically === true, // Speed up small-amount flows by enabling Lightning. Operator // has Lightning configured on this store per their setup // message; we just nudge the checkout to prefer it. defaultPaymentMethod: "BTC-LightningLike", }, }; return postInvoice({ baseURL, storeId, apiKey, body }); } // Create a BTCPay invoice for a self-serve Pro/Max subscription period. The // metadata (product:"recap_tier_subscription", user_id, tier, period_days) // is what the webhook reads to call extendUserTier on settlement. export async function createTierInvoice({ baseURL, storeId, apiKey, sats, userId, tier, periodDays, redirectURL = null, redirectAutomatically = false, }) { assertConfigured({ baseURL, storeId, apiKey }); const body = { amount: String(sats), currency: CURRENCY, metadata: { product: "recap_tier_subscription", user_id: userId, tier, period_days: periodDays, }, checkout: { redirectURL: redirectURL || undefined, redirectAutomatically: redirectAutomatically === true, defaultPaymentMethod: "BTC-LightningLike", }, }; return postInvoice({ baseURL, storeId, apiKey, body }); } // Shared invoice POST. Both createInvoice (credits) + createTierInvoice // (subscriptions) build their own body and hand it here. async function postInvoice({ baseURL, storeId, apiKey, body }) { const url = `${trimSlash(baseURL)}/api/v1/stores/${encodeURIComponent(storeId)}/invoices`; const res = await fetch(url, { method: "POST", headers: { Authorization: `token ${apiKey}`, "Content-Type": "application/json", }, body: JSON.stringify(body), signal: AbortSignal.timeout(10_000), }); const text = await res.text(); let parsed = null; try { parsed = text ? JSON.parse(text) : null; } catch {} if (!res.ok) { throw new BtcPayError( res.status, `BTCPay createInvoice ${res.status}: ${text?.slice(0, 300) || res.statusText}`, parsed ); } return parsed; } export async function getInvoice({ baseURL, storeId, apiKey, invoiceId }) { assertConfigured({ baseURL, storeId, apiKey }); const url = `${trimSlash(baseURL)}/api/v1/stores/${encodeURIComponent(storeId)}/invoices/${encodeURIComponent(invoiceId)}`; const res = await fetch(url, { headers: { Authorization: `token ${apiKey}` }, signal: AbortSignal.timeout(10_000), }); const text = await res.text(); let parsed = null; try { parsed = text ? JSON.parse(text) : null; } catch {} if (!res.ok) { throw new BtcPayError( res.status, `BTCPay getInvoice ${res.status}: ${text?.slice(0, 300) || res.statusText}`, parsed ); } return parsed; } // Fetch the per-payment-method breakdown for an invoice. This is the // endpoint that returns the actual BOLT11 invoice string + Lightning // payment link, which we need to render an inline QR on the Recap // side (Phase 1 of the inline-payment migration). Caller decides // which paymentMethod to extract from the array — for the credits // flow we only care about BTC-LightningNetwork. // // Note: BTCPay generates the Lightning invoice asynchronously on // some store configurations, so there's a small window after // createInvoice() where the LN destination may not yet be populated. // Caller can retry once with a short backoff if needed, or fall // back to the checkout_url for the buyer to use BTCPay's hosted page. export async function getInvoicePaymentMethods({ baseURL, storeId, apiKey, invoiceId, }) { assertConfigured({ baseURL, storeId, apiKey }); const url = `${trimSlash(baseURL)}/api/v1/stores/${encodeURIComponent(storeId)}/invoices/${encodeURIComponent(invoiceId)}/payment-methods`; const res = await fetch(url, { headers: { Authorization: `token ${apiKey}` }, signal: AbortSignal.timeout(10_000), }); const text = await res.text(); let parsed = null; try { parsed = text ? JSON.parse(text) : null; } catch {} if (!res.ok) { throw new BtcPayError( res.status, `BTCPay getInvoicePaymentMethods ${res.status}: ${text?.slice(0, 300) || res.statusText}`, parsed ); } return Array.isArray(parsed) ? parsed : []; } // Helper: pick the Lightning BOLT11 + payment link out of a // payment-methods array. Returns { bolt11, paymentLink } or null // if no usable LN method exists (e.g., store doesn't have LN // configured, or BTCPay hasn't generated it yet). The shape varies // across BTCPay versions, particularly between 1.x and 2.x: // • BTCPay 1.x uses `paymentMethod` with values like // "BTC-LightningNetwork" or "BTC_LightningLike" // • BTCPay 2.x uses `paymentMethodId` with values like // "BTC-LN" (BOLT11-direct) or "BTC-LNURL" (LNURL-pay) // We accept either field name. Among LN-ish entries we prefer // BOLT11-direct over LNURL because every Lightning wallet supports // BOLT11 but LNURL needs an LNURL-pay capable wallet (less common, // and BTCPay's LNURL entry sometimes has a null destination until // the buyer initiates a scan, which we can't render to a QR). export function pickLightningFromPaymentMethods(methods) { if (!Array.isArray(methods)) return null; const candidates = methods.filter((m) => { const id = String(m?.paymentMethodId || m?.paymentMethod || ""); return ( id === "BTC-LN" || id === "BTC-LightningNetwork" || id === "BTC_LightningLike" || /lightning/i.test(id) || /-LN$/i.test(id) || /-LNURL$/i.test(id) ); }); // Prefer BOLT11-direct over LNURL. Stable sort: LNURL last, // everything else preserves original order. candidates.sort((a, b) => { const aId = String(a?.paymentMethodId || a?.paymentMethod || ""); const bId = String(b?.paymentMethodId || b?.paymentMethod || ""); const aLnurl = /lnurl/i.test(aId) ? 1 : 0; const bLnurl = /lnurl/i.test(bId) ? 1 : 0; return aLnurl - bLnurl; }); for (const m of candidates) { const destination = String(m?.destination || "").trim(); if (!destination) continue; // skip empty (BTC-LNURL before scan) if (!/^ln[a-z0-9]+$/i.test(destination)) continue; // must look like a bolt11 const paymentLink = typeof m.paymentLink === "string" && m.paymentLink ? m.paymentLink : `lightning:${destination}`; return { bolt11: destination, paymentLink }; } return null; } // Webhook signature validation. BTCPay sends `BTCPay-Sig: sha256=` // where is HMAC-SHA256(rawBody, webhookSecret). We re-compute // and constant-time-compare. Returns true on match, false otherwise. // // Caller is responsible for passing the RAW request body bytes (not // the parsed JSON object) — Express's body-parser doesn't preserve // the exact bytes so we register a raw-body capture for the webhook // route specifically. See routes/credits.js. export function validateWebhookSignature({ rawBody, signatureHeader, secret }) { if (!secret) return false; if (typeof signatureHeader !== "string") return false; const m = signatureHeader.match(/^sha256\s*=\s*([0-9a-f]{64})$/i); if (!m) return false; const expected = crypto .createHmac("sha256", secret) .update(rawBody) .digest("hex"); const provided = m[1].toLowerCase(); if (provided.length !== expected.length) return false; try { return crypto.timingSafeEqual( Buffer.from(provided, "hex"), Buffer.from(expected, "hex") ); } catch { return false; } } function assertConfigured({ baseURL, storeId, apiKey }) { if (!baseURL || !storeId || !apiKey) { throw new Error( "BTCPay is not configured — set base URL, store ID, and API key via the StartOS 'Set BTCPay Connection' action" ); } } function trimSlash(s) { return (s || "").replace(/\/$/, ""); }