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
|
|
|
/**
|
|
|
|
|
* 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
|
prometheus: size-tiered pricing, 2 GiB cap, new hero copy, SEO LD
Three threads, one commit:
1. Size-tiered listing fee instead of a flat $1
priceUsdForSize(bytes) in service/payments.js:
0 - 100 MiB → free
100.001 - 200 MiB → $1
200.001 - 300 MiB → $2
...
1900.001 - 2000 MiB → $19
2000.001 - 2048 MiB → $20 (ceiling at 2 GiB)
listingNeedsPayment(bytes) replaces paymentsRequired() — a free-tier
upload always publishes immediately even when PAYMENT_REQUIRED=1; the
operator switch only governs the paid tiers. createInvoice derives the
fee from the listing's own size at invoice time.
2. Upload cap back to 2 GiB
The 200-MiB cap from the previous commit (which fixed the Caddy-413
HTML bug) is reverted. The paywall is now the real size gate; Caddy
is just the ceiling. p2pexchange Caddyfile already updated + reloaded.
3. Hero + SEO
Hero leads with the plain-English product category:
The decentralized app store.
A home for apps, programs, dApps, and libraries — listed and settled
on the Bitcoin Cash chain, hosted on the decentralized web, endorsed
by signature. Anyone can publish. Nobody can gatekeep.
Named for the one who gave fire to humans and did not ask the gods first.
"hosted on Sia" → "hosted on the decentralized web". Sia is the
mechanism, not the pitch — general readers aren't tripped on the
acronym on the hero.
Meta description rewritten to lead with "decentralized app store" and
name the long-tail terms the audience searches for ("web3 browsers",
"BCH wallets", "mobile and desktop apps").
Two JSON-LD blocks (WebSite + Organization) inlined on home via
layout's headExtra channel — knowledge panel + WebSite result card.
4. Submit wizard
Step 1 result shows the computed listing fee under the scan verdict,
so publishers see the price BEFORE filling out the manifest.
Step 4 publish branches on the server's payment_required flag. On a
paid listing it POSTs /api/invoice right after the listing POST and
renders a payment card with the exact sat amount, the operator receive
address, a `bitcoincash:` URI the wallet opens, and the invoice status
URL for polling.
2026-10-22 23:31:43 +03:00
|
|
|
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
|
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
|
|
|
|
|
|
|
|
export const RECEIVE_ADDRESS = process.env.PROMETHEUS_PAYMENT_ADDRESS || '';
|
|
|
|
|
export const PAYMENT_REQUIRED = process.env.PAYMENT_REQUIRED === '1';
|
|
|
|
|
|
prometheus: size-tiered pricing, 2 GiB cap, new hero copy, SEO LD
Three threads, one commit:
1. Size-tiered listing fee instead of a flat $1
priceUsdForSize(bytes) in service/payments.js:
0 - 100 MiB → free
100.001 - 200 MiB → $1
200.001 - 300 MiB → $2
...
1900.001 - 2000 MiB → $19
2000.001 - 2048 MiB → $20 (ceiling at 2 GiB)
listingNeedsPayment(bytes) replaces paymentsRequired() — a free-tier
upload always publishes immediately even when PAYMENT_REQUIRED=1; the
operator switch only governs the paid tiers. createInvoice derives the
fee from the listing's own size at invoice time.
2. Upload cap back to 2 GiB
The 200-MiB cap from the previous commit (which fixed the Caddy-413
HTML bug) is reverted. The paywall is now the real size gate; Caddy
is just the ceiling. p2pexchange Caddyfile already updated + reloaded.
3. Hero + SEO
Hero leads with the plain-English product category:
The decentralized app store.
A home for apps, programs, dApps, and libraries — listed and settled
on the Bitcoin Cash chain, hosted on the decentralized web, endorsed
by signature. Anyone can publish. Nobody can gatekeep.
Named for the one who gave fire to humans and did not ask the gods first.
"hosted on Sia" → "hosted on the decentralized web". Sia is the
mechanism, not the pitch — general readers aren't tripped on the
acronym on the hero.
Meta description rewritten to lead with "decentralized app store" and
name the long-tail terms the audience searches for ("web3 browsers",
"BCH wallets", "mobile and desktop apps").
Two JSON-LD blocks (WebSite + Organization) inlined on home via
layout's headExtra channel — knowledge panel + WebSite result card.
4. Submit wizard
Step 1 result shows the computed listing fee under the scan verdict,
so publishers see the price BEFORE filling out the manifest.
Step 4 publish branches on the server's payment_required flag. On a
paid listing it POSTs /api/invoice right after the listing POST and
renders a payment card with the exact sat amount, the operator receive
address, a `bitcoincash:` URI the wallet opens, and the invoice status
URL for polling.
2026-10-22 23:31:43 +03:00
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
|
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
|
|
|
export function paymentsConfigured() {
|
|
|
|
|
return RECEIVE_ADDRESS.length > 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function paymentsRequired() {
|
|
|
|
|
return PAYMENT_REQUIRED && paymentsConfigured();
|
|
|
|
|
}
|
|
|
|
|
|
prometheus: size-tiered pricing, 2 GiB cap, new hero copy, SEO LD
Three threads, one commit:
1. Size-tiered listing fee instead of a flat $1
priceUsdForSize(bytes) in service/payments.js:
0 - 100 MiB → free
100.001 - 200 MiB → $1
200.001 - 300 MiB → $2
...
1900.001 - 2000 MiB → $19
2000.001 - 2048 MiB → $20 (ceiling at 2 GiB)
listingNeedsPayment(bytes) replaces paymentsRequired() — a free-tier
upload always publishes immediately even when PAYMENT_REQUIRED=1; the
operator switch only governs the paid tiers. createInvoice derives the
fee from the listing's own size at invoice time.
2. Upload cap back to 2 GiB
The 200-MiB cap from the previous commit (which fixed the Caddy-413
HTML bug) is reverted. The paywall is now the real size gate; Caddy
is just the ceiling. p2pexchange Caddyfile already updated + reloaded.
3. Hero + SEO
Hero leads with the plain-English product category:
The decentralized app store.
A home for apps, programs, dApps, and libraries — listed and settled
on the Bitcoin Cash chain, hosted on the decentralized web, endorsed
by signature. Anyone can publish. Nobody can gatekeep.
Named for the one who gave fire to humans and did not ask the gods first.
"hosted on Sia" → "hosted on the decentralized web". Sia is the
mechanism, not the pitch — general readers aren't tripped on the
acronym on the hero.
Meta description rewritten to lead with "decentralized app store" and
name the long-tail terms the audience searches for ("web3 browsers",
"BCH wallets", "mobile and desktop apps").
Two JSON-LD blocks (WebSite + Organization) inlined on home via
layout's headExtra channel — knowledge panel + WebSite result card.
4. Submit wizard
Step 1 result shows the computed listing fee under the scan verdict,
so publishers see the price BEFORE filling out the manifest.
Step 4 publish branches on the server's payment_required flag. On a
paid listing it POSTs /api/invoice right after the listing POST and
renders a payment card with the exact sat amount, the operator receive
address, a `bitcoincash:` URI the wallet opens, and the invoice status
URL for polling.
2026-10-22 23:31:43 +03:00
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
/**
|
|
|
|
|
* Create a new invoice for a listing. Returns the invoice row.
|
prometheus: size-tiered pricing, 2 GiB cap, new hero copy, SEO LD
Three threads, one commit:
1. Size-tiered listing fee instead of a flat $1
priceUsdForSize(bytes) in service/payments.js:
0 - 100 MiB → free
100.001 - 200 MiB → $1
200.001 - 300 MiB → $2
...
1900.001 - 2000 MiB → $19
2000.001 - 2048 MiB → $20 (ceiling at 2 GiB)
listingNeedsPayment(bytes) replaces paymentsRequired() — a free-tier
upload always publishes immediately even when PAYMENT_REQUIRED=1; the
operator switch only governs the paid tiers. createInvoice derives the
fee from the listing's own size at invoice time.
2. Upload cap back to 2 GiB
The 200-MiB cap from the previous commit (which fixed the Caddy-413
HTML bug) is reverted. The paywall is now the real size gate; Caddy
is just the ceiling. p2pexchange Caddyfile already updated + reloaded.
3. Hero + SEO
Hero leads with the plain-English product category:
The decentralized app store.
A home for apps, programs, dApps, and libraries — listed and settled
on the Bitcoin Cash chain, hosted on the decentralized web, endorsed
by signature. Anyone can publish. Nobody can gatekeep.
Named for the one who gave fire to humans and did not ask the gods first.
"hosted on Sia" → "hosted on the decentralized web". Sia is the
mechanism, not the pitch — general readers aren't tripped on the
acronym on the hero.
Meta description rewritten to lead with "decentralized app store" and
name the long-tail terms the audience searches for ("web3 browsers",
"BCH wallets", "mobile and desktop apps").
Two JSON-LD blocks (WebSite + Organization) inlined on home via
layout's headExtra channel — knowledge panel + WebSite result card.
4. Submit wizard
Step 1 result shows the computed listing fee under the scan verdict,
so publishers see the price BEFORE filling out the manifest.
Step 4 publish branches on the server's payment_required flag. On a
paid listing it POSTs /api/invoice right after the listing POST and
renders a payment card with the exact sat amount, the operator receive
address, a `bitcoincash:` URI the wallet opens, and the invoice status
URL for polling.
2026-10-22 23:31:43 +03:00
|
|
|
* Throws if the listing's size falls in the free tier (caller shouldn't ask).
|
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
|
|
|
*/
|
|
|
|
|
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}`);
|
|
|
|
|
|
prometheus: size-tiered pricing, 2 GiB cap, new hero copy, SEO LD
Three threads, one commit:
1. Size-tiered listing fee instead of a flat $1
priceUsdForSize(bytes) in service/payments.js:
0 - 100 MiB → free
100.001 - 200 MiB → $1
200.001 - 300 MiB → $2
...
1900.001 - 2000 MiB → $19
2000.001 - 2048 MiB → $20 (ceiling at 2 GiB)
listingNeedsPayment(bytes) replaces paymentsRequired() — a free-tier
upload always publishes immediately even when PAYMENT_REQUIRED=1; the
operator switch only governs the paid tiers. createInvoice derives the
fee from the listing's own size at invoice time.
2. Upload cap back to 2 GiB
The 200-MiB cap from the previous commit (which fixed the Caddy-413
HTML bug) is reverted. The paywall is now the real size gate; Caddy
is just the ceiling. p2pexchange Caddyfile already updated + reloaded.
3. Hero + SEO
Hero leads with the plain-English product category:
The decentralized app store.
A home for apps, programs, dApps, and libraries — listed and settled
on the Bitcoin Cash chain, hosted on the decentralized web, endorsed
by signature. Anyone can publish. Nobody can gatekeep.
Named for the one who gave fire to humans and did not ask the gods first.
"hosted on Sia" → "hosted on the decentralized web". Sia is the
mechanism, not the pitch — general readers aren't tripped on the
acronym on the hero.
Meta description rewritten to lead with "decentralized app store" and
name the long-tail terms the audience searches for ("web3 browsers",
"BCH wallets", "mobile and desktop apps").
Two JSON-LD blocks (WebSite + Organization) inlined on home via
layout's headExtra channel — knowledge panel + WebSite result card.
4. Submit wizard
Step 1 result shows the computed listing fee under the scan verdict,
so publishers see the price BEFORE filling out the manifest.
Step 4 publish branches on the server's payment_required flag. On a
paid listing it POSTs /api/invoice right after the listing POST and
renders a payment card with the exact sat amount, the operator receive
address, a `bitcoincash:` URI the wallet opens, and the invoice status
URL for polling.
2026-10-22 23:31:43 +03:00
|
|
|
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);
|
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
|
|
|
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,
|
prometheus: size-tiered pricing, 2 GiB cap, new hero copy, SEO LD
Three threads, one commit:
1. Size-tiered listing fee instead of a flat $1
priceUsdForSize(bytes) in service/payments.js:
0 - 100 MiB → free
100.001 - 200 MiB → $1
200.001 - 300 MiB → $2
...
1900.001 - 2000 MiB → $19
2000.001 - 2048 MiB → $20 (ceiling at 2 GiB)
listingNeedsPayment(bytes) replaces paymentsRequired() — a free-tier
upload always publishes immediately even when PAYMENT_REQUIRED=1; the
operator switch only governs the paid tiers. createInvoice derives the
fee from the listing's own size at invoice time.
2. Upload cap back to 2 GiB
The 200-MiB cap from the previous commit (which fixed the Caddy-413
HTML bug) is reverted. The paywall is now the real size gate; Caddy
is just the ceiling. p2pexchange Caddyfile already updated + reloaded.
3. Hero + SEO
Hero leads with the plain-English product category:
The decentralized app store.
A home for apps, programs, dApps, and libraries — listed and settled
on the Bitcoin Cash chain, hosted on the decentralized web, endorsed
by signature. Anyone can publish. Nobody can gatekeep.
Named for the one who gave fire to humans and did not ask the gods first.
"hosted on Sia" → "hosted on the decentralized web". Sia is the
mechanism, not the pitch — general readers aren't tripped on the
acronym on the hero.
Meta description rewritten to lead with "decentralized app store" and
name the long-tail terms the audience searches for ("web3 browsers",
"BCH wallets", "mobile and desktop apps").
Two JSON-LD blocks (WebSite + Organization) inlined on home via
layout's headExtra channel — knowledge panel + WebSite result card.
4. Submit wizard
Step 1 result shows the computed listing fee under the scan verdict,
so publishers see the price BEFORE filling out the manifest.
Step 4 publish branches on the server's payment_required flag. On a
paid listing it POSTs /api/invoice right after the listing POST and
renders a payment card with the exact sat amount, the operator receive
address, a `bitcoincash:` URI the wallet opens, and the invoice status
URL for polling.
2026-10-22 23:31:43 +03:00
|
|
|
amount_usd: String(usd),
|
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
|
|
|
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');
|
|
|
|
|
}
|