/** * 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 FREE_TIER_BYTES = 100 * 1024 * 1024; // 100 MiB free const PER_TIER_BYTES = 100 * 1024 * 1024; // $1 USD per 100 MiB above the free tier export const RECEIVE_ADDRESS = process.env.PROMETHEUS_PAYMENT_ADDRESS || ''; export const PAYMENT_REQUIRED = process.env.PAYMENT_REQUIRED === '1'; /** * Size-tiered listing fee in USD. 100 MiB free, then $1 per started 100-MiB * tier above that. * * 0 - 100 MiB → $0 (free) * 100.01 - 200 MiB → $1 * 200.01 - 300 MiB → $2 * ... * 1900.01 - 2000 MiB → $19 * 2000.01 - 2048 MiB → $20 (ceiling at 2 GiB) * * Returns an integer; the oracle converts to sats at invoice time. The size * threshold is EXACTLY at tier boundaries — a 100.000 MiB file is free, a * 100.001 MiB file is the first paid tier at $1. */ export function priceUsdForSize(sizeBytes) { const n = Number(sizeBytes) || 0; if (n <= FREE_TIER_BYTES) return 0; return Math.ceil((n - FREE_TIER_BYTES) / PER_TIER_BYTES); } // Back-compat export; still read in admin-pages.js as a display hint. // Represents the first paid tier. export const LISTING_FEE_USD = 1; export function paymentsConfigured() { return RECEIVE_ADDRESS.length > 0; } export function paymentsRequired() { return PAYMENT_REQUIRED && paymentsConfigured(); } /** * True if this listing needs an invoice before publishing. * Combines the operator policy (PAYMENT_REQUIRED) with the size tier: * when PAYMENT_REQUIRED is on, ONLY listings with a non-zero size-tier * price are gated — the free tier (≤100 MiB) always publishes immediately. */ export function listingNeedsPayment(sizeBytes) { if (!paymentsRequired()) return false; return priceUsdForSize(sizeBytes) > 0; } /** * Create a new invoice for a listing. Returns the invoice row. * Throws if the listing's size falls in the free tier (caller shouldn't ask). */ 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 usd = priceUsdForSize(listing.download_size); if (usd === 0) { throw new Error(`listing is in the free tier (${listing.download_size} bytes); no invoice needed`); } const { sats: baseSats, rate_sat_per_usd } = await usdToSats(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(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'); }