/** * Invoices for the paid wave. * * Design in short: * One operator receive address (PROMETHEUS_PAYMENT_ADDRESS) collects every * payment. We distinguish invoices by amount — the base is the USD-pegged * fee (PROMETHEUS_LISTING_FEE_USD, default 1 USD), plus a random 1..256 sat * jitter so two in-flight invoices never expect the same amount at the same * time. The mempool watcher matches incoming (address, amount) pairs to a * pending invoice and marks it paid. * * This file owns invoice creation and status transitions. The mempool watcher * (service/payment-watcher.js) is a separate concern — it calls `tryMatchPayment()` * here with (address, amount, txid) as the only state it needs to tell us. * * The HD-wallet alternative (one address per invoice) is specifically NOT what * we do, because deriving + spending from many addresses requires a seeded * wallet on the server and a signing operation to consolidate. One address + * amount-matching is the shape BitPay used for years; it is simple, auditable * and only costs the admin a bit of accounting on the UTXO side. */ import { randomBytes } from 'node:crypto'; import { insertInvoice, getInvoice, markInvoicePaid, markInvoiceExpired, matchPendingInvoice, markListingPaid, getListing, invoicesForListing, } from './db.js'; import { usdToSats } from './price.js'; const INVOICE_TTL_MS = 20 * 60 * 1000; // 20 min, matches the quote lock const JITTER_MAX = 256; // random 1..256 sats added to the base const DEFAULT_FEE_USD = 1; export const RECEIVE_ADDRESS = process.env.PROMETHEUS_PAYMENT_ADDRESS || ''; export const LISTING_FEE_USD = Number(process.env.PROMETHEUS_LISTING_FEE_USD) || DEFAULT_FEE_USD; export const PAYMENT_REQUIRED = process.env.PAYMENT_REQUIRED === '1'; export function paymentsConfigured() { return RECEIVE_ADDRESS.length > 0; } export function paymentsRequired() { return PAYMENT_REQUIRED && paymentsConfigured(); } /** * Create a new invoice for a listing. Returns the invoice row. */ export async function createInvoice(listingId) { if (!paymentsConfigured()) { throw new Error('payments not configured — set PROMETHEUS_PAYMENT_ADDRESS'); } const listing = getListing(listingId); if (!listing) throw new Error(`no such listing: ${listingId}`); const { sats: baseSats, rate_sat_per_usd } = await usdToSats(LISTING_FEE_USD); const jitter = (randomBytes(1)[0] % JITTER_MAX) + 1; const amount_sat = baseSats + jitter; const now = Date.now(); const row = { id: freshInvoiceId(), listing_id: listingId, address: RECEIVE_ADDRESS, amount_sat, amount_usd: String(LISTING_FEE_USD), rate_sat_per_usd, issued_at: new Date(now).toISOString(), expires_at: new Date(now + INVOICE_TTL_MS).toISOString(), }; insertInvoice(row); return { ...row, status: 'pending' }; } /** * Called by the mempool watcher when it sees a (presumably-settlement) tx. * Matches to the single pending invoice with that (address, amount) and marks * it paid, plus flips the listing's payment_status. Returns the invoice row * on success, null on no-match. */ export function tryMatchPayment({ address, amount_sat, txid, by = 'watcher' }) { const invoice = matchPendingInvoice(address, amount_sat); if (!invoice) return null; const paid_at = new Date().toISOString(); const ok = markInvoicePaid({ id: invoice.id, paid_at, paid_txid: txid, paid_by: by }); if (!ok) return null; markListingPaid(invoice.listing_id); return { ...invoice, status: 'paid', paid_at, paid_txid: txid, paid_by: by }; } /** * Manual admin confirmation path — same effect as the watcher reconciling a * payment, but attributed differently in the audit trail. */ export function manuallyMarkPaid(invoiceId, { txid, actor }) { const invoice = getInvoice(invoiceId); if (!invoice) throw new Error(`no such invoice: ${invoiceId}`); if (invoice.status === 'paid') return invoice; const paid_at = new Date().toISOString(); markInvoicePaid({ id: invoiceId, paid_at, paid_txid: txid || '(no txid supplied)', paid_by: `manual:${actor}`, }); markListingPaid(invoice.listing_id); return { ...invoice, status: 'paid', paid_at, paid_txid: txid, paid_by: `manual:${actor}` }; } /** * Expire a specific invoice if its deadline has passed. Harmless on an * already-paid or already-expired one. Returns true if a transition happened. */ export function reapInvoice(id) { return markInvoiceExpired(id); } /** * Public shape of an invoice for the UI / API. */ export function toPublic(invoice) { return { id: invoice.id, listing_id: invoice.listing_id, address: invoice.address, amount_sat: invoice.amount_sat, amount_bch: (invoice.amount_sat / 1e8).toFixed(8), amount_usd: invoice.amount_usd, issued_at: invoice.issued_at, expires_at: invoice.expires_at, status: invoice.status, paid_at: invoice.paid_at || undefined, paid_txid: invoice.paid_txid || undefined, paid_by: invoice.paid_by || undefined, bitcoincash_uri: `bitcoincash:${invoice.address}?amount=${(invoice.amount_sat / 1e8).toFixed(8)}&label=${encodeURIComponent('Prometheus listing: ' + invoice.listing_id)}`, }; } /** Fresh base32-ish id. 12 bytes = 96 bits = plenty for an invoice. */ function freshInvoiceId() { return randomBytes(12).toString('base64url'); }