prometheus: BCH payments — invoices, price oracle, admin reconcile
Payment flow for the paid wave (the free wave is still the default):
1. Publisher POSTs /api/listing. If PAYMENT_REQUIRED=1 is set, the
listing lands with payment_status='pending' and is hidden from
browse. If not set, the listing goes straight to 'free' and is
visible immediately (today's behaviour, unchanged).
2. Anyone (publisher, their script, their wallet) POSTs
/api/invoice {listing_id} and gets back a one-shot invoice:
operator receive address, exact sat amount (USD-pegged + random
1..256 sat jitter so no two in-flight invoices collide),
bitcoincash: URI for a wallet, 20-min expiry.
3. Reader pays. The amount matches exactly one pending invoice, and
when the mempool watcher reports (address, amount, txid) to
tryMatchPayment, both the invoice and the listing flip to paid.
Browse starts showing the listing.
The mempool watcher that would do (3) automatically is deliberately NOT
in this commit — it needs an electrum-cash subscription or a block
explorer webhook, and that's a next milestone. In the meantime, admins
reconcile by hand from /admin/payments, which shows every invoice with
a Mark paid form (optional txid field). Same effect on the DB, same
audit log entry, different `paid_by` string.
Design decisions worth recording:
- One receive address + amount matching, NOT an HD wallet per invoice.
BitPay used this for years; it is simple, auditable, and doesn't
require a seed on the server to spend from later.
- Random few-sat jitter per invoice guarantees uniqueness of the
(address, amount) pair across all in-flight invoices.
- Price oracle is CoinGecko (public, no key, 60s cache, 15-min stale
fallback). If both the fresh fetch and the stale fallback fail,
/api/invoice refuses — issuing invoices on a stale price is worse
than refusing to quote until the oracle recovers.
- Quote is locked at issue time; the 20-min deadline is the lock.
- Lazy expiry: GET /api/invoice/<id> flips an expired pending invoice
to 'expired' as a side effect, so there's no sweeper to run.
Admin UI /admin/payments shows every invoice (pending/paid/expired),
plus a dashboard-ish header with the configured mode + receive address.
The gating config appears in the UI even when payments are disabled, so
an operator spinning up a new instance can see at a glance what's set.
Config:
PAYMENT_REQUIRED=1 — turn paid mode on
PROMETHEUS_PAYMENT_ADDRESS=… — the operator's receive address
PROMETHEUS_LISTING_FEE_USD=1 — the fee (default 1 USD)
PROMETHEUS_PRICE_SOURCE=… — override the CoinGecko URL
End-to-end verified:
PAYMENT_REQUIRED=1 → new listing hidden from browse → /api/invoice
returns correct sats at live BCH price → admin login + mark-paid →
listing becomes visible → /api/invoice/<id> returns status 'paid'
with paid_by='manual:admin.x'.
This commit is contained in:
parent
6eeec54266
commit
34efb319cc
8 changed files with 534 additions and 6 deletions
|
|
@ -82,6 +82,7 @@ function adminNav(session, here = '') {
|
|||
<b style="color:var(--ink);margin-right:14px;font-size:14px">Admin</b>
|
||||
${link('/admin', 'Dashboard')}
|
||||
${link('/admin/listings', 'Listings')}
|
||||
${link('/admin/payments', 'Payments')}
|
||||
${link('/admin/modes', 'Modes')}
|
||||
${link('/admin/audit', 'Audit')}
|
||||
<span style="flex:1"></span>
|
||||
|
|
@ -92,6 +93,100 @@ function adminNav(session, here = '') {
|
|||
</nav>`;
|
||||
}
|
||||
|
||||
/* ------------------------------- 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')}
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
${notice ? `<div class="note" style="margin-top:1rem"><b>${escapeHtml(notice)}</b></div>` : ''}
|
||||
|
||||
<section class="plain">
|
||||
<h2>Payments</h2>
|
||||
|
||||
<div class="grid" style="margin-top:1rem">
|
||||
<div class="card">
|
||||
<h3>${escapeHtml(config.required ? 'required' : 'off')}</h3>
|
||||
<p>payment gate — ${escapeHtml(config.required ? 'new listings land as pending' : 'new listings publish immediately')}</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>$${escapeHtml(String(config.fee_usd))}</h3>
|
||||
<p>listing fee per publication</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3 style="word-break:break-all;font-size:14px;font-family:ui-monospace,monospace">${escapeHtml(config.address || '(not set)')}</h3>
|
||||
<p>operator receive address</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${!config.address ? `
|
||||
<div class="note warn" style="margin-top:1.4rem">
|
||||
<b>No receive address configured.</b> Set <code class="inline">PROMETHEUS_PAYMENT_ADDRESS</code>
|
||||
in the service environment and restart. Until then, /api/invoice refuses to issue.
|
||||
</div>` : ''}
|
||||
${config.required && !config.address ? `
|
||||
<div class="note danger" style="margin-top:1rem">
|
||||
<b>Payments are required but no address is set.</b> New listings are being created as
|
||||
<code class="inline">pending</code> but cannot be settled. Set the address and restart,
|
||||
or unset <code class="inline">PAYMENT_REQUIRED</code>.
|
||||
</div>` : ''}
|
||||
|
||||
<h3 style="margin-top:1.8rem">Invoices (${invoices.length})</h3>
|
||||
${invoices.length === 0 ? `<p class="lede">No invoices issued yet.</p>` : `
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>id</th><th>listing</th><th>sats</th><th>status</th>
|
||||
<th>issued</th><th>expires / paid</th><th></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
${invoices.map((i) => renderInvoiceRow(i)).join('\n')}
|
||||
</tbody>
|
||||
</table>`}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Mempool watcher</h2>
|
||||
<p class="lede">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.</p>
|
||||
</section>
|
||||
|
||||
</div>`,
|
||||
};
|
||||
}
|
||||
|
||||
function renderInvoiceRow(i) {
|
||||
const paidChip =
|
||||
i.status === 'paid' ? `<span class="pill p-live">paid</span>` :
|
||||
i.status === 'expired' ? `<span class="pill" style="background:rgba(246,118,138,.14);color:var(--taken)">expired</span>` :
|
||||
`<span class="pill p-upcoming">pending</span>`;
|
||||
const rightCell = i.status === 'paid'
|
||||
? `<div style="color:var(--dim);font-size:12.5px">${escapeHtml(i.paid_at)}<br><code class="inline" style="font-size:11px">${escapeHtml((i.paid_txid || '').slice(0, 20))}…</code></div>`
|
||||
: `<div style="color:var(--dim);font-size:12.5px">${escapeHtml(i.expires_at)}</div>`;
|
||||
const action = i.status === 'pending'
|
||||
? `<form method="post" action="/admin/payments/${encodeURIComponent(i.id)}/mark-paid" style="margin:0;display:flex;gap:4px">
|
||||
<input name="txid" placeholder="txid (optional)" style="padding:4px 6px;background:var(--panel);border:1px solid var(--line);border-radius:6px;color:var(--ink);font-size:12px;width:140px">
|
||||
<button class="btn ghost" type="submit" style="padding:4px 10px;font-size:12.5px">Mark paid</button>
|
||||
</form>`
|
||||
: `<span style="color:var(--dim);font-size:12px">${i.status === 'paid' ? 'by ' + escapeHtml(i.paid_by || '') : ''}</span>`;
|
||||
return `<tr>
|
||||
<td><code class="inline" style="font-size:11px">${escapeHtml(i.id)}</code></td>
|
||||
<td><code class="inline" style="font-size:11px">${escapeHtml(i.listing_id)}</code></td>
|
||||
<td style="font-family:ui-monospace,monospace;font-size:13px">${i.amount_sat.toLocaleString()}</td>
|
||||
<td>${paidChip}</td>
|
||||
<td style="color:var(--dim);font-size:12.5px">${escapeHtml(i.issued_at)}</td>
|
||||
<td>${rightCell}</td>
|
||||
<td>${action}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
/* ------------------------------- dashboard ------------------------------- */
|
||||
|
||||
export function dashboardPage({ session, counts, total, modes, recentListings, recentAudit }) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
113
service/db.js
113
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;
|
||||
}
|
||||
|
|
|
|||
87
service/invoice-api.js
Normal file
87
service/invoice-api.js
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
/**
|
||||
* Public invoice endpoints.
|
||||
*
|
||||
* POST /api/invoice {listing_id} → create a fresh invoice
|
||||
* GET /api/invoice/<id> → 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', '<id>']
|
||||
if (parts.length !== 3) return json(res, 400, { ok: false, reason: 'expected /api/invoice/<id>' });
|
||||
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);
|
||||
}
|
||||
|
|
@ -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' } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
144
service/payments.js
Normal file
144
service/payments.js
Normal file
|
|
@ -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');
|
||||
}
|
||||
48
service/price.js
Normal file
48
service/price.js
Normal file
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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') {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue