145 lines
5.3 KiB
JavaScript
145 lines
5.3 KiB
JavaScript
|
|
/**
|
||
|
|
* 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');
|
||
|
|
}
|