@@ -260,6 +277,12 @@ const { fetch: fetchSession } = useUserSession();
const router = useRouter();
const { data: status } = await useFetch('/api/auth/status');
+const { data: paidInfo } = await useFetch('/api/payment/info');
+const priceList = computed(() =>
+ (paidInfo.value?.options ?? [])
+ .map((o) => `${o.price} ${o.currency.toUpperCase()}`)
+ .join(' / ')
+);
const { data: branding } = await useFetch<{
siteName: string;
siteLogo: string;
@@ -358,7 +381,7 @@ async function handleRegister() {
// Step 4: Register (password never sent to server)
authStatus.value = t('auth.register.creatingAccount');
- await $fetch('/api/auth/register', {
+ const result = await $fetch('/api/auth/register', {
method: 'POST',
body: {
username: form.username,
@@ -375,7 +398,8 @@ async function handleRegister() {
// Refresh session and redirect
await fetchSession();
- router.push('/');
+ // Paid registration: locked until the payment is confirmed
+ router.push(result.user.paymentPending ? '/auth/payment' : '/');
} catch (err: any) {
error.value = err.data?.message || t('auth.register.failed');
} finally {
diff --git a/doc/.vitepress/config.ts b/doc/.vitepress/config.ts
index b4c1236f..6c7e2f14 100644
--- a/doc/.vitepress/config.ts
+++ b/doc/.vitepress/config.ts
@@ -48,6 +48,7 @@ export default defineConfig({
{ text: 'Getting Started', link: '/guide/getting-started' },
{ text: 'Configuration', link: '/guide/configuration' },
{ text: 'Appearance', link: '/guide/appearance' },
+ { text: 'Paid Registration', link: '/guide/paid-registration' },
{ text: 'Monitoring', link: '/guide/monitoring' },
{ text: 'Backup & Restore', link: '/guide/backup-restore' },
{ text: 'High Availability', link: '/guide/high-availability' },
diff --git a/doc/guide/paid-registration.md b/doc/guide/paid-registration.md
new file mode 100644
index 00000000..a5733192
--- /dev/null
+++ b/doc/guide/paid-registration.md
@@ -0,0 +1,79 @@
+# Paid Registration
+
+Trackarr can charge a sign-up fee in **Bitcoin**, **Monero** or **Solana**. The account is created at once but stays **locked**: its owner only sees the payment page, and the API, RSS feeds and Torznab refuse it. Once the payment is confirmed on the blockchain, the account is unlocked automatically.
+
+Verification is self-hosted: no payment processor, and **no private key on the server**.
+
+| Coin | Receiving side | How the payment is found |
+| ------- | ------------------------------------------------- | --------------------------------------------------------- |
+| Bitcoin | A new address per invoice, from your **zpub/xpub** | Esplora API (`mempool.space` by default, or self-hosted) |
+| Monero | A new subaddress per invoice | Your view-only `monero-wallet-rpc` |
+| Solana | Your address, with a Solana Pay `reference` key | Solana JSON-RPC (`getSignaturesForAddress`) |
+
+## Setup
+
+Open **Admin → Payments**:
+
+1. Turn on **Paid registration**.
+2. Choose whether **invited users pay too** (off: an invite code skips the payment).
+3. Set the **payment delay**: locked accounts on which nothing arrived are deleted after it (the invite they used, if any, becomes usable again). Accounts with money on the way are never deleted: settle them by hand.
+4. For each coin: switch it on, set a fixed price, then its receiving side (below).
+
+Registration must still be open (or invite based): payment is an extra step, not a way in.
+
+### Bitcoin
+
+Paste the **extended public key** of a native SegWit (BIP84) account: `zpub…` (or `xpub…` from wallets that export BIP84 keys that way; `vpub`/`tpub` for testnet). Each invoice uses the next receive address `…/0/n`.
+
+- Sparrow: *Settings → Keystores → Master fingerprint / xpub* of a Native SegWit wallet.
+- Electrum: *Wallet → Information → Master Public Key*.
+
+Use a **dedicated account** for registrations. Payments made to an address before its invoice was created are ignored anyway, so an address that was already used cannot unlock an account by itself.
+
+::: warning Gap limit
+Wallets stop scanning after 20 unused addresses. Unpaid invoices leave gaps: raise the gap limit of your wallet (for example to 1000) so it shows every payment.
+:::
+
+`Confirmations required` defaults to 2. The Esplora URL defaults to `https://mempool.space/api`; point it at your own instance (mempool, electrs/esplora) for privacy.
+
+### Monero
+
+The app asks a **view-only** wallet for a new subaddress per invoice and reads its incoming transfers. A view-only wallet cannot spend.
+
+1. Create the view-only wallet from your primary address and **private view key** (Feather: *Wallet → Keys*):
+
+ ```bash
+ monero-wallet-cli --generate-from-view-key trackarr-view --daemon-address node.example.com:18089
+ ```
+
+2. Serve it with `monero-wallet-rpc` on the private Docker network, never on a public port (the app talks to it without a login):
+
+ ```bash
+ monero-wallet-rpc --wallet-file /wallet/trackarr-view --password-file /wallet/password \
+ --daemon-address node.example.com:18089 --trusted-daemon \
+ --rpc-bind-ip 0.0.0.0 --rpc-bind-port 18082 --confirm-external-bind --disable-rpc-login
+ ```
+
+3. Set `MONERO_WALLET_RPC_URL=http://monero-wallet-rpc:18082` in `.env` and restart the app. The admin page shows whether it is configured.
+
+`Confirmations required` defaults to 10 (the Monero unlock time).
+
+### Solana
+
+Paste your **receiving address**. Each invoice gets a random Solana Pay `reference` key, carried by the wallet link on the payment page: this is how the transfer is matched to the account. A plain transfer copied by hand, without the reference, cannot be matched: unlock it from the admin page. Only native SOL is counted (no SPL tokens).
+
+The public RPC (`https://api.mainnet-beta.solana.com`) is rate limited: use a dedicated RPC provider for a busy tracker. An account is unlocked once the transaction is finalized.
+
+## How it works
+
+- **Invoices**: one per account and coin, created when the user picks the coin. The amount is locked at creation: changing the price later does not affect it.
+- **Underpayment**: what arrives is added up; the user can send the difference to the same address.
+- **Watcher**: every `PAYMENT_CHECK_INTERVAL` ms (60 s by default) one app node checks every open invoice, unlocks paid accounts and deletes expired ones. The payment page also asks for a check every 30 s (at most once per 20 s per account).
+- **Manual unlock**: *Admin → Payments → Locked accounts → Unlock*, for a late or off-chain payment. Recorded as manual.
+
+## Environment
+
+| Variable | Description | Default |
+| ------------------------ | ------------------------------------------------------------ | ------- |
+| `MONERO_WALLET_RPC_URL` | View-only `monero-wallet-rpc` (empty: Monero is not offered) | - |
+| `PAYMENT_CHECK_INTERVAL` | Payment watcher interval in ms | `60000` |
diff --git a/doc/reference/env.md b/doc/reference/env.md
index 06bf4ee1..2a677590 100644
--- a/doc/reference/env.md
+++ b/doc/reference/env.md
@@ -96,6 +96,15 @@ The web app reaches the tracker's private API with:
| `TRACKER_INTERNAL_URL` | Tracker private API URL | `http://localhost:8090` (`http://tracker:8090` in Docker Compose) |
| `TRACKER_INTERNAL_TOKEN` | Same value as the tracker | — |
+## Paid Registration
+
+See [Paid Registration](/guide/paid-registration).
+
+| Variable | Description | Default |
+| ------------------------ | ------------------------------------------------------------ | ------- |
+| `MONERO_WALLET_RPC_URL` | View-only `monero-wallet-rpc` (empty: Monero is not offered) | - |
+| `PAYMENT_CHECK_INTERVAL` | Payment watcher interval in ms | `60000` |
+
## Monitoring
Production only. See [Monitoring](/guide/monitoring).
diff --git a/docker-compose.local.yml b/docker-compose.local.yml
index abba3be3..d60101a5 100644
--- a/docker-compose.local.yml
+++ b/docker-compose.local.yml
@@ -48,6 +48,8 @@ services:
# Tracker service private API (swarm peers, status)
TRACKER_INTERNAL_URL: http://tracker:8090
TRACKER_INTERNAL_TOKEN: ${TRACKER_INTERNAL_TOKEN:?TRACKER_INTERNAL_TOKEN is required}
+ # Paid registration: view-only monero-wallet-rpc (empty: Monero not offered)
+ MONERO_WALLET_RPC_URL: ${MONERO_WALLET_RPC_URL:-}
ports:
- "3000:3000" # Web UI
volumes:
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
index a8b5eb29..db208576 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -85,6 +85,9 @@ services:
# Tracker service private API (swarm peers, status)
TRACKER_INTERNAL_URL: http://tracker:8090
TRACKER_INTERNAL_TOKEN: ${TRACKER_INTERNAL_TOKEN:?TRACKER_INTERNAL_TOKEN is required}
+ # Paid registration: view-only monero-wallet-rpc (empty: Monero not offered)
+ MONERO_WALLET_RPC_URL: ${MONERO_WALLET_RPC_URL:-}
+ PAYMENT_CHECK_INTERVAL: ${PAYMENT_CHECK_INTERVAL:-60000}
expose:
- "3000"
volumes:
diff --git a/docker-compose.yml b/docker-compose.yml
index c03516a3..c43062f5 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -25,6 +25,8 @@ services:
# Tracker service private API (swarm peers, status)
- TRACKER_INTERNAL_URL=http://tracker:8090
- TRACKER_INTERNAL_TOKEN=${TRACKER_INTERNAL_TOKEN:-dev-internal-token}
+ # Paid registration: view-only monero-wallet-rpc (empty: Monero not offered)
+ - MONERO_WALLET_RPC_URL=${MONERO_WALLET_RPC_URL:-}
volumes:
- .:/app
- /app/node_modules
diff --git a/i18n/locales/de/payments.json b/i18n/locales/de/payments.json
new file mode 100644
index 00000000..90aa17f5
--- /dev/null
+++ b/i18n/locales/de/payments.json
@@ -0,0 +1,62 @@
+{
+ "payments": {
+ "adminNav": {
+ "label": "Zahlungen",
+ "description": "Kostenpflichtige Registrierung mit Bitcoin, Monero und Solana"
+ },
+ "register": {
+ "notice": "Die Registrierung ist kostenpflichtig: {prices}.",
+ "unlock": "Dein Konto wird sofort erstellt und freigeschaltet, sobald die Zahlung auf der Blockchain bestätigt ist. Unbezahlte Konten werden nach {days} Tagen gelöscht.",
+ "inviteFree": "Mit einem Einladungscode ist die Registrierung kostenlos."
+ },
+ "page": {
+ "title": "Konto aktivieren",
+ "intro": "Bezahle die Registrierungsgebühr in einer der akzeptierten Kryptowährungen. Dein Konto wird automatisch freigeschaltet, sobald die Zahlung bestätigt ist.",
+ "loading": "Wird geladen...",
+ "unavailable": "Derzeit ist keine Zahlungsmethode verfügbar. Bitte kontaktiere das Team.",
+ "chooseCurrency": "Bezahlen mit",
+ "amountDue": "Zu sendender Betrag",
+ "seen": "Erhalten: {confirmed} bestätigt, {unconfirmed} ausstehend, von {total}",
+ "address": "Adresse",
+ "solanaReference": "Nutze die Wallet-Schaltfläche unten: Der Link enthält eine Zahlungsreferenz, die dein Konto identifiziert. Eine einfache Überweisung an diese Adresse kann nicht zugeordnet werden.",
+ "openWallet": "In Wallet öffnen",
+ "confirmationsHint": "Freigeschaltet nach {count} Bestätigungen.",
+ "finalizedHint": "Freigeschaltet, sobald die Transaktion finalisiert ist (unter einer Minute).",
+ "checkNow": "Ich habe bezahlt: jetzt prüfen",
+ "deadline": "Ohne Zahlung wird dieses Konto am {date} gelöscht.",
+ "logout": "Abmelden",
+ "failed": "Etwas ist schiefgelaufen, bitte erneut versuchen"
+ },
+ "status": {
+ "pending": "Warte auf Zahlung",
+ "confirming": "Zahlung erkannt, warte auf Bestätigungen",
+ "paid": "Bezahlt",
+ "cancelled": "Storniert"
+ },
+ "admin": {
+ "title": "Kostenpflichtige Registrierung",
+ "enabledLabel": "Kostenpflichtige Registrierung",
+ "enabledDescription": "Neue Konten bleiben gesperrt, bis ihre Zahlung on-chain bestätigt ist",
+ "invitesPayLabel": "Eingeladene zahlen auch",
+ "invitesPayDescription": "Aus: Ein Einladungscode erspart die Zahlung",
+ "pendingDaysLabel": "Zahlungsfrist (Tage)",
+ "pendingDaysDescription": "Gesperrte Konten ohne Zahlungseingang werden nach dieser Frist gelöscht",
+ "price": "Preis",
+ "confirmations": "Erforderliche Bestätigungen",
+ "xpub": "Erweiterter öffentlicher Schlüssel (zpub / xpub)",
+ "xpubHint": "Native-SegWit-Kontoschlüssel (BIP84). Niemals einen privaten Schlüssel einfügen. Erhöhe das Gap-Limit deiner Wallet, damit sie alle Adressen sieht.",
+ "esploraUrl": "Esplora-API",
+ "moneroReady": "Wallet-RPC konfiguriert (MONERO_WALLET_RPC_URL)",
+ "moneroMissing": "Setze MONERO_WALLET_RPC_URL auf einen View-only monero-wallet-rpc, um Monero zu akzeptieren",
+ "solAddress": "Empfangsadresse",
+ "solRpcUrl": "Solana-RPC",
+ "saveFailed": "Einstellungen konnten nicht gespeichert werden",
+ "pendingTitle": "Gesperrte Konten",
+ "noPending": "Kein Konto wartet auf Zahlung",
+ "approve": "Freischalten",
+ "paidTitle": "Letzte Zahlungen",
+ "noPaid": "Noch keine Zahlung",
+ "manual": "manuell"
+ }
+ }
+}
diff --git a/i18n/locales/en/payments.json b/i18n/locales/en/payments.json
new file mode 100644
index 00000000..cf0830e6
--- /dev/null
+++ b/i18n/locales/en/payments.json
@@ -0,0 +1,62 @@
+{
+ "payments": {
+ "adminNav": {
+ "label": "Payments",
+ "description": "Paid registration in Bitcoin, Monero and Solana"
+ },
+ "register": {
+ "notice": "Registration is paid: {prices}.",
+ "unlock": "Your account is created now and unlocked once the payment is confirmed on the blockchain. Unpaid accounts are deleted after {days} days.",
+ "inviteFree": "Registration with an invite code is free."
+ },
+ "page": {
+ "title": "Activate your account",
+ "intro": "Pay the registration fee in one of the accepted coins. Your account is unlocked automatically once the payment is confirmed.",
+ "loading": "Loading...",
+ "unavailable": "No payment method is available right now. Please contact the staff.",
+ "chooseCurrency": "Pay with",
+ "amountDue": "Amount to send",
+ "seen": "Received: {confirmed} confirmed, {unconfirmed} pending, out of {total}",
+ "address": "Address",
+ "solanaReference": "Use the wallet button below: the link carries a payment reference that identifies your account. A plain transfer to this address cannot be matched.",
+ "openWallet": "Open in wallet",
+ "confirmationsHint": "Unlocked after {count} confirmations.",
+ "finalizedHint": "Unlocked once the transaction is finalized (under a minute).",
+ "checkNow": "I have paid: check now",
+ "deadline": "Without a payment, this account is deleted on {date}.",
+ "logout": "Sign out",
+ "failed": "Something went wrong, please try again"
+ },
+ "status": {
+ "pending": "Waiting for payment",
+ "confirming": "Payment seen, waiting for confirmations",
+ "paid": "Paid",
+ "cancelled": "Cancelled"
+ },
+ "admin": {
+ "title": "Paid registration",
+ "enabledLabel": "Paid registration",
+ "enabledDescription": "New accounts stay locked until their payment is confirmed on-chain",
+ "invitesPayLabel": "Invited users pay too",
+ "invitesPayDescription": "Off: an invite code skips the payment",
+ "pendingDaysLabel": "Payment delay (days)",
+ "pendingDaysDescription": "Locked accounts that received nothing are deleted after this delay",
+ "price": "Price",
+ "confirmations": "Confirmations required",
+ "xpub": "Extended public key (zpub / xpub)",
+ "xpubHint": "Native SegWit account key (BIP84). Never paste a private key. Raise the gap limit of your wallet so it sees every address.",
+ "esploraUrl": "Esplora API",
+ "moneroReady": "Wallet RPC configured (MONERO_WALLET_RPC_URL)",
+ "moneroMissing": "Set MONERO_WALLET_RPC_URL to a view-only monero-wallet-rpc to accept Monero",
+ "solAddress": "Receiving address",
+ "solRpcUrl": "Solana RPC",
+ "saveFailed": "Could not save the settings",
+ "pendingTitle": "Locked accounts",
+ "noPending": "No account is waiting for payment",
+ "approve": "Unlock",
+ "paidTitle": "Recent payments",
+ "noPaid": "No payment yet",
+ "manual": "manual"
+ }
+ }
+}
diff --git a/i18n/locales/es/payments.json b/i18n/locales/es/payments.json
new file mode 100644
index 00000000..484dc542
--- /dev/null
+++ b/i18n/locales/es/payments.json
@@ -0,0 +1,62 @@
+{
+ "payments": {
+ "adminNav": {
+ "label": "Pagos",
+ "description": "Registro de pago en Bitcoin, Monero y Solana"
+ },
+ "register": {
+ "notice": "El registro es de pago: {prices}.",
+ "unlock": "Tu cuenta se crea ahora y se desbloquea cuando el pago se confirma en la blockchain. Las cuentas impagadas se eliminan tras {days} días.",
+ "inviteFree": "El registro con código de invitación es gratuito."
+ },
+ "page": {
+ "title": "Activa tu cuenta",
+ "intro": "Paga la cuota de registro en una de las criptomonedas aceptadas. Tu cuenta se desbloquea automáticamente cuando se confirma el pago.",
+ "loading": "Cargando...",
+ "unavailable": "No hay ningún método de pago disponible ahora. Contacta con el equipo.",
+ "chooseCurrency": "Pagar con",
+ "amountDue": "Importe a enviar",
+ "seen": "Recibido: {confirmed} confirmado, {unconfirmed} pendiente, de {total}",
+ "address": "Dirección",
+ "solanaReference": "Usa el botón de monedero de abajo: el enlace lleva una referencia de pago que identifica tu cuenta. Una transferencia simple a esta dirección no se puede asociar.",
+ "openWallet": "Abrir en el monedero",
+ "confirmationsHint": "Desbloqueada tras {count} confirmaciones.",
+ "finalizedHint": "Desbloqueada cuando la transacción se finaliza (menos de un minuto).",
+ "checkNow": "He pagado: comprobar ahora",
+ "deadline": "Sin pago, esta cuenta se eliminará el {date}.",
+ "logout": "Cerrar sesión",
+ "failed": "Algo salió mal, inténtalo de nuevo"
+ },
+ "status": {
+ "pending": "Esperando el pago",
+ "confirming": "Pago detectado, esperando confirmaciones",
+ "paid": "Pagado",
+ "cancelled": "Cancelado"
+ },
+ "admin": {
+ "title": "Registro de pago",
+ "enabledLabel": "Registro de pago",
+ "enabledDescription": "Las cuentas nuevas quedan bloqueadas hasta que su pago se confirma en la cadena",
+ "invitesPayLabel": "Los invitados también pagan",
+ "invitesPayDescription": "Desactivado: un código de invitación evita el pago",
+ "pendingDaysLabel": "Plazo de pago (días)",
+ "pendingDaysDescription": "Las cuentas bloqueadas que no recibieron nada se eliminan tras este plazo",
+ "price": "Precio",
+ "confirmations": "Confirmaciones requeridas",
+ "xpub": "Clave pública extendida (zpub / xpub)",
+ "xpubHint": "Clave de cuenta SegWit nativa (BIP84). Nunca pegues una clave privada. Aumenta el gap limit de tu monedero para que vea todas las direcciones.",
+ "esploraUrl": "API Esplora",
+ "moneroReady": "Wallet RPC configurado (MONERO_WALLET_RPC_URL)",
+ "moneroMissing": "Define MONERO_WALLET_RPC_URL hacia un monero-wallet-rpc de solo lectura para aceptar Monero",
+ "solAddress": "Dirección de recepción",
+ "solRpcUrl": "RPC de Solana",
+ "saveFailed": "No se pudieron guardar los ajustes",
+ "pendingTitle": "Cuentas bloqueadas",
+ "noPending": "Ninguna cuenta espera un pago",
+ "approve": "Desbloquear",
+ "paidTitle": "Pagos recientes",
+ "noPaid": "Aún no hay pagos",
+ "manual": "manual"
+ }
+ }
+}
diff --git a/i18n/locales/fr/payments.json b/i18n/locales/fr/payments.json
new file mode 100644
index 00000000..5adad7bd
--- /dev/null
+++ b/i18n/locales/fr/payments.json
@@ -0,0 +1,62 @@
+{
+ "payments": {
+ "adminNav": {
+ "label": "Paiements",
+ "description": "Inscription payante en Bitcoin, Monero et Solana"
+ },
+ "register": {
+ "notice": "L'inscription est payante : {prices}.",
+ "unlock": "Votre compte est créé tout de suite et débloqué dès que le paiement est confirmé sur la blockchain. Les comptes non payés sont supprimés après {days} jours.",
+ "inviteFree": "L'inscription avec un code d'invitation est gratuite."
+ },
+ "page": {
+ "title": "Activez votre compte",
+ "intro": "Réglez les frais d'inscription dans l'une des cryptomonnaies acceptées. Votre compte est débloqué automatiquement dès que le paiement est confirmé.",
+ "loading": "Chargement...",
+ "unavailable": "Aucun moyen de paiement n'est disponible pour le moment. Contactez l'équipe.",
+ "chooseCurrency": "Payer en",
+ "amountDue": "Montant à envoyer",
+ "seen": "Reçu : {confirmed} confirmé, {unconfirmed} en attente, sur {total}",
+ "address": "Adresse",
+ "solanaReference": "Utilisez le bouton portefeuille ci-dessous : le lien contient une référence de paiement qui identifie votre compte. Un simple virement vers cette adresse ne peut pas être rattaché.",
+ "openWallet": "Ouvrir dans le portefeuille",
+ "confirmationsHint": "Débloqué après {count} confirmations.",
+ "finalizedHint": "Débloqué dès que la transaction est finalisée (moins d'une minute).",
+ "checkNow": "J'ai payé : vérifier maintenant",
+ "deadline": "Sans paiement, ce compte sera supprimé le {date}.",
+ "logout": "Se déconnecter",
+ "failed": "Une erreur est survenue, réessayez"
+ },
+ "status": {
+ "pending": "En attente du paiement",
+ "confirming": "Paiement détecté, en attente de confirmations",
+ "paid": "Payé",
+ "cancelled": "Annulé"
+ },
+ "admin": {
+ "title": "Inscription payante",
+ "enabledLabel": "Inscription payante",
+ "enabledDescription": "Les nouveaux comptes restent verrouillés jusqu'à la confirmation de leur paiement on-chain",
+ "invitesPayLabel": "Les invités paient aussi",
+ "invitesPayDescription": "Désactivé : un code d'invitation dispense du paiement",
+ "pendingDaysLabel": "Délai de paiement (jours)",
+ "pendingDaysDescription": "Les comptes verrouillés qui n'ont rien reçu sont supprimés après ce délai",
+ "price": "Prix",
+ "confirmations": "Confirmations requises",
+ "xpub": "Clé publique étendue (zpub / xpub)",
+ "xpubHint": "Clé de compte SegWit natif (BIP84). Ne collez jamais une clé privée. Augmentez le gap limit de votre portefeuille pour qu'il voie toutes les adresses.",
+ "esploraUrl": "API Esplora",
+ "moneroReady": "Wallet RPC configuré (MONERO_WALLET_RPC_URL)",
+ "moneroMissing": "Définissez MONERO_WALLET_RPC_URL vers un monero-wallet-rpc en lecture seule pour accepter Monero",
+ "solAddress": "Adresse de réception",
+ "solRpcUrl": "RPC Solana",
+ "saveFailed": "Impossible d'enregistrer les réglages",
+ "pendingTitle": "Comptes verrouillés",
+ "noPending": "Aucun compte en attente de paiement",
+ "approve": "Débloquer",
+ "paidTitle": "Paiements récents",
+ "noPaid": "Aucun paiement pour l'instant",
+ "manual": "manuel"
+ }
+ }
+}
diff --git a/i18n/locales/it/payments.json b/i18n/locales/it/payments.json
new file mode 100644
index 00000000..1c29616f
--- /dev/null
+++ b/i18n/locales/it/payments.json
@@ -0,0 +1,62 @@
+{
+ "payments": {
+ "adminNav": {
+ "label": "Pagamenti",
+ "description": "Registrazione a pagamento in Bitcoin, Monero e Solana"
+ },
+ "register": {
+ "notice": "La registrazione è a pagamento: {prices}.",
+ "unlock": "Il tuo account viene creato subito e sbloccato quando il pagamento è confermato sulla blockchain. Gli account non pagati vengono eliminati dopo {days} giorni.",
+ "inviteFree": "La registrazione con un codice di invito è gratuita."
+ },
+ "page": {
+ "title": "Attiva il tuo account",
+ "intro": "Paga la quota di registrazione in una delle criptovalute accettate. Il tuo account viene sbloccato automaticamente quando il pagamento è confermato.",
+ "loading": "Caricamento...",
+ "unavailable": "Nessun metodo di pagamento è disponibile al momento. Contatta lo staff.",
+ "chooseCurrency": "Paga con",
+ "amountDue": "Importo da inviare",
+ "seen": "Ricevuto: {confirmed} confermato, {unconfirmed} in attesa, su {total}",
+ "address": "Indirizzo",
+ "solanaReference": "Usa il pulsante del wallet qui sotto: il link contiene un riferimento di pagamento che identifica il tuo account. Un semplice trasferimento a questo indirizzo non può essere associato.",
+ "openWallet": "Apri nel wallet",
+ "confirmationsHint": "Sbloccato dopo {count} conferme.",
+ "finalizedHint": "Sbloccato quando la transazione è finalizzata (meno di un minuto).",
+ "checkNow": "Ho pagato: verifica ora",
+ "deadline": "Senza pagamento, questo account verrà eliminato il {date}.",
+ "logout": "Esci",
+ "failed": "Qualcosa è andato storto, riprova"
+ },
+ "status": {
+ "pending": "In attesa del pagamento",
+ "confirming": "Pagamento rilevato, in attesa di conferme",
+ "paid": "Pagato",
+ "cancelled": "Annullato"
+ },
+ "admin": {
+ "title": "Registrazione a pagamento",
+ "enabledLabel": "Registrazione a pagamento",
+ "enabledDescription": "I nuovi account restano bloccati finché il pagamento non è confermato on-chain",
+ "invitesPayLabel": "Anche gli invitati pagano",
+ "invitesPayDescription": "Disattivato: un codice di invito evita il pagamento",
+ "pendingDaysLabel": "Termine di pagamento (giorni)",
+ "pendingDaysDescription": "Gli account bloccati che non hanno ricevuto nulla vengono eliminati dopo questo termine",
+ "price": "Prezzo",
+ "confirmations": "Conferme richieste",
+ "xpub": "Chiave pubblica estesa (zpub / xpub)",
+ "xpubHint": "Chiave di account SegWit nativo (BIP84). Non incollare mai una chiave privata. Aumenta il gap limit del wallet perché veda tutti gli indirizzi.",
+ "esploraUrl": "API Esplora",
+ "moneroReady": "Wallet RPC configurato (MONERO_WALLET_RPC_URL)",
+ "moneroMissing": "Imposta MONERO_WALLET_RPC_URL su un monero-wallet-rpc in sola lettura per accettare Monero",
+ "solAddress": "Indirizzo di ricezione",
+ "solRpcUrl": "RPC Solana",
+ "saveFailed": "Impossibile salvare le impostazioni",
+ "pendingTitle": "Account bloccati",
+ "noPending": "Nessun account in attesa di pagamento",
+ "approve": "Sblocca",
+ "paidTitle": "Pagamenti recenti",
+ "noPaid": "Ancora nessun pagamento",
+ "manual": "manuale"
+ }
+ }
+}
diff --git a/i18n/locales/ja/payments.json b/i18n/locales/ja/payments.json
new file mode 100644
index 00000000..84d33b21
--- /dev/null
+++ b/i18n/locales/ja/payments.json
@@ -0,0 +1,62 @@
+{
+ "payments": {
+ "adminNav": {
+ "label": "支払い",
+ "description": "Bitcoin・Monero・Solana による有料登録"
+ },
+ "register": {
+ "notice": "登録は有料です: {prices}。",
+ "unlock": "アカウントはすぐに作成され、ブロックチェーン上で支払いが確認されると有効になります。未払いのアカウントは {days} 日後に削除されます。",
+ "inviteFree": "招待コードを使った登録は無料です。"
+ },
+ "page": {
+ "title": "アカウントを有効化",
+ "intro": "対応する暗号資産のいずれかで登録料を支払ってください。支払いが確認されると自動的にアカウントが有効になります。",
+ "loading": "読み込み中...",
+ "unavailable": "現在利用できる支払い方法がありません。スタッフに連絡してください。",
+ "chooseCurrency": "支払い方法",
+ "amountDue": "送金額",
+ "seen": "受領: 確認済み {confirmed}、未確認 {unconfirmed}、合計 {total}",
+ "address": "アドレス",
+ "solanaReference": "下のウォレットボタンを使ってください。リンクにはアカウントを識別する支払い参照が含まれます。このアドレスへの通常の送金は照合できません。",
+ "openWallet": "ウォレットで開く",
+ "confirmationsHint": "{count} 承認後に有効になります。",
+ "finalizedHint": "トランザクションが確定すると有効になります (1 分未満)。",
+ "checkNow": "支払い済み: 今すぐ確認",
+ "deadline": "支払いがない場合、このアカウントは {date} に削除されます。",
+ "logout": "ログアウト",
+ "failed": "問題が発生しました。もう一度お試しください"
+ },
+ "status": {
+ "pending": "支払い待ち",
+ "confirming": "支払いを検出、承認待ち",
+ "paid": "支払い済み",
+ "cancelled": "キャンセル"
+ },
+ "admin": {
+ "title": "有料登録",
+ "enabledLabel": "有料登録",
+ "enabledDescription": "新しいアカウントはオンチェーンで支払いが確認されるまでロックされます",
+ "invitesPayLabel": "招待ユーザーも支払う",
+ "invitesPayDescription": "オフ: 招待コードで支払いが免除されます",
+ "pendingDaysLabel": "支払い期限 (日)",
+ "pendingDaysDescription": "何も受け取っていないロック中のアカウントはこの期限後に削除されます",
+ "price": "価格",
+ "confirmations": "必要な承認数",
+ "xpub": "拡張公開鍵 (zpub / xpub)",
+ "xpubHint": "ネイティブ SegWit のアカウント鍵 (BIP84)。秘密鍵は絶対に貼り付けないでください。すべてのアドレスが見えるようウォレットのギャップリミットを上げてください。",
+ "esploraUrl": "Esplora API",
+ "moneroReady": "ウォレット RPC 設定済み (MONERO_WALLET_RPC_URL)",
+ "moneroMissing": "Monero を受け付けるには MONERO_WALLET_RPC_URL に閲覧専用の monero-wallet-rpc を設定してください",
+ "solAddress": "受取アドレス",
+ "solRpcUrl": "Solana RPC",
+ "saveFailed": "設定を保存できませんでした",
+ "pendingTitle": "ロック中のアカウント",
+ "noPending": "支払い待ちのアカウントはありません",
+ "approve": "ロック解除",
+ "paidTitle": "最近の支払い",
+ "noPaid": "まだ支払いはありません",
+ "manual": "手動"
+ }
+ }
+}
diff --git a/i18n/locales/ru/payments.json b/i18n/locales/ru/payments.json
new file mode 100644
index 00000000..4e7971c6
--- /dev/null
+++ b/i18n/locales/ru/payments.json
@@ -0,0 +1,62 @@
+{
+ "payments": {
+ "adminNav": {
+ "label": "Платежи",
+ "description": "Платная регистрация в Bitcoin, Monero и Solana"
+ },
+ "register": {
+ "notice": "Регистрация платная: {prices}.",
+ "unlock": "Аккаунт создаётся сразу и разблокируется после подтверждения платежа в блокчейне. Неоплаченные аккаунты удаляются через {days} дн.",
+ "inviteFree": "Регистрация по коду приглашения бесплатна."
+ },
+ "page": {
+ "title": "Активируйте аккаунт",
+ "intro": "Оплатите регистрацию в одной из принимаемых криптовалют. Аккаунт разблокируется автоматически после подтверждения платежа.",
+ "loading": "Загрузка...",
+ "unavailable": "Сейчас нет доступных способов оплаты. Свяжитесь с администрацией.",
+ "chooseCurrency": "Оплатить в",
+ "amountDue": "Сумма к отправке",
+ "seen": "Получено: {confirmed} подтверждено, {unconfirmed} в ожидании, из {total}",
+ "address": "Адрес",
+ "solanaReference": "Используйте кнопку кошелька ниже: ссылка содержит платёжную метку, которая определяет ваш аккаунт. Простой перевод на этот адрес сопоставить нельзя.",
+ "openWallet": "Открыть в кошельке",
+ "confirmationsHint": "Разблокировка после {count} подтверждений.",
+ "finalizedHint": "Разблокировка после финализации транзакции (меньше минуты).",
+ "checkNow": "Я оплатил: проверить сейчас",
+ "deadline": "Без оплаты этот аккаунт будет удалён {date}.",
+ "logout": "Выйти",
+ "failed": "Что-то пошло не так, попробуйте ещё раз"
+ },
+ "status": {
+ "pending": "Ожидание оплаты",
+ "confirming": "Платёж обнаружен, ожидание подтверждений",
+ "paid": "Оплачено",
+ "cancelled": "Отменено"
+ },
+ "admin": {
+ "title": "Платная регистрация",
+ "enabledLabel": "Платная регистрация",
+ "enabledDescription": "Новые аккаунты заблокированы до подтверждения платежа в сети",
+ "invitesPayLabel": "Приглашённые тоже платят",
+ "invitesPayDescription": "Выключено: код приглашения освобождает от оплаты",
+ "pendingDaysLabel": "Срок оплаты (дни)",
+ "pendingDaysDescription": "Заблокированные аккаунты без поступлений удаляются после этого срока",
+ "price": "Цена",
+ "confirmations": "Требуется подтверждений",
+ "xpub": "Расширенный публичный ключ (zpub / xpub)",
+ "xpubHint": "Ключ аккаунта Native SegWit (BIP84). Никогда не вставляйте приватный ключ. Увеличьте gap limit кошелька, чтобы он видел все адреса.",
+ "esploraUrl": "API Esplora",
+ "moneroReady": "Wallet RPC настроен (MONERO_WALLET_RPC_URL)",
+ "moneroMissing": "Укажите в MONERO_WALLET_RPC_URL monero-wallet-rpc только для просмотра, чтобы принимать Monero",
+ "solAddress": "Адрес получения",
+ "solRpcUrl": "RPC Solana",
+ "saveFailed": "Не удалось сохранить настройки",
+ "pendingTitle": "Заблокированные аккаунты",
+ "noPending": "Нет аккаунтов, ожидающих оплаты",
+ "approve": "Разблокировать",
+ "paidTitle": "Последние платежи",
+ "noPaid": "Платежей пока нет",
+ "manual": "вручную"
+ }
+ }
+}
diff --git a/i18n/locales/zh/payments.json b/i18n/locales/zh/payments.json
new file mode 100644
index 00000000..c96ba9b4
--- /dev/null
+++ b/i18n/locales/zh/payments.json
@@ -0,0 +1,62 @@
+{
+ "payments": {
+ "adminNav": {
+ "label": "付款",
+ "description": "使用 Bitcoin、Monero 和 Solana 付费注册"
+ },
+ "register": {
+ "notice": "注册需要付费:{prices}。",
+ "unlock": "账户会立即创建,并在区块链确认付款后解锁。未付款的账户将在 {days} 天后删除。",
+ "inviteFree": "使用邀请码注册免费。"
+ },
+ "page": {
+ "title": "激活你的账户",
+ "intro": "使用任一受支持的加密货币支付注册费。付款确认后账户将自动解锁。",
+ "loading": "加载中...",
+ "unavailable": "目前没有可用的付款方式,请联系管理员。",
+ "chooseCurrency": "付款方式",
+ "amountDue": "需发送金额",
+ "seen": "已收到:已确认 {confirmed},待确认 {unconfirmed},共需 {total}",
+ "address": "地址",
+ "solanaReference": "请使用下方的钱包按钮:链接中包含识别你账户的付款参考。直接向该地址转账无法匹配。",
+ "openWallet": "在钱包中打开",
+ "confirmationsHint": "{count} 次确认后解锁。",
+ "finalizedHint": "交易最终确认后解锁(不到一分钟)。",
+ "checkNow": "我已付款:立即检查",
+ "deadline": "如未付款,该账户将于 {date} 删除。",
+ "logout": "退出登录",
+ "failed": "出错了,请重试"
+ },
+ "status": {
+ "pending": "等待付款",
+ "confirming": "已检测到付款,等待确认",
+ "paid": "已付款",
+ "cancelled": "已取消"
+ },
+ "admin": {
+ "title": "付费注册",
+ "enabledLabel": "付费注册",
+ "enabledDescription": "新账户在链上确认付款前保持锁定",
+ "invitesPayLabel": "受邀用户也需付款",
+ "invitesPayDescription": "关闭:使用邀请码可免付款",
+ "pendingDaysLabel": "付款期限(天)",
+ "pendingDaysDescription": "未收到任何款项的锁定账户将在此期限后删除",
+ "price": "价格",
+ "confirmations": "所需确认数",
+ "xpub": "扩展公钥(zpub / xpub)",
+ "xpubHint": "原生 SegWit 账户密钥(BIP84)。切勿粘贴私钥。请提高钱包的 gap limit 以便看到所有地址。",
+ "esploraUrl": "Esplora API",
+ "moneroReady": "钱包 RPC 已配置(MONERO_WALLET_RPC_URL)",
+ "moneroMissing": "请将 MONERO_WALLET_RPC_URL 设置为只读的 monero-wallet-rpc 以接受 Monero",
+ "solAddress": "收款地址",
+ "solRpcUrl": "Solana RPC",
+ "saveFailed": "无法保存设置",
+ "pendingTitle": "锁定的账户",
+ "noPending": "没有等待付款的账户",
+ "approve": "解锁",
+ "paidTitle": "最近付款",
+ "noPaid": "暂无付款",
+ "manual": "手动"
+ }
+ }
+}
diff --git a/nuxt.config.ts b/nuxt.config.ts
index 863cb6b2..fe709fea 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -1,7 +1,7 @@
import pkg from './package.json';
// One translation file per area, in i18n/locales/
/ (issue #43)
-const LOCALE_FILES = ['app.json', 'shop.json', 'messages.json', 'shoutbox.json', 'integrations.json', 'appearance.json'];
+const LOCALE_FILES = ['app.json', 'shop.json', 'messages.json', 'shoutbox.json', 'integrations.json', 'appearance.json', 'payments.json'];
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
diff --git a/package-lock.json b/package-lock.json
index 6d02d838..c8751761 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -16,9 +16,12 @@
"@fontsource/nunito": "^5.3.0",
"@fontsource/source-serif-4": "5.3.0",
"@fontsource/space-grotesk": "^5.3.0",
+ "@noble/hashes": "^1.8.0",
"@nuxtjs/i18n": "^10.6.0",
"@nuxtjs/tailwindcss": "6.14.0",
"@pinia/nuxt": "^1.0.3",
+ "@scure/base": "^1.2.6",
+ "@scure/bip32": "^1.7.0",
"@tiptap/extension-color": "^3.15.0",
"@tiptap/extension-placeholder": "^3.15.0",
"@tiptap/extension-text-style": "^3.15.0",
@@ -2682,6 +2685,33 @@
"@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4"
}
},
+ "node_modules/@noble/curves": {
+ "version": "1.9.7",
+ "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz",
+ "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==",
+ "license": "MIT",
+ "dependencies": {
+ "@noble/hashes": "1.8.0"
+ },
+ "engines": {
+ "node": "^14.21.3 || >=16"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@noble/hashes": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
+ "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
+ "license": "MIT",
+ "engines": {
+ "node": "^14.21.3 || >=16"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -5476,6 +5506,29 @@
"win32"
]
},
+ "node_modules/@scure/base": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz",
+ "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@scure/bip32": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz",
+ "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==",
+ "license": "MIT",
+ "dependencies": {
+ "@noble/curves": "~1.9.0",
+ "@noble/hashes": "~1.8.0",
+ "@scure/base": "~1.2.5"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
"node_modules/@sindresorhus/is": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz",
diff --git a/package.json b/package.json
index b889afa3..ed93cd06 100644
--- a/package.json
+++ b/package.json
@@ -26,9 +26,12 @@
"@fontsource/nunito": "^5.3.0",
"@fontsource/source-serif-4": "5.3.0",
"@fontsource/space-grotesk": "^5.3.0",
+ "@noble/hashes": "^1.8.0",
"@nuxtjs/i18n": "^10.6.0",
"@nuxtjs/tailwindcss": "6.14.0",
"@pinia/nuxt": "^1.0.3",
+ "@scure/base": "^1.2.6",
+ "@scure/bip32": "^1.7.0",
"@tiptap/extension-color": "^3.15.0",
"@tiptap/extension-placeholder": "^3.15.0",
"@tiptap/extension-text-style": "^3.15.0",
diff --git a/server/api/admin/payments/[userId]/approve.post.ts b/server/api/admin/payments/[userId]/approve.post.ts
new file mode 100644
index 00000000..6b741b50
--- /dev/null
+++ b/server/api/admin/payments/[userId]/approve.post.ts
@@ -0,0 +1,39 @@
+import { and, desc, eq, inArray } from 'drizzle-orm';
+import { db } from '../../../../db';
+import { registrationPayments, users } from '../../../../db/schema';
+import { requireAdminSession } from '../../../../utils/adminAuth';
+import { uuidSchema } from '../../../../utils/schemas';
+import { activateAccount } from '../../../../utils/payments/invoices';
+
+/**
+ * POST /api/admin/payments/:userId/approve
+ * Unlock a locked account by hand (paid another way, late payment, gift)
+ */
+export default defineEventHandler(async (event) => {
+ const session = await requireAdminSession(event);
+ const parsed = uuidSchema.safeParse(getRouterParam(event, 'userId'));
+ if (!parsed.success) throw createError({ statusCode: 400, message: 'Invalid user id' });
+
+ const [user] = await db
+ .select({ id: users.id })
+ .from(users)
+ .where(and(eq(users.id, parsed.data), eq(users.paymentPending, true)))
+ .limit(1);
+ if (!user) throw createError({ statusCode: 404, message: 'No locked account with this id' });
+
+ // Recorded on its latest open invoice, if it has one
+ const [invoice] = await db
+ .select({ id: registrationPayments.id })
+ .from(registrationPayments)
+ .where(
+ and(
+ eq(registrationPayments.userId, user.id),
+ inArray(registrationPayments.status, ['pending', 'confirming'])
+ )
+ )
+ .orderBy(desc(registrationPayments.createdAt))
+ .limit(1);
+
+ await activateAccount(user.id, invoice?.id ?? null, session.user.id);
+ return { success: true };
+});
diff --git a/server/api/admin/payments/index.get.ts b/server/api/admin/payments/index.get.ts
new file mode 100644
index 00000000..35687a89
--- /dev/null
+++ b/server/api/admin/payments/index.get.ts
@@ -0,0 +1,47 @@
+import { desc, eq, inArray } from 'drizzle-orm';
+import { db } from '../../../db';
+import { registrationPayments, users } from '../../../db/schema';
+import { requireAdminSession } from '../../../utils/adminAuth';
+import { invoiceView } from '../../../utils/payments/invoices';
+
+/**
+ * GET /api/admin/payments
+ * Locked accounts with their invoices, and the latest paid invoices
+ */
+export default defineEventHandler(async (event) => {
+ await requireAdminSession(event);
+
+ const pending = await db
+ .select({ id: users.id, username: users.username, createdAt: users.createdAt })
+ .from(users)
+ .where(eq(users.paymentPending, true))
+ .orderBy(desc(users.createdAt))
+ .limit(200);
+
+ const invoices = pending.length
+ ? await db
+ .select()
+ .from(registrationPayments)
+ .where(inArray(registrationPayments.userId, pending.map((u) => u.id)))
+ : [];
+
+ const paid = await db
+ .select({ invoice: registrationPayments, username: users.username })
+ .from(registrationPayments)
+ .innerJoin(users, eq(users.id, registrationPayments.userId))
+ .where(eq(registrationPayments.status, 'paid'))
+ .orderBy(desc(registrationPayments.paidAt))
+ .limit(50);
+
+ return {
+ pending: pending.map((user) => ({
+ ...user,
+ invoices: invoices.filter((i) => i.userId === user.id).map(invoiceView),
+ })),
+ paid: paid.map((row) => ({
+ username: row.username,
+ approvedBy: row.invoice.approvedBy,
+ ...invoiceView(row.invoice),
+ })),
+ };
+});
diff --git a/server/api/admin/payments/settings.get.ts b/server/api/admin/payments/settings.get.ts
new file mode 100644
index 00000000..0b7da6dd
--- /dev/null
+++ b/server/api/admin/payments/settings.get.ts
@@ -0,0 +1,15 @@
+import { requireAdminSession } from '../../../utils/adminAuth';
+import { getPaidRegistration } from '../../../utils/settings';
+import { moneroRpcUrl } from '../../../utils/payments/xmr';
+
+/**
+ * GET /api/admin/payments/settings
+ */
+export default defineEventHandler(async (event) => {
+ await requireAdminSession(event);
+ return {
+ config: await getPaidRegistration(),
+ // Set in the environment, not here: shown so the admin knows XMR can work
+ moneroRpcConfigured: moneroRpcUrl() !== null,
+ };
+});
diff --git a/server/api/admin/payments/settings.put.ts b/server/api/admin/payments/settings.put.ts
new file mode 100644
index 00000000..9c2d3700
--- /dev/null
+++ b/server/api/admin/payments/settings.put.ts
@@ -0,0 +1,71 @@
+import { z } from 'zod';
+import { requireAdminSession } from '../../../utils/adminAuth';
+import { validateBody } from '../../../utils/schemas';
+import { getPaidRegistration, setPaidRegistration } from '../../../utils/settings';
+import { deriveBtcAddress } from '../../../utils/payments/btc';
+import { isSolanaAddress } from '../../../utils/payments/sol';
+import {
+ isValidPrice,
+ parsePaidRegistration,
+ type PaymentCurrency,
+} from '../../../../shared/utils/paidRegistration';
+
+const price = z.string().trim().max(32);
+const url = z.union([z.literal(''), z.url().max(500)]);
+
+const bodySchema = z.object({
+ enabled: z.boolean(),
+ invitesPay: z.boolean(),
+ pendingDays: z.number().int().min(1).max(365),
+ btc: z.object({
+ enabled: z.boolean(),
+ price,
+ xpub: z.string().trim().max(200),
+ confirmations: z.number().int().min(0).max(100),
+ esploraUrl: url,
+ }),
+ xmr: z.object({
+ enabled: z.boolean(),
+ price,
+ confirmations: z.number().int().min(0).max(100),
+ }),
+ sol: z.object({
+ enabled: z.boolean(),
+ price,
+ address: z.string().trim().max(64),
+ rpcUrl: url,
+ }),
+});
+
+function invalid(message: string): never {
+ throw createError({ statusCode: 400, message });
+}
+
+/**
+ * PUT /api/admin/payments/settings
+ * A coin switched on must be fully usable: valid price and receiving side
+ */
+export default defineEventHandler(async (event) => {
+ await requireAdminSession(event);
+ const body = await validateBody(event, bodySchema);
+
+ for (const currency of ['btc', 'xmr', 'sol'] as PaymentCurrency[]) {
+ const coin = body[currency];
+ if (coin.enabled && !isValidPrice(coin.price, currency)) {
+ invalid(`Invalid ${currency.toUpperCase()} price`);
+ }
+ }
+ if (body.btc.enabled || body.btc.xpub) {
+ try {
+ deriveBtcAddress(body.btc.xpub, 0);
+ } catch (err) {
+ invalid(`Bitcoin key: ${(err as Error).message}`);
+ }
+ }
+ if ((body.sol.enabled || body.sol.address) && !isSolanaAddress(body.sol.address)) {
+ invalid('Invalid Solana address');
+ }
+
+ await setPaidRegistration(parsePaidRegistration(body));
+ return { config: await getPaidRegistration() };
+});
diff --git a/server/api/auth/login.post.ts b/server/api/auth/login.post.ts
index a602c791..efb57556 100644
--- a/server/api/auth/login.post.ts
+++ b/server/api/auth/login.post.ts
@@ -135,6 +135,7 @@ export default defineEventHandler(async (event) => {
isModerator: user.isModerator,
uploaded: user.uploaded,
downloaded: user.downloaded,
+ paymentPending: user.paymentPending,
},
loggedInAt: Date.now(),
sessionVersion: user.sessionVersion,
@@ -149,6 +150,7 @@ export default defineEventHandler(async (event) => {
isModerator: user.isModerator,
uploaded: user.uploaded,
downloaded: user.downloaded,
+ paymentPending: user.paymentPending,
},
};
});
diff --git a/server/api/auth/register.post.ts b/server/api/auth/register.post.ts
index b939106f..b608e151 100644
--- a/server/api/auth/register.post.ts
+++ b/server/api/auth/register.post.ts
@@ -11,7 +11,10 @@ import {
getStarterUpload,
isInviteEnabled,
getDefaultInvites,
+ getPaidRegistration,
} from '../../utils/settings';
+import { registrationRequiresPayment } from '../../../shared/utils/paidRegistration';
+import { offeredCurrencies } from '../../utils/payments/invoices';
import { validateBody, registerSchema } from '../../utils/schemas';
import { verifyPoWSolution } from '../../utils/pow';
import { getClientIP, protectEndpoint } from '../../utils/rateLimit';
@@ -146,6 +149,14 @@ export default defineEventHandler(async (event) => {
const starterUpload = await getStarterUpload();
const defaultInvites = await getDefaultInvites();
+ // Paid registration: the account stays locked until the payment is confirmed
+ const paidConfig = await getPaidRegistration();
+ const paymentPending = registrationRequiresPayment(paidConfig, {
+ isFirstUser,
+ usedInvite: Boolean(validInvite),
+ currencies: offeredCurrencies(paidConfig),
+ });
+
// For first user, require and hash panic password
let panicPasswordHash: string | null = null;
if (isFirstUser) {
@@ -182,6 +193,7 @@ export default defineEventHandler(async (event) => {
uploaded: starterUpload,
invitesRemaining: isFirstUser ? 10 : defaultInvites,
panicPasswordHash,
+ paymentPending,
});
// Consume the invite only if still unused: one code, one account
@@ -220,6 +232,7 @@ export default defineEventHandler(async (event) => {
isModerator: false,
uploaded: starterUpload,
downloaded: 0,
+ paymentPending,
},
loggedInAt: Date.now(),
sessionVersion: 0,
@@ -235,6 +248,7 @@ export default defineEventHandler(async (event) => {
isModerator: false,
uploaded: starterUpload,
downloaded: 0,
+ paymentPending,
},
};
});
diff --git a/server/api/auth/status.get.ts b/server/api/auth/status.get.ts
index 07d03216..faa77592 100644
--- a/server/api/auth/status.get.ts
+++ b/server/api/auth/status.get.ts
@@ -30,6 +30,7 @@ export default defineEventHandler(async (event) => {
uploaded: users.uploaded,
downloaded: users.downloaded,
isBanned: users.isBanned,
+ paymentPending: users.paymentPending,
isAdmin: users.isAdmin,
isModerator: users.isModerator,
sessionVersion: users.sessionVersion,
@@ -49,7 +50,8 @@ export default defineEventHandler(async (event) => {
dbUser.uploaded !== session.user.uploaded ||
dbUser.downloaded !== session.user.downloaded ||
dbUser.isAdmin !== session.user.isAdmin ||
- dbUser.isModerator !== session.user.isModerator
+ dbUser.isModerator !== session.user.isModerator ||
+ dbUser.paymentPending !== Boolean(session.user.paymentPending)
) {
await setUserSession(event, {
...session,
@@ -59,6 +61,7 @@ export default defineEventHandler(async (event) => {
downloaded: dbUser.downloaded,
isAdmin: dbUser.isAdmin,
isModerator: dbUser.isModerator,
+ paymentPending: dbUser.paymentPending,
},
});
}
@@ -70,6 +73,7 @@ export default defineEventHandler(async (event) => {
isModerator: dbUser.isModerator,
uploaded: dbUser.uploaded,
downloaded: dbUser.downloaded,
+ paymentPending: dbUser.paymentPending,
};
}
} else {
diff --git a/server/api/payment/check.post.ts b/server/api/payment/check.post.ts
new file mode 100644
index 00000000..e473f3e5
--- /dev/null
+++ b/server/api/payment/check.post.ts
@@ -0,0 +1,28 @@
+import { redis } from '../../redis/client';
+import { claim } from '../../redis/claim';
+import { getPaidRegistration } from '../../utils/settings';
+import { listUserInvoices, refreshInvoice } from '../../utils/payments/invoices';
+import { paymentStatus, requirePaymentSession } from '../../utils/payments/userStatus';
+
+/**
+ * POST /api/payment/check
+ * "I have paid": read the chain now instead of waiting for the watcher.
+ * At most once every 20 s per account (public chain APIs rate-limit).
+ */
+export default defineEventHandler(async (event) => {
+ const session = await requirePaymentSession(event);
+ const userId = session.user.id;
+
+ if (event.context.authUser?.paymentPending && (await claim(redis, `payment-check:${userId}`, 20_000))) {
+ const config = await getPaidRegistration();
+ for (const invoice of await listUserInvoices(userId)) {
+ try {
+ const updated = await refreshInvoice(invoice, config);
+ if (updated.status === 'paid') break;
+ } catch (err) {
+ console.error(`[Payments] Check failed for invoice ${invoice.id}:`, (err as Error).message);
+ }
+ }
+ }
+ return paymentStatus(userId);
+});
diff --git a/server/api/payment/info.get.ts b/server/api/payment/info.get.ts
new file mode 100644
index 00000000..3190f6c2
--- /dev/null
+++ b/server/api/payment/info.get.ts
@@ -0,0 +1,17 @@
+import { getPaidRegistration } from '../../utils/settings';
+import { offeredCurrencies } from '../../utils/payments/invoices';
+
+/**
+ * GET /api/payment/info
+ * Public: whether sign-up is paid, and the price in each accepted coin
+ */
+export default defineEventHandler(async () => {
+ const config = await getPaidRegistration();
+ const currencies = config.enabled ? offeredCurrencies(config) : [];
+ return {
+ enabled: currencies.length > 0,
+ invitesPay: config.invitesPay,
+ pendingDays: config.pendingDays,
+ options: currencies.map((currency) => ({ currency, price: config[currency].price })),
+ };
+});
diff --git a/server/api/payment/invoice.post.ts b/server/api/payment/invoice.post.ts
new file mode 100644
index 00000000..04695671
--- /dev/null
+++ b/server/api/payment/invoice.post.ts
@@ -0,0 +1,31 @@
+import { z } from 'zod';
+import { validateBody } from '../../utils/schemas';
+import { protectEndpoint } from '../../utils/rateLimit';
+import { getPaidRegistration } from '../../utils/settings';
+import { openInvoice } from '../../utils/payments/invoices';
+import { paymentStatus, requirePaymentSession } from '../../utils/payments/userStatus';
+import { PAYMENT_CURRENCIES } from '../../../shared/utils/paidRegistration';
+
+const bodySchema = z.object({ currency: z.enum(PAYMENT_CURRENCIES) });
+
+/**
+ * POST /api/payment/invoice
+ * Get (or create) the locked account's invoice for one coin
+ */
+export default defineEventHandler(async (event) => {
+ await protectEndpoint(event, 'mutation');
+ const session = await requirePaymentSession(event);
+ if (!event.context.authUser?.paymentPending) {
+ throw createError({ statusCode: 400, message: 'This account is already active' });
+ }
+ const { currency } = await validateBody(event, bodySchema);
+
+ try {
+ await openInvoice(session.user.id, currency, await getPaidRegistration());
+ } catch (err) {
+ if ((err as { statusCode?: number }).statusCode) throw err;
+ console.error(`[Payments] Could not create ${currency} invoice:`, (err as Error).message);
+ throw createError({ statusCode: 503, message: 'Payment backend unavailable, try again later' });
+ }
+ return paymentStatus(session.user.id);
+});
diff --git a/server/api/payment/status.get.ts b/server/api/payment/status.get.ts
new file mode 100644
index 00000000..0c242942
--- /dev/null
+++ b/server/api/payment/status.get.ts
@@ -0,0 +1,10 @@
+import { paymentStatus, requirePaymentSession } from '../../utils/payments/userStatus';
+
+/**
+ * GET /api/payment/status
+ * The signed-in account's payment state and invoices
+ */
+export default defineEventHandler(async (event) => {
+ const session = await requirePaymentSession(event);
+ return paymentStatus(session.user.id);
+});
diff --git a/server/api/torznab/utils/auth.ts b/server/api/torznab/utils/auth.ts
index da0e15b7..98bdc6c2 100644
--- a/server/api/torznab/utils/auth.ts
+++ b/server/api/torznab/utils/auth.ts
@@ -56,6 +56,7 @@ export async function authenticateTorznab(
username: schema.users.username,
passkey: schema.users.passkey,
isBanned: schema.users.isBanned,
+ paymentPending: schema.users.paymentPending,
isAdmin: schema.users.isAdmin,
isModerator: schema.users.isModerator,
})
@@ -73,6 +74,11 @@ export async function authenticateTorznab(
throw createTorznabError(event, TORZNAB_ERRORS.ACCOUNT_SUSPENDED);
}
+ // Locked until the sign-up payment is confirmed
+ if (user.paymentPending) {
+ throw createTorznabError(event, TORZNAB_ERRORS.ACCOUNT_SUSPENDED);
+ }
+
return { ...user, apikey: credential.value };
}
diff --git a/server/db/schema.ts b/server/db/schema.ts
index 934b6142..982d27d1 100644
--- a/server/db/schema.ts
+++ b/server/db/schema.ts
@@ -36,6 +36,8 @@ export const users = pgTable(
isAdmin: boolean('is_admin').default(false).notNull(),
isModerator: boolean('is_moderator').default(false).notNull(),
isBanned: boolean('is_banned').default(false).notNull(),
+ // Paid registration: locked until the sign-up payment is confirmed on-chain
+ paymentPending: boolean('payment_pending').default(false).notNull(),
// Bumped to revoke every sealed session cookie issued before (ban, sign out everywhere)
sessionVersion: integer('session_version').default(0).notNull(),
roleId: text('role_id'),
@@ -411,6 +413,43 @@ export const invitations = pgTable(
]
);
+// ============================================================================
+// Registration payments (paid sign-up, shared/utils/paidRegistration.ts)
+// ============================================================================
+export const registrationPayments = pgTable(
+ 'registration_payments',
+ {
+ id: text('id').primaryKey(),
+ userId: text('user_id')
+ .notNull()
+ .references(() => users.id, { onDelete: 'cascade' }),
+ currency: text('currency').notNull(), // btc | xmr | sol
+ // Where to pay: unique per invoice for BTC/XMR, the shop address for SOL
+ address: text('address').notNull(),
+ btcIndex: integer('btc_index'), // BIP84 receive index
+ xmrIndex: integer('xmr_index'), // Subaddress index (account 0)
+ solReference: text('sol_reference'), // Solana Pay reference key
+ // Amounts in the smallest unit (satoshi, piconero, lamport) as decimal text
+ amount: text('amount').notNull(),
+ received: text('received').default('0').notNull(),
+ unconfirmed: text('unconfirmed').default('0').notNull(),
+ status: text('status').default('pending').notNull(), // pending | confirming | paid | cancelled
+ txids: text('txids').array().default(sql`ARRAY[]::text[]`).notNull(),
+ approvedBy: text('approved_by'), // Admin who unlocked the account by hand
+ createdAt: timestamp('created_at').defaultNow().notNull(),
+ checkedAt: timestamp('checked_at'),
+ paidAt: timestamp('paid_at'),
+ },
+ (table) => [
+ index('registration_payments_user_idx').on(table.userId),
+ index('registration_payments_status_idx').on(table.status),
+ uniqueIndex('registration_payments_btc_idx').on(table.btcIndex),
+ // Not unique: a wallet restored from scratch numbers subaddresses again
+ index('registration_payments_xmr_idx').on(table.xmrIndex),
+ uniqueIndex('registration_payments_sol_idx').on(table.solReference),
+ ]
+);
+
// ============================================================================
// Reports
// ============================================================================
diff --git a/server/plugins/payment-watcher.ts b/server/plugins/payment-watcher.ts
new file mode 100644
index 00000000..c8c53ea2
--- /dev/null
+++ b/server/plugins/payment-watcher.ts
@@ -0,0 +1,50 @@
+import { redis } from '../redis/client';
+import { claim } from '../redis/claim';
+import { getPaidRegistration } from '../utils/settings';
+import {
+ listOpenInvoices,
+ purgeUnpaidAccounts,
+ refreshInvoice,
+} from '../utils/payments/invoices';
+
+/**
+ * Paid registration: polls the chains for open invoices and unlocks the
+ * accounts once paid, then deletes locked accounts left unpaid too long.
+ * With several app nodes, only the first to claim an interval runs it.
+ */
+export default defineNitroPlugin(() => {
+ const INTERVAL = parseInt(process.env.PAYMENT_CHECK_INTERVAL || '60000', 10);
+ let running = false;
+
+ const run = async () => {
+ if (running) return;
+ running = true;
+ try {
+ if (!(await claim(redis, 'job:payment-watcher', INTERVAL * 0.9))) return;
+ const config = await getPaidRegistration();
+ const invoices = await listOpenInvoices();
+
+ // One at a time: public APIs (mempool.space, Solana RPC) rate-limit
+ for (const invoice of invoices) {
+ try {
+ await refreshInvoice(invoice, config);
+ } catch (err) {
+ console.error(
+ `[Payments] Check failed for ${invoice.currency} invoice ${invoice.id}:`,
+ (err as Error).message
+ );
+ }
+ }
+
+ const purged = await purgeUnpaidAccounts(config.pendingDays);
+ if (purged > 0) console.log(`[Payments] Deleted ${purged} unpaid account(s)`);
+ } catch (err) {
+ console.error('[Payments] Watcher failed:', err);
+ } finally {
+ running = false;
+ }
+ };
+
+ setTimeout(run, 15_000);
+ setInterval(run, INTERVAL);
+});
diff --git a/server/utils/adminAuth.ts b/server/utils/adminAuth.ts
index 2314a8fd..c354464a 100644
--- a/server/utils/adminAuth.ts
+++ b/server/utils/adminAuth.ts
@@ -6,6 +6,7 @@ import { checkSession, type SessionCheck } from './sessionRules';
export const authUserColumns = {
isBanned: users.isBanned,
+ paymentPending: users.paymentPending,
isAdmin: users.isAdmin,
isModerator: users.isModerator,
passkey: users.passkey,
@@ -15,6 +16,7 @@ export const authUserColumns = {
export interface AuthUserState {
isBanned: boolean;
+ paymentPending: boolean;
isAdmin: boolean;
isModerator: boolean;
passkey: string;
@@ -40,9 +42,14 @@ export function sessionRejection(check: SessionCheck) {
}
/**
- * Require user authentication and check for bans
+ * Require user authentication and check for bans. Accounts waiting for their
+ * sign-up payment are refused (402) unless `allowPaymentPending`: only the
+ * payment endpoints let them through.
*/
-export async function requireAuthSession(event: H3Event) {
+export async function requireAuthSession(
+ event: H3Event,
+ options: { allowPaymentPending?: boolean } = {}
+) {
const session = await requireUserSession(event);
// Roles come from the DB, never from the cookie: a demoted admin keeps an
@@ -64,6 +71,10 @@ export async function requireAuthSession(event: H3Event) {
throw sessionRejection(check);
}
+ if (dbUser.paymentPending && !options.allowPaymentPending) {
+ throw createError({ statusCode: 402, message: 'Payment required to activate your account' });
+ }
+
event.context.authUser = dbUser;
session.user.isAdmin = dbUser.isAdmin;
session.user.isModerator = dbUser.isModerator;
diff --git a/server/utils/feedAuth.ts b/server/utils/feedAuth.ts
index 358bb737..37351ba5 100644
--- a/server/utils/feedAuth.ts
+++ b/server/utils/feedAuth.ts
@@ -22,7 +22,14 @@ export async function requireFeedAccess(event: H3Event): Promise {
const [user] = await db
.select({ id: users.id })
.from(users)
- .where(and(eq(users.passkey, passkey.toLowerCase()), eq(users.isBanned, false)))
+ .where(
+ and(
+ eq(users.passkey, passkey.toLowerCase()),
+ eq(users.isBanned, false),
+ // Locked until the sign-up payment is confirmed
+ eq(users.paymentPending, false)
+ )
+ )
.limit(1);
if (!user) {
diff --git a/server/utils/payments/btc.ts b/server/utils/payments/btc.ts
new file mode 100644
index 00000000..e301a8b1
--- /dev/null
+++ b/server/utils/payments/btc.ts
@@ -0,0 +1,104 @@
+/**
+ * Bitcoin: one native SegWit (BIP84, p2wpkh) address per invoice, derived
+ * from the admin's extended public key on the receive chain (.../0/index).
+ * The server never holds a private key. Payments are read from an Esplora
+ * API (mempool.space by default, or a self-hosted instance).
+ */
+
+import { HDKey } from '@scure/bip32';
+import { bech32 } from '@scure/base';
+import { sha256 } from '@noble/hashes/sha2';
+import { ripemd160 } from '@noble/hashes/legacy';
+import { getJson, getText, trimSlash, type ChainReceipt } from './http';
+
+// SLIP-132 version bytes of the public extended keys we accept
+const PUBLIC_VERSIONS: Record = {
+ xpub: { version: 0x0488b21e, hrp: 'bc' },
+ zpub: { version: 0x04b24746, hrp: 'bc' },
+ tpub: { version: 0x043587cf, hrp: 'tb' },
+ vpub: { version: 0x045f1cf6, hrp: 'tb' },
+};
+
+/** Parse an extended public key; private keys and other formats are refused */
+export function parseExtendedPublicKey(key: string): { node: HDKey; hrp: 'bc' | 'tb' } {
+ const prefix = key.slice(0, 4);
+ const kind = PUBLIC_VERSIONS[prefix];
+ if (!kind) {
+ throw new Error(
+ prefix.endsWith('prv')
+ ? 'This is a private key: paste the extended PUBLIC key (xpub/zpub)'
+ : 'Unsupported key: expected xpub, zpub, tpub or vpub'
+ );
+ }
+ // Only the public version matters: HDKey checks the one in the key
+ const node = HDKey.fromExtendedKey(key, { public: kind.version, private: 0 });
+ if (node.privateKey) throw new Error('Private keys are not accepted');
+ return { node, hrp: kind.hrp };
+}
+
+/** Receive address number `index` (account-level key: child 0/index) */
+export function deriveBtcAddress(extendedKey: string, index: number): string {
+ const { node, hrp } = parseExtendedPublicKey(extendedKey);
+ const child = node.deriveChild(0).deriveChild(index);
+ if (!child.publicKey) throw new Error('Derivation failed');
+ const program = ripemd160(sha256(child.publicKey));
+ return bech32.encode(hrp, [0, ...bech32.toWords(program)]);
+}
+
+export interface EsploraTx {
+ txid: string;
+ status: { confirmed: boolean; block_height?: number; block_time?: number };
+ vout: { scriptpubkey_address?: string; value: number }[];
+}
+
+/**
+ * Sum what `address` received, split by confirmation depth. Blocks older than
+ * `notBefore` (unix seconds) are ignored: an address that was already used
+ * (xpub shared with another wallet) must not pay for a new invoice.
+ */
+export function sumEsploraTxs(
+ txs: EsploraTx[],
+ address: string,
+ tipHeight: number,
+ minConfirmations: number,
+ notBefore = 0
+): ChainReceipt {
+ let confirmed = 0n;
+ let unconfirmed = 0n;
+ const txids: string[] = [];
+ for (const tx of txs) {
+ if (tx.status.confirmed && (tx.status.block_time ?? 0) < notBefore) continue;
+ const value = tx.vout
+ .filter((out) => out.scriptpubkey_address === address)
+ .reduce((sum, out) => sum + BigInt(out.value), 0n);
+ if (value === 0n) continue;
+ txids.push(tx.txid);
+ const depth =
+ tx.status.confirmed && tx.status.block_height !== undefined
+ ? tipHeight - tx.status.block_height + 1
+ : 0;
+ // 0 confirmations: the mempool is enough (fast, but double-spendable)
+ if (depth >= minConfirmations) {
+ confirmed += value;
+ } else {
+ unconfirmed += value;
+ }
+ }
+ return { confirmed, unconfirmed, txids };
+}
+
+export async function checkBtcAddress(
+ esploraUrl: string,
+ address: string,
+ minConfirmations: number,
+ notBefore = 0
+): Promise {
+ const base = trimSlash(esploraUrl);
+ // A fresh address has few transactions: the first page (mempool + 25 confirmed) is enough
+ const [tip, txs] = await Promise.all([
+ getText(`${base}/blocks/tip/height`).then((h) => parseInt(h, 10)),
+ getJson(`${base}/address/${address}/txs`),
+ ]);
+ if (!Number.isFinite(tip)) throw new Error('Esplora: invalid tip height');
+ return sumEsploraTxs(txs, address, tip, minConfirmations, notBefore);
+}
diff --git a/server/utils/payments/http.ts b/server/utils/payments/http.ts
new file mode 100644
index 00000000..4aa05d87
--- /dev/null
+++ b/server/utils/payments/http.ts
@@ -0,0 +1,46 @@
+/**
+ * Small HTTP helpers for the chain backends (Esplora, monero-wallet-rpc,
+ * Solana JSON-RPC). Every call is bounded: a slow node must not stall the
+ * payment watcher.
+ */
+
+const TIMEOUT_MS = 10_000;
+
+/** Amounts seen on-chain for one invoice, in the smallest unit */
+export interface ChainReceipt {
+ /** With enough confirmations */
+ confirmed: bigint;
+ /** Seen (mempool, pool, not yet final) but not confirmed enough */
+ unconfirmed: bigint;
+ txids: string[];
+}
+
+export async function getJson(url: string): Promise {
+ const res = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT_MS) });
+ if (!res.ok) throw new Error(`GET ${new URL(url).host}: HTTP ${res.status}`);
+ return (await res.json()) as T;
+}
+
+export async function getText(url: string): Promise {
+ const res = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT_MS) });
+ if (!res.ok) throw new Error(`GET ${new URL(url).host}: HTTP ${res.status}`);
+ return res.text();
+}
+
+/** JSON-RPC 2.0 call; throws on transport and RPC errors */
+export async function jsonRpc(url: string, method: string, params: unknown): Promise {
+ const res = await fetch(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ jsonrpc: '2.0', id: '0', method, params }),
+ signal: AbortSignal.timeout(TIMEOUT_MS),
+ });
+ if (!res.ok) throw new Error(`${method}: HTTP ${res.status}`);
+ const body = (await res.json()) as { result?: T; error?: { message?: string } };
+ if (body.error) throw new Error(`${method}: ${body.error.message ?? 'RPC error'}`);
+ return body.result as T;
+}
+
+export function trimSlash(url: string): string {
+ return url.replace(/\/+$/, '');
+}
diff --git a/server/utils/payments/invoices.ts b/server/utils/payments/invoices.ts
new file mode 100644
index 00000000..c292d571
--- /dev/null
+++ b/server/utils/payments/invoices.ts
@@ -0,0 +1,278 @@
+/**
+ * Registration invoices: one per (account, coin), with its own receiving
+ * address or reference. The amount is locked when the invoice is created, so
+ * a later price change does not affect it. Once enough is confirmed on-chain
+ * the account is unlocked (users.payment_pending = false).
+ */
+
+import { and, eq, inArray, lt, sql } from 'drizzle-orm';
+import { db } from '../../db';
+import { invitations, registrationPayments, users } from '../../db/schema';
+import {
+ availableCurrencies,
+ fromAtomic,
+ invoiceStatus,
+ toAtomic,
+ type PaidRegistrationConfig,
+ paymentUri,
+ type PaymentCurrency,
+} from '../../../shared/utils/paidRegistration';
+import type { ChainReceipt } from './http';
+import { checkBtcAddress, deriveBtcAddress } from './btc';
+import { checkXmrSubaddress, createXmrSubaddress, moneroRpcUrl } from './xmr';
+import { checkSolReference, newSolReference } from './sol';
+
+export type RegistrationPayment = typeof registrationPayments.$inferSelect;
+
+// Serializes BTC index allocation across requests and nodes
+const BTC_INDEX_LOCK_ID = 7_202_611;
+
+const OPEN_STATUSES = ['pending', 'confirming'] as const;
+
+/** Coins a new account can pay with right now */
+export function offeredCurrencies(config: PaidRegistrationConfig): PaymentCurrency[] {
+ return availableCurrencies(config).filter((c) => c !== 'xmr' || moneroRpcUrl() !== null);
+}
+
+export async function listUserInvoices(userId: string): Promise {
+ return db
+ .select()
+ .from(registrationPayments)
+ .where(eq(registrationPayments.userId, userId))
+ .orderBy(registrationPayments.createdAt);
+}
+
+/** The user's invoice for this coin, created on first request */
+export async function openInvoice(
+ userId: string,
+ currency: PaymentCurrency,
+ config: PaidRegistrationConfig
+): Promise {
+ const [existing] = await db
+ .select()
+ .from(registrationPayments)
+ .where(
+ and(
+ eq(registrationPayments.userId, userId),
+ eq(registrationPayments.currency, currency),
+ inArray(registrationPayments.status, [...OPEN_STATUSES])
+ )
+ )
+ .limit(1);
+ if (existing) return existing;
+
+ if (!offeredCurrencies(config).includes(currency)) {
+ throw createError({ statusCode: 400, message: 'This currency is not accepted' });
+ }
+
+ const id = crypto.randomUUID();
+ const amount = toAtomic(config[currency].price, currency).toString();
+
+ if (currency === 'btc') {
+ return db.transaction(async (tx) => {
+ await tx.execute(sql`select pg_advisory_xact_lock(${BTC_INDEX_LOCK_ID})`);
+ const [row] = await tx
+ .select({ next: sql`coalesce(max(${registrationPayments.btcIndex}), -1) + 1` })
+ .from(registrationPayments);
+ const btcIndex = Number(row?.next ?? 0);
+ const address = deriveBtcAddress(config.btc.xpub, btcIndex);
+ const [created] = await tx
+ .insert(registrationPayments)
+ .values({ id, userId, currency, address, btcIndex, amount })
+ .returning();
+ return created!;
+ });
+ }
+
+ if (currency === 'xmr') {
+ const sub = await createXmrSubaddress(`trackarr:${userId}`);
+ const [created] = await db
+ .insert(registrationPayments)
+ .values({ id, userId, currency, address: sub.address, xmrIndex: sub.index, amount })
+ .returning();
+ return created!;
+ }
+
+ const [created] = await db
+ .insert(registrationPayments)
+ .values({
+ id,
+ userId,
+ currency,
+ address: config.sol.address,
+ solReference: newSolReference(),
+ amount,
+ })
+ .returning();
+ return created!;
+}
+
+async function readChain(
+ invoice: RegistrationPayment,
+ config: PaidRegistrationConfig
+): Promise {
+ // Only money sent for this invoice: block timestamps may lag up to 2 h
+ const notBefore = Math.floor(invoice.createdAt.getTime() / 1000) - 7200;
+ switch (invoice.currency as PaymentCurrency) {
+ case 'btc':
+ return checkBtcAddress(
+ config.btc.esploraUrl,
+ invoice.address,
+ config.btc.confirmations,
+ notBefore
+ );
+ case 'xmr':
+ if (invoice.xmrIndex === null) throw new Error('Invoice has no subaddress index');
+ return checkXmrSubaddress(invoice.xmrIndex, config.xmr.confirmations, notBefore);
+ case 'sol':
+ if (!invoice.solReference) throw new Error('Invoice has no reference');
+ return checkSolReference(config.sol.rpcUrl, invoice.address, invoice.solReference);
+ default:
+ throw new Error(`Unknown currency: ${invoice.currency}`);
+ }
+}
+
+/**
+ * Unlock the account and close its invoices. `invoiceId` is marked paid, the
+ * user's other open invoices are cancelled. Idempotent.
+ */
+export async function activateAccount(
+ userId: string,
+ invoiceId: string | null,
+ approvedBy: string | null = null
+): Promise {
+ const now = new Date();
+ await db.transaction(async (tx) => {
+ if (invoiceId) {
+ await tx
+ .update(registrationPayments)
+ .set({ status: 'paid', paidAt: now, approvedBy })
+ .where(eq(registrationPayments.id, invoiceId));
+ }
+ await tx
+ .update(registrationPayments)
+ .set({ status: 'cancelled' })
+ .where(
+ and(
+ eq(registrationPayments.userId, userId),
+ inArray(registrationPayments.status, [...OPEN_STATUSES])
+ )
+ );
+ await tx.update(users).set({ paymentPending: false }).where(eq(users.id, userId));
+ });
+}
+
+/** Read the chain for one open invoice, store the amounts, unlock if paid */
+export async function refreshInvoice(
+ invoice: RegistrationPayment,
+ config: PaidRegistrationConfig
+): Promise {
+ if (!(OPEN_STATUSES as readonly string[]).includes(invoice.status)) return invoice;
+
+ const receipt = await readChain(invoice, config);
+ const status = invoiceStatus(BigInt(invoice.amount), receipt.confirmed, receipt.unconfirmed);
+ const [updated] = await db
+ .update(registrationPayments)
+ .set({
+ received: receipt.confirmed.toString(),
+ unconfirmed: receipt.unconfirmed.toString(),
+ txids: receipt.txids,
+ checkedAt: new Date(),
+ // 'paid' is written by activateAccount, with the account, in one transaction
+ ...(status !== 'paid' && { status }),
+ })
+ .where(eq(registrationPayments.id, invoice.id))
+ .returning();
+
+ if (status === 'paid') {
+ await activateAccount(invoice.userId, invoice.id);
+ console.log(`[Payments] Account ${invoice.userId} unlocked (${invoice.currency} ${invoice.id})`);
+ return { ...updated!, status: 'paid' };
+ }
+ return updated!;
+}
+
+/** Open invoices of accounts still locked */
+export async function listOpenInvoices(): Promise {
+ return db
+ .select({ invoice: registrationPayments })
+ .from(registrationPayments)
+ .innerJoin(users, eq(users.id, registrationPayments.userId))
+ .where(
+ and(
+ inArray(registrationPayments.status, [...OPEN_STATUSES]),
+ eq(users.paymentPending, true)
+ )
+ )
+ .then((rows) => rows.map((r) => r.invoice));
+}
+
+/**
+ * Delete locked accounts older than `pendingDays` on which nothing was ever
+ * seen. Accounts with money on the way are kept for an admin to settle. The
+ * invite they used, if any, becomes usable again.
+ */
+export async function purgeUnpaidAccounts(pendingDays: number): Promise {
+ const cutoff = new Date(Date.now() - pendingDays * 86_400_000);
+ const stale = await db
+ .select({ id: users.id })
+ .from(users)
+ .where(
+ and(
+ eq(users.paymentPending, true),
+ lt(users.createdAt, cutoff),
+ sql`not exists (
+ select 1 from ${registrationPayments}
+ where ${registrationPayments.userId} = ${users.id}
+ and (${registrationPayments.received} <> '0'
+ or ${registrationPayments.unconfirmed} <> '0'
+ or ${registrationPayments.status} in ('confirming', 'paid'))
+ )`
+ )
+ )
+ .limit(500);
+
+ let purged = 0;
+ for (const { id } of stale) {
+ try {
+ await db.transaction(async (tx) => {
+ await tx
+ .update(invitations)
+ .set({ usedBy: null, usedAt: null })
+ .where(eq(invitations.usedBy, id));
+ // Re-checked under the delete: paid in the meantime means kept
+ await tx.delete(users).where(and(eq(users.id, id), eq(users.paymentPending, true)));
+ });
+ purged++;
+ } catch (err) {
+ console.error(`[Payments] Could not purge unpaid account ${id}:`, (err as Error).message);
+ }
+ }
+ return purged;
+}
+
+/** What the payment page shows for one invoice (amounts as decimal strings) */
+export function invoiceView(invoice: RegistrationPayment) {
+ const currency = invoice.currency as PaymentCurrency;
+ const amount = BigInt(invoice.amount);
+ const seen = BigInt(invoice.received) + BigInt(invoice.unconfirmed);
+ const due = amount > seen ? amount - seen : 0n;
+ return {
+ id: invoice.id,
+ currency,
+ address: invoice.address,
+ reference: invoice.solReference,
+ amount: fromAtomic(amount, currency),
+ received: fromAtomic(invoice.received, currency),
+ unconfirmed: fromAtomic(invoice.unconfirmed, currency),
+ due: fromAtomic(due, currency),
+ status: invoice.status,
+ uri: paymentUri(currency, invoice.address, fromAtomic(due > 0n ? due : amount, currency), invoice.solReference),
+ txids: invoice.txids,
+ createdAt: invoice.createdAt,
+ checkedAt: invoice.checkedAt,
+ paidAt: invoice.paidAt,
+ };
+}
+
+export type InvoiceView = ReturnType;
diff --git a/server/utils/payments/sol.ts b/server/utils/payments/sol.ts
new file mode 100644
index 00000000..d5cd46c9
--- /dev/null
+++ b/server/utils/payments/sol.ts
@@ -0,0 +1,82 @@
+/**
+ * Solana: payments go to the admin's address; each invoice gets a random
+ * Solana Pay `reference` key that the wallet adds to the transfer, so the
+ * transaction can be found with getSignaturesForAddress(reference). Only
+ * native SOL transfers are counted. Final once the cluster says "finalized".
+ */
+
+import { randomBytes } from 'node:crypto';
+import { base58 } from '@scure/base';
+import { jsonRpc, type ChainReceipt } from './http';
+
+export function isSolanaAddress(value: string): boolean {
+ try {
+ return base58.decode(value).length === 32;
+ } catch {
+ return false;
+ }
+}
+
+/** Random 32-byte key: never on-chain before, unique to one invoice */
+export function newSolReference(): string {
+ return base58.encode(randomBytes(32));
+}
+
+interface SignatureInfo {
+ signature: string;
+ err: unknown;
+ confirmationStatus?: 'processed' | 'confirmed' | 'finalized';
+}
+
+export interface SolanaTransaction {
+ meta: {
+ err: unknown;
+ preBalances: number[];
+ postBalances: number[];
+ loadedAddresses?: { writable: string[]; readonly: string[] };
+ } | null;
+ transaction: { message: { accountKeys: (string | { pubkey: string })[] } };
+}
+
+/** Lamports `recipient` gained in one transaction (0 if failed or absent) */
+export function solanaCredit(tx: SolanaTransaction, recipient: string): bigint {
+ if (!tx.meta || tx.meta.err) return 0n;
+ // Account order: static keys, then loaded writable, then loaded readonly
+ const keys = [
+ ...tx.transaction.message.accountKeys.map((k) => (typeof k === 'string' ? k : k.pubkey)),
+ ...(tx.meta.loadedAddresses?.writable ?? []),
+ ...(tx.meta.loadedAddresses?.readonly ?? []),
+ ];
+ const i = keys.indexOf(recipient);
+ if (i < 0) return 0n;
+ const delta = BigInt(tx.meta.postBalances[i] ?? 0) - BigInt(tx.meta.preBalances[i] ?? 0);
+ return delta > 0n ? delta : 0n;
+}
+
+export async function checkSolReference(
+ rpcUrl: string,
+ recipient: string,
+ reference: string
+): Promise {
+ const signatures = await jsonRpc(rpcUrl, 'getSignaturesForAddress', [
+ reference,
+ { limit: 20, commitment: 'confirmed' },
+ ]);
+ let confirmed = 0n;
+ let unconfirmed = 0n;
+ const txids: string[] = [];
+ for (const info of signatures) {
+ if (info.err) continue;
+ const tx = await jsonRpc(rpcUrl, 'getTransaction', [
+ info.signature,
+ { encoding: 'json', commitment: 'confirmed', maxSupportedTransactionVersion: 0 },
+ ]);
+ if (!tx) continue;
+ const credit = solanaCredit(tx, recipient);
+ if (credit === 0n) continue;
+ txids.push(info.signature);
+ if (info.confirmationStatus === 'finalized') confirmed += credit;
+ else unconfirmed += credit;
+ }
+ return { confirmed, unconfirmed, txids };
+}
diff --git a/server/utils/payments/userStatus.ts b/server/utils/payments/userStatus.ts
new file mode 100644
index 00000000..9a912f88
--- /dev/null
+++ b/server/utils/payments/userStatus.ts
@@ -0,0 +1,36 @@
+import type { H3Event } from 'h3';
+import { eq } from 'drizzle-orm';
+import { db } from '../../db';
+import { users } from '../../db/schema';
+import { getPaidRegistration } from '../settings';
+import { requireAuthSession } from '../adminAuth';
+import { invoiceView, listUserInvoices, offeredCurrencies } from './invoices';
+
+/** Signed-in account, locked or not: the payment endpoints serve both */
+export async function requirePaymentSession(event: H3Event) {
+ return requireAuthSession(event, { allowPaymentPending: true });
+}
+
+/** Everything the payment page needs for one account */
+export async function paymentStatus(userId: string) {
+ const config = await getPaidRegistration();
+ const [user] = await db
+ .select({ paymentPending: users.paymentPending, createdAt: users.createdAt })
+ .from(users)
+ .where(eq(users.id, userId))
+ .limit(1);
+ if (!user) throw createError({ statusCode: 404, message: 'Account not found' });
+
+ const invoices = await listUserInvoices(userId);
+ return {
+ paymentPending: user.paymentPending,
+ // Unpaid accounts on which nothing arrived are deleted after this date
+ deadline: new Date(user.createdAt.getTime() + config.pendingDays * 86_400_000),
+ options: offeredCurrencies(config).map((currency) => ({
+ currency,
+ price: config[currency].price,
+ confirmations: currency === 'sol' ? null : config[currency].confirmations,
+ })),
+ invoices: invoices.map(invoiceView),
+ };
+}
diff --git a/server/utils/payments/xmr.ts b/server/utils/payments/xmr.ts
new file mode 100644
index 00000000..9138058b
--- /dev/null
+++ b/server/utils/payments/xmr.ts
@@ -0,0 +1,78 @@
+/**
+ * Monero: one subaddress per invoice from a view-only wallet served by
+ * monero-wallet-rpc (MONERO_WALLET_RPC_URL). A view-only wallet sees incoming
+ * payments but cannot spend. The RPC has no login here: keep it on a private
+ * network (see doc/guide/paid-registration.md).
+ */
+
+import { jsonRpc, trimSlash, type ChainReceipt } from './http';
+
+export function moneroRpcUrl(): string | null {
+ const url = process.env.MONERO_WALLET_RPC_URL?.trim();
+ return url ? `${trimSlash(url)}/json_rpc` : null;
+}
+
+function requireRpc(): string {
+ const url = moneroRpcUrl();
+ if (!url) throw new Error('MONERO_WALLET_RPC_URL is not set');
+ return url;
+}
+
+export async function createXmrSubaddress(label: string): Promise<{ address: string; index: number }> {
+ const result = await jsonRpc<{ address: string; address_index: number }>(
+ requireRpc(),
+ 'create_address',
+ { account_index: 0, label }
+ );
+ return { address: result.address, index: result.address_index };
+}
+
+export interface MoneroTransfer {
+ txid: string;
+ amount: number;
+ confirmations?: number;
+ timestamp?: number;
+ subaddr_index?: { major: number; minor: number };
+}
+
+/**
+ * Sum what subaddress `index` received, split by confirmation depth. Transfers
+ * older than `notBefore` (unix seconds) are ignored: a wallet restored from
+ * scratch hands out subaddresses that were already paid.
+ */
+export function sumMoneroTransfers(
+ incoming: MoneroTransfer[],
+ pool: MoneroTransfer[],
+ index: number,
+ minConfirmations: number,
+ notBefore = 0
+): ChainReceipt {
+ let confirmed = 0n;
+ let unconfirmed = 0n;
+ const txids = new Set();
+ const mine = (t: MoneroTransfer) =>
+ (!t.subaddr_index || t.subaddr_index.minor === index) && (t.timestamp ?? notBefore) >= notBefore;
+ for (const t of incoming.filter(mine)) {
+ txids.add(t.txid);
+ if ((t.confirmations ?? 0) >= minConfirmations) confirmed += BigInt(t.amount);
+ else unconfirmed += BigInt(t.amount);
+ }
+ for (const t of pool.filter(mine)) {
+ txids.add(t.txid);
+ unconfirmed += BigInt(t.amount);
+ }
+ return { confirmed, unconfirmed, txids: [...txids] };
+}
+
+export async function checkXmrSubaddress(
+ index: number,
+ minConfirmations: number,
+ notBefore = 0
+): Promise {
+ const result = await jsonRpc<{ in?: MoneroTransfer[]; pool?: MoneroTransfer[] }>(
+ requireRpc(),
+ 'get_transfers',
+ { in: true, pool: true, account_index: 0, subaddr_indices: [index] }
+ );
+ return sumMoneroTransfers(result.in ?? [], result.pool ?? [], index, minConfirmations, notBefore);
+}
diff --git a/server/utils/settings.ts b/server/utils/settings.ts
index 1616ada2..8b860157 100644
--- a/server/utils/settings.ts
+++ b/server/utils/settings.ts
@@ -13,6 +13,10 @@ import {
parseFooterLinks,
type FooterLink,
} from '../../shared/utils/footerLinks';
+import {
+ parsePaidRegistration,
+ type PaidRegistrationConfig,
+} from '../../shared/utils/paidRegistration';
/**
* Check if HTML content is effectively empty (just empty tags like or whitespace)
@@ -78,6 +82,8 @@ export const SETTINGS_KEYS = {
SHOUTBOX_SEEDED: 'shoutbox_seeded',
// Theme, fonts, login design, images, text overrides (shared/utils/appearance.ts)
APPEARANCE: 'appearance',
+ // Paid sign-up: JSON (shared/utils/paidRegistration.ts)
+ PAID_REGISTRATION: 'paid_registration',
} as const;
const settingsCache = new Map<
@@ -541,3 +547,14 @@ export async function getAppearance(): Promise {
export async function setAppearance(appearance: Appearance): Promise {
await setSetting(SETTINGS_KEYS.APPEARANCE, JSON.stringify(appearance));
}
+
+/**
+ * Paid registration (prices, receiving keys); falls back to the defaults
+ */
+export async function getPaidRegistration(): Promise {
+ return parsePaidRegistration(await getSetting(SETTINGS_KEYS.PAID_REGISTRATION));
+}
+
+export async function setPaidRegistration(config: PaidRegistrationConfig): Promise {
+ await setSetting(SETTINGS_KEYS.PAID_REGISTRATION, JSON.stringify(config));
+}
diff --git a/shared/utils/paidRegistration.ts b/shared/utils/paidRegistration.ts
new file mode 100644
index 00000000..d7c6b5f4
--- /dev/null
+++ b/shared/utils/paidRegistration.ts
@@ -0,0 +1,203 @@
+/**
+ * Paid registration: the admin sets a fixed price per coin, new accounts stay
+ * locked (users.payment_pending) until the payment is confirmed on-chain.
+ * Stored as one JSON setting, parsed leniently: unknown or broken values fall
+ * back to the defaults. Shared between app and server.
+ */
+
+export const PAYMENT_CURRENCIES = ['btc', 'xmr', 'sol'] as const;
+export type PaymentCurrency = (typeof PAYMENT_CURRENCIES)[number];
+
+/** Decimals of the smallest unit: satoshi, piconero, lamport */
+export const CURRENCY_DECIMALS: Record = {
+ btc: 8,
+ xmr: 12,
+ sol: 9,
+};
+
+export const CURRENCY_SYMBOLS: Record = {
+ btc: 'BTC',
+ xmr: 'XMR',
+ sol: 'SOL',
+};
+
+export interface PaidRegistrationConfig {
+ enabled: boolean;
+ /** Accounts created with an invite code must pay too */
+ invitesPay: boolean;
+ /** Unpaid accounts that received nothing are deleted after this many days */
+ pendingDays: number;
+ btc: {
+ enabled: boolean;
+ price: string;
+ /** Extended public key (xpub/zpub, tpub/vpub on testnet): BIP84 receive chain */
+ xpub: string;
+ confirmations: number;
+ esploraUrl: string;
+ };
+ xmr: {
+ enabled: boolean;
+ price: string;
+ confirmations: number;
+ };
+ sol: {
+ enabled: boolean;
+ price: string;
+ address: string;
+ rpcUrl: string;
+ };
+}
+
+export const DEFAULT_PAID_REGISTRATION: PaidRegistrationConfig = {
+ enabled: false,
+ invitesPay: false,
+ pendingDays: 7,
+ btc: {
+ enabled: false,
+ price: '',
+ xpub: '',
+ confirmations: 2,
+ esploraUrl: 'https://mempool.space/api',
+ },
+ xmr: { enabled: false, price: '', confirmations: 10 },
+ sol: {
+ enabled: false,
+ price: '',
+ address: '',
+ rpcUrl: 'https://api.mainnet-beta.solana.com',
+ },
+};
+
+const DECIMAL = /^\d+(\.\d+)?$/;
+
+/** A positive decimal amount with no more digits than the coin has */
+export function isValidPrice(price: string, currency: PaymentCurrency): boolean {
+ if (!DECIMAL.test(price)) return false;
+ const fraction = price.split('.')[1] ?? '';
+ if (fraction.length > CURRENCY_DECIMALS[currency]) return false;
+ return toAtomic(price, currency) > 0n;
+}
+
+/** '0.0005' BTC -> 50000n satoshis. Throws on malformed input */
+export function toAtomic(amount: string, currency: PaymentCurrency): bigint {
+ if (!DECIMAL.test(amount)) throw new Error(`Invalid amount: ${amount}`);
+ const decimals = CURRENCY_DECIMALS[currency];
+ const [whole = '0', fraction = ''] = amount.split('.');
+ if (fraction.length > decimals) throw new Error(`Too many decimals: ${amount}`);
+ return BigInt(whole + fraction.padEnd(decimals, '0'));
+}
+
+/** 50000n satoshis -> '0.0005' (no trailing zeros) */
+export function fromAtomic(atomic: bigint | string, currency: PaymentCurrency): string {
+ const value = typeof atomic === 'bigint' ? atomic : BigInt(atomic);
+ const decimals = CURRENCY_DECIMALS[currency];
+ const negative = value < 0n;
+ const digits = (negative ? -value : value).toString().padStart(decimals + 1, '0');
+ const whole = digits.slice(0, -decimals);
+ const fraction = digits.slice(-decimals).replace(/0+$/, '');
+ return `${negative ? '-' : ''}${whole}${fraction ? `.${fraction}` : ''}`;
+}
+
+const bool = (value: unknown, fallback: boolean) =>
+ typeof value === 'boolean' ? value : fallback;
+const str = (value: unknown, fallback: string) =>
+ typeof value === 'string' ? value.trim() : fallback;
+const int = (value: unknown, fallback: number, min: number, max: number) =>
+ typeof value === 'number' && Number.isInteger(value) && value >= min && value <= max
+ ? value
+ : fallback;
+const obj = (value: unknown): Record =>
+ value && typeof value === 'object' ? (value as Record) : {};
+
+/** Parse the stored JSON (or an already parsed object) with defaults */
+export function parsePaidRegistration(raw: unknown): PaidRegistrationConfig {
+ let data: unknown = raw;
+ if (typeof raw === 'string') {
+ try {
+ data = JSON.parse(raw);
+ } catch {
+ data = {};
+ }
+ }
+ const root = obj(data);
+ const btc = obj(root.btc);
+ const xmr = obj(root.xmr);
+ const sol = obj(root.sol);
+ const d = DEFAULT_PAID_REGISTRATION;
+
+ return {
+ enabled: bool(root.enabled, d.enabled),
+ invitesPay: bool(root.invitesPay, d.invitesPay),
+ pendingDays: int(root.pendingDays, d.pendingDays, 1, 365),
+ btc: {
+ enabled: bool(btc.enabled, d.btc.enabled),
+ price: str(btc.price, d.btc.price),
+ xpub: str(btc.xpub, d.btc.xpub),
+ confirmations: int(btc.confirmations, d.btc.confirmations, 0, 100),
+ esploraUrl: str(btc.esploraUrl, d.btc.esploraUrl) || d.btc.esploraUrl,
+ },
+ xmr: {
+ enabled: bool(xmr.enabled, d.xmr.enabled),
+ price: str(xmr.price, d.xmr.price),
+ confirmations: int(xmr.confirmations, d.xmr.confirmations, 0, 100),
+ },
+ sol: {
+ enabled: bool(sol.enabled, d.sol.enabled),
+ price: str(sol.price, d.sol.price),
+ address: str(sol.address, d.sol.address),
+ rpcUrl: str(sol.rpcUrl, d.sol.rpcUrl) || d.sol.rpcUrl,
+ },
+ };
+}
+
+/**
+ * Coins that can be offered: switched on, with a valid price and the receiving
+ * side configured (Monero also needs MONERO_WALLET_RPC_URL, checked on the server)
+ */
+export function availableCurrencies(config: PaidRegistrationConfig): PaymentCurrency[] {
+ const out: PaymentCurrency[] = [];
+ if (config.btc.enabled && config.btc.xpub && isValidPrice(config.btc.price, 'btc')) out.push('btc');
+ if (config.xmr.enabled && isValidPrice(config.xmr.price, 'xmr')) out.push('xmr');
+ if (config.sol.enabled && config.sol.address && isValidPrice(config.sol.price, 'sol')) out.push('sol');
+ return out;
+}
+
+/** Whether a new account must pay before it is unlocked */
+export function registrationRequiresPayment(
+ config: PaidRegistrationConfig,
+ options: { isFirstUser: boolean; usedInvite: boolean; currencies: PaymentCurrency[] }
+): boolean {
+ if (options.isFirstUser || !config.enabled) return false;
+ if (options.currencies.length === 0) return false;
+ return !options.usedInvite || config.invitesPay;
+}
+
+export type InvoiceStatus = 'pending' | 'confirming' | 'paid' | 'cancelled';
+
+/**
+ * Status from what the chain shows: paid once enough is confirmed, confirming
+ * while the rest waits for confirmations, pending otherwise (underpaid: the
+ * user can send the difference to the same address)
+ */
+export function invoiceStatus(amount: bigint, confirmed: bigint, unconfirmed: bigint): InvoiceStatus {
+ if (confirmed >= amount) return 'paid';
+ if (confirmed + unconfirmed >= amount) return 'confirming';
+ return 'pending';
+}
+
+/** Wallet link (BIP21, Monero URI, Solana Pay) with the amount still due */
+export function paymentUri(
+ currency: PaymentCurrency,
+ address: string,
+ amountDue: string,
+ reference?: string | null
+): string {
+ switch (currency) {
+ case 'btc':
+ return `bitcoin:${address}?amount=${amountDue}`;
+ case 'xmr':
+ return `monero:${address}?tx_amount=${amountDue}`;
+ case 'sol':
+ return `solana:${address}?amount=${amountDue}${reference ? `&reference=${reference}` : ''}`;
+ }
+}
diff --git a/tests/paid-registration.test.ts b/tests/paid-registration.test.ts
new file mode 100644
index 00000000..b15725e4
--- /dev/null
+++ b/tests/paid-registration.test.ts
@@ -0,0 +1,185 @@
+import { describe, it, expect } from 'vitest';
+import {
+ availableCurrencies,
+ fromAtomic,
+ invoiceStatus,
+ isValidPrice,
+ parsePaidRegistration,
+ paymentUri,
+ registrationRequiresPayment,
+ toAtomic,
+ DEFAULT_PAID_REGISTRATION,
+} from '../shared/utils/paidRegistration';
+import { deriveBtcAddress, parseExtendedPublicKey, sumEsploraTxs } from '../server/utils/payments/btc';
+import { sumMoneroTransfers } from '../server/utils/payments/xmr';
+import { isSolanaAddress, newSolReference, solanaCredit } from '../server/utils/payments/sol';
+
+// BIP84 test vector: "abandon abandon ... about", account m/84'/0'/0'
+const ZPUB =
+ 'zpub6rFR7y4Q2AijBEqTUquhVz398htDFrtymD9xYYfG1m4wAcvPhXNfE3EfH1r1ADqtfSdVCToUG868RvUUkgDKf31mGDtKsAYz2oz2AGutZYs';
+
+describe('amounts', () => {
+ it('converts decimals to the smallest unit and back', () => {
+ expect(toAtomic('0.0005', 'btc')).toBe(50_000n);
+ expect(toAtomic('1', 'xmr')).toBe(1_000_000_000_000n);
+ expect(toAtomic('0.5', 'sol')).toBe(500_000_000n);
+ expect(fromAtomic(50_000n, 'btc')).toBe('0.0005');
+ expect(fromAtomic('1000000000000', 'xmr')).toBe('1');
+ expect(fromAtomic(0n, 'sol')).toBe('0');
+ });
+
+ it('rejects malformed or too precise prices', () => {
+ expect(isValidPrice('0.0005', 'btc')).toBe(true);
+ expect(isValidPrice('0.000000001', 'btc')).toBe(false); // 9 decimals
+ expect(isValidPrice('0', 'btc')).toBe(false);
+ expect(isValidPrice('-1', 'sol')).toBe(false);
+ expect(isValidPrice('1e3', 'sol')).toBe(false);
+ expect(isValidPrice('', 'xmr')).toBe(false);
+ });
+});
+
+describe('config', () => {
+ it('falls back to defaults on broken input', () => {
+ expect(parsePaidRegistration('not json')).toEqual(DEFAULT_PAID_REGISTRATION);
+ expect(parsePaidRegistration(null)).toEqual(DEFAULT_PAID_REGISTRATION);
+ expect(parsePaidRegistration({ pendingDays: 0, btc: { confirmations: -1 } }).pendingDays).toBe(7);
+ });
+
+ it('offers only coins that are on, priced and configured', () => {
+ const config = parsePaidRegistration({
+ enabled: true,
+ btc: { enabled: true, price: '0.001', xpub: '' }, // no key
+ xmr: { enabled: true, price: '0.1' },
+ sol: { enabled: false, price: '1', address: 'x' },
+ });
+ expect(availableCurrencies(config)).toEqual(['xmr']);
+ });
+
+ it('asks for payment only when it applies', () => {
+ const config = parsePaidRegistration({ enabled: true });
+ const base = { isFirstUser: false, usedInvite: false, currencies: ['btc' as const] };
+ expect(registrationRequiresPayment(config, base)).toBe(true);
+ expect(registrationRequiresPayment(config, { ...base, isFirstUser: true })).toBe(false);
+ expect(registrationRequiresPayment(config, { ...base, currencies: [] })).toBe(false);
+ expect(registrationRequiresPayment(config, { ...base, usedInvite: true })).toBe(false);
+ expect(
+ registrationRequiresPayment({ ...config, invitesPay: true }, { ...base, usedInvite: true })
+ ).toBe(true);
+ expect(registrationRequiresPayment({ ...config, enabled: false }, base)).toBe(false);
+ });
+});
+
+describe('invoice status', () => {
+ it('is paid once enough is confirmed', () => {
+ expect(invoiceStatus(100n, 100n, 0n)).toBe('paid');
+ expect(invoiceStatus(100n, 120n, 0n)).toBe('paid');
+ expect(invoiceStatus(100n, 50n, 50n)).toBe('confirming');
+ expect(invoiceStatus(100n, 50n, 0n)).toBe('pending');
+ });
+
+ it('builds wallet links', () => {
+ expect(paymentUri('btc', 'bc1qx', '0.001')).toBe('bitcoin:bc1qx?amount=0.001');
+ expect(paymentUri('xmr', '8abc', '0.1')).toBe('monero:8abc?tx_amount=0.1');
+ expect(paymentUri('sol', 'Addr', '1', 'Ref')).toBe('solana:Addr?amount=1&reference=Ref');
+ });
+});
+
+describe('bitcoin', () => {
+ it('derives BIP84 receive addresses', () => {
+ expect(deriveBtcAddress(ZPUB, 0)).toBe('bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu');
+ expect(deriveBtcAddress(ZPUB, 1)).toBe('bc1qnjg0jd8228aq7egyzacy8cys3knf9xvrerkf9g');
+ });
+
+ it('refuses private and unknown keys', () => {
+ expect(() => parseExtendedPublicKey('xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi')).toThrow(/private/i);
+ expect(() => parseExtendedPublicKey('ypub123')).toThrow(/Unsupported/);
+ });
+
+ it('splits received amounts by confirmation depth', () => {
+ const address = 'bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu';
+ const receipt = sumEsploraTxs(
+ [
+ { txid: 'a', status: { confirmed: true, block_height: 100 }, vout: [{ scriptpubkey_address: address, value: 30_000 }] },
+ { txid: 'b', status: { confirmed: true, block_height: 102 }, vout: [{ scriptpubkey_address: address, value: 10_000 }] },
+ { txid: 'c', status: { confirmed: false }, vout: [{ scriptpubkey_address: address, value: 5_000 }] },
+ { txid: 'd', status: { confirmed: true, block_height: 90 }, vout: [{ scriptpubkey_address: 'other', value: 99 }] },
+ ],
+ address,
+ 102,
+ 2
+ );
+ expect(receipt).toEqual({ confirmed: 30_000n, unconfirmed: 15_000n, txids: ['a', 'b', 'c'] });
+ });
+});
+
+describe('monero', () => {
+ it('counts only the invoice subaddress', () => {
+ const receipt = sumMoneroTransfers(
+ [
+ { txid: 'a', amount: 7, confirmations: 12, subaddr_index: { major: 0, minor: 3 } },
+ { txid: 'b', amount: 2, confirmations: 4, subaddr_index: { major: 0, minor: 3 } },
+ { txid: 'c', amount: 100, confirmations: 50, subaddr_index: { major: 0, minor: 4 } },
+ ],
+ [{ txid: 'd', amount: 1, subaddr_index: { major: 0, minor: 3 } }],
+ 3,
+ 10
+ );
+ expect(receipt).toEqual({ confirmed: 7n, unconfirmed: 3n, txids: ['a', 'b', 'd'] });
+ });
+});
+
+describe('solana', () => {
+ it('reads the recipient balance change, static and loaded keys', () => {
+ const tx = {
+ meta: {
+ err: null,
+ preBalances: [5_000_000_000, 1_000, 0],
+ postBalances: [4_499_995_000, 500_001_000, 0],
+ loadedAddresses: { writable: [], readonly: [] },
+ },
+ transaction: { message: { accountKeys: ['payer', 'shop', 'reference'] } },
+ };
+ expect(solanaCredit(tx, 'shop')).toBe(500_000_000n);
+ expect(solanaCredit(tx, 'payer')).toBe(0n);
+ expect(solanaCredit({ ...tx, meta: { ...tx.meta, err: { InstructionError: [] } } }, 'shop')).toBe(0n);
+ });
+
+ it('makes valid unique references', () => {
+ const a = newSolReference();
+ expect(isSolanaAddress(a)).toBe(true);
+ expect(a).not.toBe(newSolReference());
+ expect(isSolanaAddress('not-base58-0OIl')).toBe(false);
+ });
+});
+
+describe('payments made before the invoice', () => {
+ it('ignores older bitcoin blocks but keeps the mempool', () => {
+ const address = 'bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu';
+ const receipt = sumEsploraTxs(
+ [
+ { txid: 'old', status: { confirmed: true, block_height: 10, block_time: 1_000 }, vout: [{ scriptpubkey_address: address, value: 50_000 }] },
+ { txid: 'new', status: { confirmed: true, block_height: 20, block_time: 5_000 }, vout: [{ scriptpubkey_address: address, value: 20_000 }] },
+ { txid: 'mem', status: { confirmed: false }, vout: [{ scriptpubkey_address: address, value: 1_000 }] },
+ ],
+ address,
+ 30,
+ 1,
+ 4_000
+ );
+ expect(receipt).toEqual({ confirmed: 20_000n, unconfirmed: 1_000n, txids: ['new', 'mem'] });
+ });
+
+ it('ignores older monero transfers', () => {
+ const receipt = sumMoneroTransfers(
+ [
+ { txid: 'old', amount: 9, confirmations: 99, timestamp: 1_000, subaddr_index: { major: 0, minor: 1 } },
+ { txid: 'new', amount: 4, confirmations: 99, timestamp: 5_000, subaddr_index: { major: 0, minor: 1 } },
+ ],
+ [],
+ 1,
+ 10,
+ 4_000
+ );
+ expect(receipt).toEqual({ confirmed: 4n, unconfirmed: 0n, txids: ['new'] });
+ });
+});
diff --git a/types/auth.d.ts b/types/auth.d.ts
index e665b4cd..504e5a5e 100644
--- a/types/auth.d.ts
+++ b/types/auth.d.ts
@@ -6,6 +6,8 @@ declare module '#auth-utils' {
isModerator: boolean;
uploaded: number;
downloaded: number;
+ // Paid registration: locked until the sign-up payment is confirmed
+ paymentPending?: boolean;
}
interface UserSession {
@@ -24,6 +26,7 @@ export interface PublicUser {
isModerator: boolean;
uploaded: number;
downloaded: number;
+ paymentPending?: boolean;
}
export {};
From b60d2309f7b567c95e08a4c23f44461e24a14b4d Mon Sep 17 00:00:00 2001
From: Florian <52180080+florianjs@users.noreply.github.com>
Date: Mon, 9 Nov 2026 15:06:52 +0100
Subject: [PATCH 2/4] fix(payments): never purge on stale data, lock the row,
validate inputs
- the purge skips accounts whose chain check failed this cycle: during an
API outage their stored amounts are stale and a paid account was deleted
- purge locks the user row before releasing the invite: an account unlocked
meanwhile keeps its invite and is not counted as deleted
- PAYMENT_CHECK_INTERVAL: digits only, at least 5 s, else 60 s ("60s" made
setInterval fire every millisecond)
- jsonRpc rejects bodies without result or error instead of returning
undefined
Co-Authored-By: Claude Opus 5.5
---
server/plugins/payment-watcher.ts | 7 +++--
server/utils/payments/http.ts | 7 ++++-
server/utils/payments/invoices.ts | 27 ++++++++++++----
shared/utils/paidRegistration.ts | 13 ++++++++
tests/paid-registration.test.ts | 51 ++++++++++++++++++++++++++++++-
5 files changed, 95 insertions(+), 10 deletions(-)
diff --git a/server/plugins/payment-watcher.ts b/server/plugins/payment-watcher.ts
index c8c53ea2..052bfffd 100644
--- a/server/plugins/payment-watcher.ts
+++ b/server/plugins/payment-watcher.ts
@@ -1,6 +1,7 @@
import { redis } from '../redis/client';
import { claim } from '../redis/claim';
import { getPaidRegistration } from '../utils/settings';
+import { parseCheckInterval } from '../../shared/utils/paidRegistration';
import {
listOpenInvoices,
purgeUnpaidAccounts,
@@ -13,7 +14,7 @@ import {
* With several app nodes, only the first to claim an interval runs it.
*/
export default defineNitroPlugin(() => {
- const INTERVAL = parseInt(process.env.PAYMENT_CHECK_INTERVAL || '60000', 10);
+ const INTERVAL = parseCheckInterval(process.env.PAYMENT_CHECK_INTERVAL);
let running = false;
const run = async () => {
@@ -25,10 +26,12 @@ export default defineNitroPlugin(() => {
const invoices = await listOpenInvoices();
// One at a time: public APIs (mempool.space, Solana RPC) rate-limit
+ const failedUsers = new Set();
for (const invoice of invoices) {
try {
await refreshInvoice(invoice, config);
} catch (err) {
+ failedUsers.add(invoice.userId);
console.error(
`[Payments] Check failed for ${invoice.currency} invoice ${invoice.id}:`,
(err as Error).message
@@ -36,7 +39,7 @@ export default defineNitroPlugin(() => {
}
}
- const purged = await purgeUnpaidAccounts(config.pendingDays);
+ const purged = await purgeUnpaidAccounts(config.pendingDays, [...failedUsers]);
if (purged > 0) console.log(`[Payments] Deleted ${purged} unpaid account(s)`);
} catch (err) {
console.error('[Payments] Watcher failed:', err);
diff --git a/server/utils/payments/http.ts b/server/utils/payments/http.ts
index 4aa05d87..d21d16ec 100644
--- a/server/utils/payments/http.ts
+++ b/server/utils/payments/http.ts
@@ -36,8 +36,13 @@ export async function jsonRpc(url: string, method: string, params: unknown):
signal: AbortSignal.timeout(TIMEOUT_MS),
});
if (!res.ok) throw new Error(`${method}: HTTP ${res.status}`);
- const body = (await res.json()) as { result?: T; error?: { message?: string } };
+ const body = (await res.json()) as { result?: T; error?: { message?: string } } | null;
+ if (typeof body !== 'object' || body === null) {
+ throw new Error(`${method}: malformed RPC response`);
+ }
if (body.error) throw new Error(`${method}: ${body.error.message ?? 'RPC error'}`);
+ // Key presence, not truthiness: getTransaction legitimately answers null
+ if (!('result' in body)) throw new Error(`${method}: RPC response has no result`);
return body.result as T;
}
diff --git a/server/utils/payments/invoices.ts b/server/utils/payments/invoices.ts
index c292d571..9beed2f5 100644
--- a/server/utils/payments/invoices.ts
+++ b/server/utils/payments/invoices.ts
@@ -5,7 +5,7 @@
* the account is unlocked (users.payment_pending = false).
*/
-import { and, eq, inArray, lt, sql } from 'drizzle-orm';
+import { and, eq, inArray, lt, notInArray, sql } from 'drizzle-orm';
import { db } from '../../db';
import { invitations, registrationPayments, users } from '../../db/schema';
import {
@@ -211,8 +211,14 @@ export async function listOpenInvoices(): Promise {
* Delete locked accounts older than `pendingDays` on which nothing was ever
* seen. Accounts with money on the way are kept for an admin to settle. The
* invite they used, if any, becomes usable again.
+ *
+ * `skipUserIds`: accounts whose chain check failed this cycle. Their stored
+ * amounts may be stale (API outage): never delete on stale data.
*/
-export async function purgeUnpaidAccounts(pendingDays: number): Promise {
+export async function purgeUnpaidAccounts(
+ pendingDays: number,
+ skipUserIds: string[] = []
+): Promise {
const cutoff = new Date(Date.now() - pendingDays * 86_400_000);
const stale = await db
.select({ id: users.id })
@@ -221,6 +227,7 @@ export async function purgeUnpaidAccounts(pendingDays: number): Promise
and(
eq(users.paymentPending, true),
lt(users.createdAt, cutoff),
+ skipUserIds.length > 0 ? notInArray(users.id, skipUserIds) : undefined,
sql`not exists (
select 1 from ${registrationPayments}
where ${registrationPayments.userId} = ${users.id}
@@ -235,15 +242,23 @@ export async function purgeUnpaidAccounts(pendingDays: number): Promise
let purged = 0;
for (const { id } of stale) {
try {
- await db.transaction(async (tx) => {
+ const deleted = await db.transaction(async (tx) => {
+ // Row lock: an unlock running now waits, so the check below holds
+ // until the delete (paid in the meantime means kept, invite included)
+ const [locked] = await tx
+ .select({ id: users.id })
+ .from(users)
+ .where(and(eq(users.id, id), eq(users.paymentPending, true)))
+ .for('update');
+ if (!locked) return false;
await tx
.update(invitations)
.set({ usedBy: null, usedAt: null })
.where(eq(invitations.usedBy, id));
- // Re-checked under the delete: paid in the meantime means kept
- await tx.delete(users).where(and(eq(users.id, id), eq(users.paymentPending, true)));
+ await tx.delete(users).where(eq(users.id, id));
+ return true;
});
- purged++;
+ if (deleted) purged++;
} catch (err) {
console.error(`[Payments] Could not purge unpaid account ${id}:`, (err as Error).message);
}
diff --git a/shared/utils/paidRegistration.ts b/shared/utils/paidRegistration.ts
index d7c6b5f4..5fb370e6 100644
--- a/shared/utils/paidRegistration.ts
+++ b/shared/utils/paidRegistration.ts
@@ -201,3 +201,16 @@ export function paymentUri(
return `solana:${address}?amount=${amountDue}${reference ? `&reference=${reference}` : ''}`;
}
}
+
+export const DEFAULT_CHECK_INTERVAL_MS = 60_000;
+export const MIN_CHECK_INTERVAL_MS = 5_000;
+
+/**
+ * PAYMENT_CHECK_INTERVAL in ms: digits only and at least 5 s, otherwise the
+ * default ("60s" or "0" must not turn the watcher into a tight loop)
+ */
+export function parseCheckInterval(raw: string | undefined): number {
+ if (!raw || !/^\d+$/.test(raw.trim())) return DEFAULT_CHECK_INTERVAL_MS;
+ const value = Number(raw.trim());
+ return value >= MIN_CHECK_INTERVAL_MS ? value : DEFAULT_CHECK_INTERVAL_MS;
+}
diff --git a/tests/paid-registration.test.ts b/tests/paid-registration.test.ts
index b15725e4..1c910d1c 100644
--- a/tests/paid-registration.test.ts
+++ b/tests/paid-registration.test.ts
@@ -1,4 +1,4 @@
-import { describe, it, expect } from 'vitest';
+import { afterEach, describe, it, expect, vi } from 'vitest';
import {
availableCurrencies,
fromAtomic,
@@ -183,3 +183,52 @@ describe('payments made before the invoice', () => {
expect(receipt).toEqual({ confirmed: 4n, unconfirmed: 0n, txids: ['new'] });
});
});
+
+describe('watcher interval', () => {
+ it('accepts whole milliseconds of at least 5 s, else the default', async () => {
+ const { parseCheckInterval } = await import('../shared/utils/paidRegistration');
+ expect(parseCheckInterval(undefined)).toBe(60_000);
+ expect(parseCheckInterval('30000')).toBe(30_000);
+ expect(parseCheckInterval('60s')).toBe(60_000);
+ expect(parseCheckInterval('0')).toBe(60_000);
+ expect(parseCheckInterval('4999')).toBe(60_000);
+ expect(parseCheckInterval('-1')).toBe(60_000);
+ });
+});
+
+describe('json-rpc responses', () => {
+ const reply = (body: unknown) =>
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify(body)));
+
+ afterEach(() => vi.restoreAllMocks());
+
+ it('returns the result, null included', async () => {
+ const { jsonRpc } = await import('../server/utils/payments/http');
+ reply({ result: { ok: 1 } });
+ await expect(jsonRpc('http://rpc', 'm', {})).resolves.toEqual({ ok: 1 });
+ reply({ result: null });
+ await expect(jsonRpc('http://rpc', 'm', {})).resolves.toBeNull();
+ });
+
+ it('rejects errors and malformed bodies with the method name', async () => {
+ const { jsonRpc } = await import('../server/utils/payments/http');
+ reply({ error: { message: 'No wallet file' } });
+ await expect(jsonRpc('http://rpc', 'get_transfers', {})).rejects.toThrow('get_transfers: No wallet file');
+ reply({});
+ await expect(jsonRpc('http://rpc', 'get_transfers', {})).rejects.toThrow('get_transfers: RPC response has no result');
+ reply(null);
+ await expect(jsonRpc('http://rpc', 'get_transfers', {})).rejects.toThrow('get_transfers: malformed RPC response');
+ });
+});
+
+describe('monero keys', () => {
+ it('accepts primary addresses and view keys only', async () => {
+ const { isMoneroPrimaryAddress, isMoneroViewKey } = await import('../server/utils/payments/xmr');
+ const primary = '4' + 'A'.repeat(94);
+ expect(isMoneroPrimaryAddress(primary)).toBe(true);
+ expect(isMoneroPrimaryAddress('8' + 'A'.repeat(94))).toBe(false); // subaddress
+ expect(isMoneroPrimaryAddress('4' + '0'.repeat(94))).toBe(false); // not base58
+ expect(isMoneroViewKey('ab'.repeat(32))).toBe(true);
+ expect(isMoneroViewKey('xy'.repeat(32))).toBe(false);
+ });
+});
From 6e6d4af3019538e74afa02d7fbbf89b3fb2d1849 Mon Sep 17 00:00:00 2001
From: Florian <52180080+florianjs@users.noreply.github.com>
Date: Mon, 9 Nov 2026 15:06:52 +0100
Subject: [PATCH 3/4] feat(payments): bundled monero wallet set up by the
installer
- docker-compose.prod.yml: view-only monero-wallet-rpc service (profile
monero, pinned image, no published port)
- install.sh asks whether to accept Monero and for a node, generates the
wallet password, enables the profile and keeps it all on re-runs
- Admin > Payments creates the view-only wallet from the primary address and
private view key (written to the wallet file only); the app reopens it
after a restart, and Monero cannot be switched on before it is ready
- docs: guide, getting started, configuration, env reference, README,
.env.example
Co-Authored-By: Claude Opus 5.5
---
.env.example | 8 ++
README.md | 1 +
app/components/admin/Payments.vue | 81 +++++++++++++++--
doc/guide/configuration.md | 4 +
doc/guide/getting-started.md | 4 +
doc/guide/paid-registration.md | 53 +++++++----
doc/reference/env.md | 8 +-
docker-compose.prod.yml | 28 ++++++
i18n/locales/de/payments.json | 12 ++-
i18n/locales/en/payments.json | 12 ++-
i18n/locales/es/payments.json | 12 ++-
i18n/locales/fr/payments.json | 12 ++-
i18n/locales/it/payments.json | 12 ++-
i18n/locales/ja/payments.json | 12 ++-
i18n/locales/ru/payments.json | 12 ++-
i18n/locales/zh/payments.json | 12 ++-
scripts/install.sh | 106 +++++++++++++++++++++-
server/api/admin/payments/monero.get.ts | 11 +++
server/api/admin/payments/monero.post.ts | 48 ++++++++++
server/api/admin/payments/settings.get.ts | 7 +-
server/api/admin/payments/settings.put.ts | 7 ++
server/utils/payments/xmr.ts | 103 +++++++++++++++++++--
22 files changed, 490 insertions(+), 75 deletions(-)
create mode 100644 server/api/admin/payments/monero.get.ts
create mode 100644 server/api/admin/payments/monero.post.ts
diff --git a/.env.example b/.env.example
index c18e6a2f..2336a39a 100644
--- a/.env.example
+++ b/.env.example
@@ -77,6 +77,14 @@ TRACKER_INTERNAL_PORT=8090
TRACKER_INTERNAL_URL=http://localhost:8090
TRACKER_INTERNAL_TOKEN=
+# Paid registration (optional, Admin > Payments). Bitcoin and Solana need no
+# variable. Monero: view-only wallet service, set up by scripts/install.sh
+# COMPOSE_PROFILES=monero
+# MONERO_DAEMON_ADDRESS=node.example.com:18089
+# MONERO_WALLET_RPC_URL=http://monero-wallet-rpc:18083
+# MONERO_WALLET_PASSWORD= # openssl rand -hex 32
+# PAYMENT_CHECK_INTERVAL=60000
+
# Debug mode for verbose tracker logging
TRACKER_DEBUG=false
diff --git a/README.md b/README.md
index 63b9b826..39a9052a 100644
--- a/README.md
+++ b/README.md
@@ -41,6 +41,7 @@ Built with Nuxt 4 • Rust • PostgreSQL • Redis
| Global freeleech (timed or open-ended) | Torrent moderation queue |
| Forum, comments, reports, invitations, H&R | UI in 8 languages (EN, FR, ES, DE, IT, ZH, JA, RU) |
| Private messages between members, with blocks | Light & dark themes, ⌘K command palette |
+| Paid sign-up in Bitcoin, Monero or Solana | On-chain payment checks, no processor, no spend key |
| **Security** | **Emergency** |
| -------------------------------- | -------------------------------------------- |
diff --git a/app/components/admin/Payments.vue b/app/components/admin/Payments.vue
index f2e67793..918ca40f 100644
--- a/app/components/admin/Payments.vue
+++ b/app/components/admin/Payments.vue
@@ -64,10 +64,44 @@
-