diff --git a/service/admin-pages.js b/service/admin-pages.js
index 8b03098..f9479cc 100644
--- a/service/admin-pages.js
+++ b/service/admin-pages.js
@@ -82,6 +82,7 @@ function adminNav(session, here = '') {
Admin
${link('/admin', 'Dashboard')}
${link('/admin/listings', 'Listings')}
+ ${link('/admin/payments', 'Payments')}
${link('/admin/modes', 'Modes')}
${link('/admin/audit', 'Audit')}
@@ -92,6 +93,100 @@ function adminNav(session, here = '') {
`;
}
+/* ------------------------------- payments ------------------------------- */
+
+export function paymentsPage({ session, invoices, config, notice = null }) {
+ return {
+ title: 'Admin — payments',
+ description: 'Invoices and payment status.',
+ here: '/admin/payments',
+ body: `
+${adminNav(session, '/admin/payments')}
+
+
+
+${notice ? `
${escapeHtml(notice)}
` : ''}
+
+
+ Payments
+
+
+
+
${escapeHtml(config.required ? 'required' : 'off')}
+
payment gate — ${escapeHtml(config.required ? 'new listings land as pending' : 'new listings publish immediately')}
+
+
+
$${escapeHtml(String(config.fee_usd))}
+
listing fee per publication
+
+
+
${escapeHtml(config.address || '(not set)')}
+
operator receive address
+
+
+
+ ${!config.address ? `
+
+ No receive address configured. Set PROMETHEUS_PAYMENT_ADDRESS
+ in the service environment and restart. Until then, /api/invoice refuses to issue.
+
` : ''}
+ ${config.required && !config.address ? `
+
+ Payments are required but no address is set. New listings are being created as
+ pending but cannot be settled. Set the address and restart,
+ or unset PAYMENT_REQUIRED.
+
` : ''}
+
+ Invoices (${invoices.length})
+ ${invoices.length === 0 ? `No invoices issued yet.
` : `
+
+
+ | id | listing | sats | status |
+ issued | expires / paid | |
+
+
+ ${invoices.map((i) => renderInvoiceRow(i)).join('\n')}
+
+
`}
+
+
+
+ Mempool watcher
+ The mempool watcher that would auto-reconcile incoming payments to pending
+ invoices is a follow-up milestone. For now, confirm a payment by hand once it lands —
+ paste the txid into the Mark paid form on an invoice row. The listing becomes visible
+ on browse the moment the invoice flips to paid.
+
+
+
`,
+ };
+}
+
+function renderInvoiceRow(i) {
+ const paidChip =
+ i.status === 'paid' ? `paid` :
+ i.status === 'expired' ? `expired` :
+ `pending`;
+ const rightCell = i.status === 'paid'
+ ? `${escapeHtml(i.paid_at)}
${escapeHtml((i.paid_txid || '').slice(0, 20))}…
`
+ : `${escapeHtml(i.expires_at)}
`;
+ const action = i.status === 'pending'
+ ? ``
+ : `${i.status === 'paid' ? 'by ' + escapeHtml(i.paid_by || '') : ''}`;
+ return `
+ ${escapeHtml(i.id)} |
+ ${escapeHtml(i.listing_id)} |
+ ${i.amount_sat.toLocaleString()} |
+ ${paidChip} |
+ ${escapeHtml(i.issued_at)} |
+ ${rightCell} |
+ ${action} |
+
`;
+}
+
/* ------------------------------- dashboard ------------------------------- */
export function dashboardPage({ session, counts, total, modes, recentListings, recentAudit }) {
diff --git a/service/admin.js b/service/admin.js
index e8c4c91..a127880 100644
--- a/service/admin.js
+++ b/service/admin.js
@@ -21,8 +21,12 @@ import { allModes, setMode, MODES } from './modes.js';
import { CATEGORIES, countsByCategory } from './pages-browse.js';
import { logAudit } from './audit.js';
import {
- loginPage, dashboardPage, listingsPage, modesPage, auditPage,
+ loginPage, dashboardPage, listingsPage, modesPage, auditPage, paymentsPage,
} from './admin-pages.js';
+import { allInvoices } from './db.js';
+import {
+ manuallyMarkPaid, LISTING_FEE_USD, RECEIVE_ADDRESS, paymentsRequired,
+} from './payments.js';
/* ----------------- dispatch (called from server.js) ----------------- */
@@ -57,12 +61,18 @@ export async function handleAdmin(req, res, url) {
return showModes(res, session);
}
if (url === '/admin/audit') return showAudit(res, session);
+ if (url === '/admin/payments') return showPayments(res, session);
const withdrawMatch = url.match(/^\/admin\/listings\/([^/]+)\/([^/]+)\/withdraw$/);
if (withdrawMatch && req.method === 'POST') {
return doWithdraw(req, res, session, `${decodeURIComponent(withdrawMatch[1])}/${decodeURIComponent(withdrawMatch[2])}`);
}
+ const markPaidMatch = url.match(/^\/admin\/payments\/([^/]+)\/mark-paid$/);
+ if (markPaidMatch && req.method === 'POST') {
+ return doMarkPaid(req, res, session, decodeURIComponent(markPaidMatch[1]));
+ }
+
return notFoundHtml(res);
}
@@ -125,6 +135,16 @@ function showAudit(res, session) {
renderPage(res, auditPage({ session, entries }));
}
+function showPayments(res, session) {
+ const invoices = allInvoices();
+ const config = {
+ required: paymentsRequired(),
+ fee_usd: LISTING_FEE_USD,
+ address: RECEIVE_ADDRESS,
+ };
+ renderPage(res, paymentsPage({ session, invoices, config }));
+}
+
/* ----------------- POST actions ----------------- */
async function doWithdraw(req, res, session, id) {
@@ -143,6 +163,23 @@ async function doWithdraw(req, res, session, id) {
res.end();
}
+async function doMarkPaid(req, res, session, invoiceId) {
+ const form = await readForm(req);
+ try {
+ const invoice = manuallyMarkPaid(invoiceId, { txid: form.txid || '', actor: session.name });
+ logAudit({
+ actor: session.name,
+ action: 'mark_invoice_paid',
+ target: invoiceId,
+ details: { listing_id: invoice.listing_id, txid: form.txid || null, amount_sat: invoice.amount_sat },
+ });
+ } catch (err) {
+ // Fall through to the redirect; the admin can retry from the UI.
+ }
+ res.writeHead(302, { location: '/admin/payments' });
+ res.end();
+}
+
async function saveModes(req, res, session) {
const form = await readForm(req);
diff --git a/service/db.js b/service/db.js
index 3e247c1..8106666 100644
--- a/service/db.js
+++ b/service/db.js
@@ -48,6 +48,11 @@ db.exec(`
signed_message TEXT NOT NULL, -- exact bytes the publisher signed
created_at TEXT NOT NULL,
withdrawn_at TEXT,
+ -- payment_status is one of: 'free' | 'pending' | 'paid'. Free is the opt-in
+ -- default (the launch wave underwrites hosting). When PAYMENT_REQUIRED is
+ -- set, new listings land as 'pending' and browse hides them until the
+ -- matching invoice settles.
+ payment_status TEXT NOT NULL DEFAULT 'free',
UNIQUE (publisher, slug)
);
@@ -56,6 +61,27 @@ db.exec(`
CREATE INDEX IF NOT EXISTS idx_listings_publisher
ON listings(publisher);
+ -- Invoices for the paid wave. One receiving address serves every invoice;
+ -- we distinguish them by amount — each invoice has a random few-sat jitter
+ -- on top of the base USD-equivalent, so no two in-flight invoices expect
+ -- the same number of sats to the same address at the same time.
+ CREATE TABLE IF NOT EXISTS invoices (
+ id TEXT PRIMARY KEY, -- opaque base32 token
+ listing_id TEXT NOT NULL REFERENCES listings(id) ON DELETE CASCADE,
+ address TEXT NOT NULL, -- the operator receive address
+ amount_sat INTEGER NOT NULL, -- exact sats expected
+ amount_usd TEXT NOT NULL, -- the USD figure the quote was based on
+ rate_sat_per_usd INTEGER NOT NULL, -- the price snapshot used for amount_sat
+ issued_at TEXT NOT NULL,
+ expires_at TEXT NOT NULL, -- 20 min after issued_at
+ status TEXT NOT NULL DEFAULT 'pending', -- 'pending' | 'paid' | 'expired'
+ paid_at TEXT,
+ paid_txid TEXT,
+ paid_by TEXT -- 'manual' | 'watcher'
+ );
+ CREATE INDEX IF NOT EXISTS idx_invoices_listing ON invoices(listing_id);
+ CREATE INDEX IF NOT EXISTS idx_invoices_status ON invoices(status);
+
-- Operator-visible settings. Admin reads/writes through /admin/modes etc.
-- Values are text; callers coerce when they read.
CREATE TABLE IF NOT EXISTS settings (
@@ -96,13 +122,19 @@ const stmtInsert = db.prepare(`
const stmtGet = db.prepare(`SELECT * FROM listings WHERE id = ?`);
+// "Visible" rows are those that would be shown on browse — not withdrawn, not
+// awaiting payment. 'free' and 'paid' both qualify as settled.
const stmtAllActive = db.prepare(`
- SELECT * FROM listings WHERE withdrawn_at IS NULL ORDER BY created_at DESC
+ SELECT * FROM listings
+ WHERE withdrawn_at IS NULL AND payment_status IN ('free', 'paid')
+ ORDER BY created_at DESC
`);
const stmtByCategory = db.prepare(`
SELECT * FROM listings
- WHERE category = ? AND withdrawn_at IS NULL
+ WHERE category = ?
+ AND withdrawn_at IS NULL
+ AND payment_status IN ('free', 'paid')
ORDER BY created_at DESC
`);
@@ -111,12 +143,13 @@ const stmtByPublisher = db.prepare(`
`);
const stmtCountActive = db.prepare(`
- SELECT COUNT(*) AS n FROM listings WHERE withdrawn_at IS NULL
+ SELECT COUNT(*) AS n FROM listings
+ WHERE withdrawn_at IS NULL AND payment_status IN ('free', 'paid')
`);
const stmtCountByCategory = db.prepare(`
SELECT category, COUNT(*) AS n FROM listings
- WHERE withdrawn_at IS NULL
+ WHERE withdrawn_at IS NULL AND payment_status IN ('free', 'paid')
GROUP BY category
`);
@@ -226,3 +259,75 @@ export function insertAuditRow(row) {
export function recentAudit(limit = 50) {
return stmtAuditRecent.all(limit);
}
+
+/* ----------------------- invoices ----------------------- */
+
+const stmtInsertListingWithStatus = db.prepare(`
+ INSERT INTO listings (
+ id, publisher, slug, name, description, category, version,
+ homepage, icon, download_url, download_sha256, download_size,
+ scan_report, signer_address, signature, signed_message, created_at,
+ payment_status
+ ) VALUES (
+ @id, @publisher, @slug, @name, @description, @category, @version,
+ @homepage, @icon, @download_url, @download_sha256, @download_size,
+ @scan_report, @signer_address, @signature, @signed_message, @created_at,
+ @payment_status
+ )
+`);
+
+/** Insert a listing while explicitly setting payment_status (for paid mode). */
+export function insertListingWithPaymentStatus(row) {
+ stmtInsertListingWithStatus.run(row);
+}
+
+const stmtListingSetPaid = db.prepare(`
+ UPDATE listings SET payment_status = 'paid'
+ WHERE id = ? AND payment_status != 'paid'
+`);
+
+export function markListingPaid(id) {
+ const info = stmtListingSetPaid.run(id);
+ return info.changes > 0;
+}
+
+const stmtInvInsert = db.prepare(`
+ INSERT INTO invoices (
+ id, listing_id, address, amount_sat, amount_usd, rate_sat_per_usd,
+ issued_at, expires_at
+ ) VALUES (
+ @id, @listing_id, @address, @amount_sat, @amount_usd, @rate_sat_per_usd,
+ @issued_at, @expires_at
+ )
+`);
+const stmtInvGet = db.prepare(`SELECT * FROM invoices WHERE id = ?`);
+const stmtInvByListing = db.prepare(`
+ SELECT * FROM invoices WHERE listing_id = ? ORDER BY issued_at DESC
+`);
+const stmtInvAll = db.prepare(`SELECT * FROM invoices ORDER BY issued_at DESC`);
+const stmtInvMarkPaid = db.prepare(`
+ UPDATE invoices
+ SET status = 'paid', paid_at = @paid_at, paid_txid = @paid_txid, paid_by = @paid_by
+ WHERE id = @id AND status = 'pending'
+`);
+const stmtInvMarkExpired = db.prepare(`
+ UPDATE invoices SET status = 'expired'
+ WHERE id = ? AND status = 'pending' AND datetime(expires_at) < datetime('now')
+`);
+const stmtInvPendingByAddr = db.prepare(`
+ SELECT * FROM invoices
+ WHERE address = ? AND amount_sat = ? AND status = 'pending'
+ AND datetime(expires_at) >= datetime('now')
+ ORDER BY issued_at ASC
+ LIMIT 1
+`);
+
+export function insertInvoice(row) { stmtInvInsert.run(row); }
+export function getInvoice(id) { return stmtInvGet.get(id) || null; }
+export function invoicesForListing(id) { return stmtInvByListing.all(id); }
+export function allInvoices() { return stmtInvAll.all(); }
+export function markInvoicePaid(args) { const info = stmtInvMarkPaid.run(args); return info.changes > 0; }
+export function markInvoiceExpired(id) { const info = stmtInvMarkExpired.run(id); return info.changes > 0; }
+export function matchPendingInvoice(address, amount_sat) {
+ return stmtInvPendingByAddr.get(address, amount_sat) || null;
+}
diff --git a/service/invoice-api.js b/service/invoice-api.js
new file mode 100644
index 0000000..d2abc27
--- /dev/null
+++ b/service/invoice-api.js
@@ -0,0 +1,87 @@
+/**
+ * Public invoice endpoints.
+ *
+ * POST /api/invoice {listing_id} → create a fresh invoice
+ * GET /api/invoice/ → status
+ *
+ * No auth. The invoice contents are a payment request to the operator's own
+ * address — paying the exact amount shown settles the listing it was issued
+ * for. If somebody creates an unused invoice, they have an unused invoice;
+ * nothing moves until the mempool watcher (or an admin) sees the payment.
+ */
+
+import {
+ createInvoice,
+ paymentsConfigured,
+ toPublic as invoiceToPublic,
+ reapInvoice,
+} from './payments.js';
+import { getInvoice, getListing } from './db.js';
+
+export async function handleInvoiceCreate(req, res) {
+ if (req.method !== 'POST') return json(res, 405, { ok: false, reason: 'method not allowed' });
+ if (!paymentsConfigured()) {
+ return json(res, 503, { ok: false, reason: 'payments are not enabled on this instance' });
+ }
+
+ let body;
+ try { body = await readJson(req); }
+ catch (err) { return json(res, 400, { ok: false, reason: String(err?.message || err) }); }
+
+ const listingId = String(body?.listing_id || '').trim();
+ if (!listingId) return json(res, 400, { ok: false, reason: '`listing_id` required' });
+ const listing = getListing(listingId);
+ if (!listing) return json(res, 404, { ok: false, reason: 'no such listing' });
+
+ try {
+ const invoice = await createInvoice(listingId);
+ return json(res, 201, { ok: true, invoice: invoiceToPublic(invoice) });
+ } catch (err) {
+ return json(res, 500, { ok: false, reason: String(err?.message || err) });
+ }
+}
+
+export function handleInvoiceGet(req, res, url) {
+ const parts = new URL(url, 'http://localhost').pathname.split('/').filter(Boolean);
+ // ['api', 'invoice', '']
+ if (parts.length !== 3) return json(res, 400, { ok: false, reason: 'expected /api/invoice/' });
+ const id = parts[2];
+ const invoice = getInvoice(id);
+ if (!invoice) return json(res, 404, { ok: false, reason: 'no such invoice' });
+
+ // A pending invoice past its deadline flips to 'expired' here lazily — the
+ // mempool watcher does not need a periodic sweeper if every status check
+ // takes care of its own invoice.
+ if (invoice.status === 'pending' && Date.parse(invoice.expires_at) < Date.now()) {
+ reapInvoice(id);
+ invoice.status = 'expired';
+ }
+ return json(res, 200, { ok: true, invoice: invoiceToPublic(invoice) });
+}
+
+/* -------- helpers -------- */
+
+const MAX_BODY = 32 * 1024;
+
+async function readJson(req) {
+ const chunks = [];
+ let total = 0;
+ for await (const chunk of req) {
+ total += chunk.length;
+ if (total > MAX_BODY) throw new Error('body too large');
+ chunks.push(chunk);
+ }
+ const body = Buffer.concat(chunks).toString('utf8');
+ if (!body.trim()) throw new Error('empty request body');
+ return JSON.parse(body);
+}
+
+function json(res, status, body) {
+ const payload = JSON.stringify(body);
+ res.writeHead(status, {
+ 'content-type': 'application/json; charset=utf-8',
+ 'cache-control': 'no-store',
+ 'content-length': Buffer.byteLength(payload),
+ });
+ res.end(payload);
+}
diff --git a/service/listings.js b/service/listings.js
index 9e3b767..c87f2b3 100644
--- a/service/listings.js
+++ b/service/listings.js
@@ -32,7 +32,9 @@ import {
allActiveListings,
listingsByCategory,
listingsByPublisher,
+ insertListingWithPaymentStatus,
} from './db.js';
+import { paymentsRequired } from './payments.js';
const CATEGORIES = new Set([
'communication', 'privacy', 'wallets', 'games',
@@ -102,6 +104,9 @@ export async function handleListingCreate(req, res) {
const slug = String(body.slug).toLowerCase();
const id = `${name}/${slug}`;
+ // In paid mode the listing lands as 'pending' and is hidden from browse
+ // until an invoice settles against it; in free mode it is immediately live.
+ const paid = paymentsRequired();
const row = {
id,
publisher: name,
@@ -120,10 +125,11 @@ export async function handleListingCreate(req, res) {
signature: String(body.signature),
signed_message: message,
created_at: now,
+ payment_status: paid ? 'pending' : 'free',
};
try {
- insertListing(row);
+ insertListingWithPaymentStatus(row);
} catch (err) {
if (/UNIQUE constraint failed/.test(String(err?.message))) {
return json(res, 409, {
@@ -138,6 +144,7 @@ export async function handleListingCreate(req, res) {
ok: true,
id,
listing: toPublic(row),
+ ...(paid ? { payment_required: true, note: 'POST /api/invoice with this listing_id to receive a payment request' } : {}),
});
}
diff --git a/service/payments.js b/service/payments.js
new file mode 100644
index 0000000..10b15d9
--- /dev/null
+++ b/service/payments.js
@@ -0,0 +1,144 @@
+/**
+ * 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');
+}
diff --git a/service/price.js b/service/price.js
new file mode 100644
index 0000000..39f8d0d
--- /dev/null
+++ b/service/price.js
@@ -0,0 +1,48 @@
+/**
+ * BCH ↔ USD price lookup, with a 60-second in-memory cache.
+ *
+ * We pull from CoinGecko by default — public, no API key, generous for the
+ * once-a-minute cadence we need. The price feeds the invoice quote; a
+ * publisher's quote is locked at issue time and does not re-quote while they
+ * decide whether to pay.
+ *
+ * On fetch failure we return the last good value if it is under 15 minutes
+ * old. Beyond that, we throw — handing out invoices with stale prices is
+ * worse than refusing to quote until the oracle recovers.
+ */
+
+const CACHE_TTL_MS = 60_000;
+const STALE_FALLBACK_MS = 15 * 60_000;
+const SOURCE = process.env.PROMETHEUS_PRICE_SOURCE
+ || 'https://api.coingecko.com/api/v3/simple/price?ids=bitcoin-cash&vs_currencies=usd';
+
+let cached = null; // { price_usd, at }
+
+export async function bchUsd() {
+ const now = Date.now();
+ if (cached && now - cached.at < CACHE_TTL_MS) return cached.price_usd;
+
+ try {
+ const resp = await fetch(SOURCE, {
+ headers: { accept: 'application/json' },
+ signal: AbortSignal.timeout(5000),
+ });
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
+ const body = await resp.json();
+ const p = Number(body?.['bitcoin-cash']?.usd);
+ if (!Number.isFinite(p) || p <= 0) throw new Error('bad price in response');
+ cached = { price_usd: p, at: now };
+ return p;
+ } catch (err) {
+ if (cached && now - cached.at < STALE_FALLBACK_MS) return cached.price_usd;
+ throw new Error(`price oracle: ${err?.message || err}`);
+ }
+}
+
+/** USD → sats, using the current (or last-good) BCH/USD price. */
+export async function usdToSats(usd) {
+ const price = await bchUsd(); // USD per BCH
+ const bch = Number(usd) / price; // BCH per `usd` dollars
+ const sats = Math.round(bch * 1e8); // satoshis per BCH = 1e8
+ return { sats, rate_sat_per_usd: Math.round(1e8 / price), price_usd: price };
+}
diff --git a/service/server.js b/service/server.js
index cbd44da..bd6cdf9 100644
--- a/service/server.js
+++ b/service/server.js
@@ -30,6 +30,7 @@ import { categoryPage, appPage, CATEGORIES } from './pages-browse.js';
import { handleAdmin } from './admin.js';
import { renderSitemap, renderRobots } from './seo.js';
import { allActiveListings } from './db.js';
+import { handleInvoiceCreate, handleInvoiceGet } from './invoice-api.js';
const PORT = Number(process.env.PORT) || 3700;
const BIND = process.env.BIND || '127.0.0.1';
@@ -88,6 +89,10 @@ const server = createServer(async (req, res) => {
handleListingIndex(req, res, req.url);
} else if (url.startsWith('/api/listings/')) {
handleListingGet(req, res, req.url);
+ } else if (url === '/api/invoice') {
+ await handleInvoiceCreate(req, res);
+ } else if (url.startsWith('/api/invoice/')) {
+ handleInvoiceGet(req, res, req.url);
} else if (url === '/healthz') {
await serveHealth(res);
} else if (url === '/.well-known/prometheus-service-key.json') {