96 lines
3.5 KiB
TypeScript
96 lines
3.5 KiB
TypeScript
/**
|
|
* BTCPay Server webhook — auto-credit VOID when an invoice settles.
|
|
*
|
|
* Configure in BTCPay: Store → Settings → Webhooks → Add Webhook
|
|
* URL: http://127.0.0.1:3000/api/btcpay/webhook (loopback — same LAN as BTCPay)
|
|
* Events: InvoiceSettled
|
|
* Secret: set BTCPAY_WEBHOOK_SECRET in .env.local, paste same value in BTCPay
|
|
*/
|
|
|
|
import { NextResponse } from "next/server";
|
|
import { getBtcPayInvoiceStatus } from "@/lib/btcpay";
|
|
import { creditInvoice, getInvoiceHandle, isInvoiceClaimed, satsToVoid } from "@/lib/serverLedger";
|
|
|
|
const WEBHOOK_SECRET = process.env.BTCPAY_WEBHOOK_SECRET ?? "";
|
|
|
|
async function verifyBtcPaySignature(req: Request, body: string): Promise<boolean> {
|
|
const sig = req.headers.get("btcpay-sig") ?? "";
|
|
if (!WEBHOOK_SECRET || !sig) return !WEBHOOK_SECRET; // if no secret configured, skip verification
|
|
try {
|
|
const key = await crypto.subtle.importKey(
|
|
"raw",
|
|
new TextEncoder().encode(WEBHOOK_SECRET),
|
|
{ name: "HMAC", hash: "SHA-256" },
|
|
false,
|
|
["sign"],
|
|
);
|
|
const expected = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(body));
|
|
const expectedHex = Array.from(new Uint8Array(expected)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
return sig === `sha256=${expectedHex}`;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export async function POST(req: Request) {
|
|
const rawBody = await req.text();
|
|
|
|
if (!(await verifyBtcPaySignature(req, rawBody))) {
|
|
return NextResponse.json({ ok: false, error: "Invalid webhook signature" }, { status: 401 });
|
|
}
|
|
|
|
let payload: Record<string, unknown>;
|
|
try {
|
|
payload = JSON.parse(rawBody) as Record<string, unknown>;
|
|
} catch {
|
|
return NextResponse.json({ ok: false, error: "Invalid JSON" }, { status: 400 });
|
|
}
|
|
|
|
const type = payload.type as string | undefined;
|
|
const invoiceId = (payload.invoiceId ?? payload.id) as string | undefined;
|
|
|
|
if (!invoiceId || type !== "InvoiceSettled") {
|
|
return NextResponse.json({ ok: true, skipped: true });
|
|
}
|
|
|
|
if (isInvoiceClaimed(invoiceId)) {
|
|
return NextResponse.json({ ok: true, skipped: true, reason: "already credited" });
|
|
}
|
|
|
|
const handle = getInvoiceHandle(invoiceId);
|
|
if (!handle) {
|
|
return NextResponse.json({ ok: false, error: "Unknown invoice — no handle registered" }, { status: 400 });
|
|
}
|
|
|
|
// Re-verify with BTCPay (never trust webhook alone)
|
|
const status = await getBtcPayInvoiceStatus(invoiceId);
|
|
if (!status.ok || status.status !== "Settled") {
|
|
return NextResponse.json({ ok: false, error: `Invoice not settled (${status.ok ? status.status : status.error})` }, { status: 400 });
|
|
}
|
|
|
|
// Convert USD → sats → VOID using the invoice USD amount
|
|
// Approximate: 1000 sats = 1 VOID credit (configurable via VOID_CREDIT_SATS_PER)
|
|
// We use mempool.space BTC price for the conversion
|
|
const btcPriceRes = await fetch(
|
|
"https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd",
|
|
{ next: { revalidate: 60 } },
|
|
).catch(() => null);
|
|
const btcUsd = btcPriceRes?.ok
|
|
? ((await btcPriceRes.json()) as { bitcoin?: { usd?: number } }).bitcoin?.usd ?? 0
|
|
: 0;
|
|
|
|
let voidToCredit = 0;
|
|
if (btcUsd > 0) {
|
|
const sats = Math.floor((status.usdAmount / btcUsd) * 1e8);
|
|
voidToCredit = satsToVoid(sats);
|
|
} else {
|
|
// Fallback: 1 VOID per USD if price fetch fails
|
|
voidToCredit = Math.floor(status.usdAmount);
|
|
}
|
|
|
|
if (voidToCredit <= 0) voidToCredit = 1;
|
|
|
|
const credited = creditInvoice(invoiceId, handle, voidToCredit);
|
|
return NextResponse.json({ ok: true, credited, handle, voidToCredit });
|
|
}
|