Commit graph

2 commits

Author SHA1 Message Date
Local Dev
34efb319cc 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'.
2026-10-08 21:52:01 +02:00
Local Dev
72f7d41902 prometheus: signed listings — BIP-137 publisher sign, SQLite index
POST /api/listing accepts a JSON body with the manifest fields, a
reference to a signed scan report from /api/upload, and a BIP-137
signature. Before the row lands in SQLite the service verifies three
things:

  1. the attached scan_report was signed by this service, is for the
     exact (hash, size) the listing claims, and the verdict is clean —
     a publisher cannot swap in a different file's clean report;

  2. the publisher's signature recovers to a valid cashaddress, and
     that address matches the on-chain owner of the publisher's BNS
     name (gateway /api/name/<name>) — recovery alone is never enough
     per [[recovery-is-not-verification]];

  3. the usual shape checks: category in the allow-list, sha256 is 64
     hex, download URL is http(s), slug matches [a-z0-9-]{1..64}, body
     under 1 MiB.

GET /api/listings and GET /api/listings/<publisher>/<slug> are read-only
endpoints the browse milestone will render from.

MOCK_BNS_OWNER=<cashaddr> short-circuits the gateway call for local
testing without a chipnet gateway reachable — same pattern as MOCK_SCAN.

Dependencies:
- @bitauth/libauth, for the secp256k1 primitives behind BIP-137 recovery
  and the cashaddress encoder. Same primitive Argus's registrar uses, so
  signatures from Argus-compatible wallets verify unchanged.
- node:sqlite (ships with Node 22+) for the listings DB — one file next
  to the service, WAL journaling, no native build step. engines bumped
  to ">=22".

Lifted service/lib/sign-message.js from Argus so Prometheus does not
have to pull the resolver's whole dependency tree in for one verifier.
Removed its NETWORK import; the default address prefix comes from
BNS_NETWORK=chipnet|mainnet instead.

INTERIM: no APS1 record on chain yet. The signed row IS the listing;
when APS1 is defined cross-session the service will add an on-chain
publish step that points at the same signed manifest. The browse and
admin milestones can proceed against this DB in the meantime.

scripts/test-listing-roundtrip.mjs drives the whole flow end-to-end
with a deterministic test key, so a sanity check is a two-command
sequence: start the server with MOCK_SCAN=clean + MOCK_BNS_OWNER=<the
test address>, then run the script. Negative paths (fake scan report,
bad category, duplicate slug) also verified by hand.
2026-10-08 01:48:12 +02:00