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/ToggleSwitch.vue b/app/components/ToggleSwitch.vue new file mode 100644 index 00000000..9022b903 --- /dev/null +++ b/app/components/ToggleSwitch.vue @@ -0,0 +1,21 @@ + + + diff --git a/app/components/admin/Payments.vue b/app/components/admin/Payments.vue new file mode 100644 index 00000000..918ca40f --- /dev/null +++ b/app/components/admin/Payments.vue @@ -0,0 +1,314 @@ + + + diff --git a/app/middleware/auth.global.ts b/app/middleware/auth.global.ts index 3c1e481f..ee962591 100644 --- a/app/middleware/auth.global.ts +++ b/app/middleware/auth.global.ts @@ -4,7 +4,7 @@ * Redirects to setup if no users exist */ export default defineNuxtRouteMiddleware(async (to) => { - const { loggedIn, fetch: fetchSession } = useUserSession(); + const { loggedIn, user, fetch: fetchSession } = useUserSession(); // Public routes that don't require auth const publicRoutes = ['/auth/login', '/auth/register']; @@ -51,4 +51,13 @@ export default defineNuxtRouteMiddleware(async (to) => { if (loggedIn.value && isPublicRoute) { return navigateTo('/'); } + + // Paid registration: a locked account only sees the payment page + const onPaymentPage = to.path === '/auth/payment'; + if (loggedIn.value && user.value?.paymentPending && !onPaymentPage) { + return navigateTo('/auth/payment'); + } + if (loggedIn.value && !user.value?.paymentPending && onPaymentPage) { + return navigateTo('/'); + } }); diff --git a/app/pages/admin.vue b/app/pages/admin.vue index 9e1fab0f..462a21b5 100644 --- a/app/pages/admin.vue +++ b/app/pages/admin.vue @@ -151,6 +151,12 @@ const menuItems = computed(() => [ icon: 'ph:coins', description: t('bonus.adminNav.description'), }, + { + label: t('payments.adminNav.label'), + path: '/admin/payments', + icon: 'ph:currency-btc', + description: t('payments.adminNav.description'), + }, { label: t('shoutbox.adminNav.label'), path: '/admin/shoutbox', diff --git a/app/pages/admin/payments.vue b/app/pages/admin/payments.vue new file mode 100644 index 00000000..c222de1a --- /dev/null +++ b/app/pages/admin/payments.vue @@ -0,0 +1,3 @@ + diff --git a/app/pages/auth/payment.vue b/app/pages/auth/payment.vue new file mode 100644 index 00000000..9c0bed13 --- /dev/null +++ b/app/pages/auth/payment.vue @@ -0,0 +1,260 @@ + + + diff --git a/app/pages/auth/register.vue b/app/pages/auth/register.vue index 35973c62..fe4698c9 100644 --- a/app/pages/auth/register.vue +++ b/app/pages/auth/register.vue @@ -56,6 +56,23 @@

+ +
+

+ + {{ t('payments.register.notice', { prices: priceList }) }} +

+

+ {{ t('payments.register.unlock', { days: paidInfo.pendingDays }) }} +

+

+ {{ t('payments.register.inviteFree') }} +

+
+
@@ -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/configuration.md b/doc/guide/configuration.md index 104781aa..8c1ac76a 100644 --- a/doc/guide/configuration.md +++ b/doc/guide/configuration.md @@ -68,6 +68,10 @@ the default 22 (~4 million hashes) takes 1-2 s there, longer on phones. A value outside 16-32 is ignored with a warning and the default is used (older docs described a 1-10 scale, which the server never applied). +### Paid Registration + +Optional sign-up fee in Bitcoin, Monero or Solana, set in *Admin → Payments*. Only Monero needs environment variables (set by the installer): see [Paid Registration](/guide/paid-registration#environment). + ## Docker Compose Configuration ### Production (`docker-compose.prod.yml`) diff --git a/doc/guide/getting-started.md b/doc/guide/getting-started.md index e97f9017..45a9028d 100644 --- a/doc/guide/getting-started.md +++ b/doc/guide/getting-started.md @@ -62,6 +62,8 @@ For an unattended install, pass the values as environment variables: sudo DOMAIN=example.com ACME_EMAIL=admin@example.com ./install.sh --yes ``` +To accept Monero for [paid registration](/guide/paid-registration), add `MONERO_ENABLED=true MONERO_DAEMON_ADDRESS=node.example.com:18089` (the interactive installer asks). Bitcoin and Solana need nothing at install time. + `./install.sh --help` lists every option. After installation, open `https://monitoring.your-domain.com` and log in with the monitoring login shown at the end of the install (also in `.env`: `BESZEL_ADMIN_EMAIL` / `BESZEL_ADMIN_PASSWORD`). Then [pair the agent](/guide/monitoring#pairing-the-agent). @@ -132,6 +134,7 @@ Open `https://your-domain.com` to access your tracker. 2. **Set a Panic Password** — This is required and protects your data in emergencies 3. **Configure site settings** — Set your tracker name, description, and rules 4. **Invite users** — Use the invitation system to add trusted members +5. **Charge for sign-up (optional)**: Bitcoin, Monero or Solana, verified on-chain: see [Paid Registration](/guide/paid-registration) ## Updating @@ -188,5 +191,6 @@ Log in to `https://monitoring.your-domain.com` and [pair the Beszel agent](/guid ## Next Steps - [Configuration](/guide/configuration) — Learn about environment variables and settings +- [Paid Registration](/guide/paid-registration): Charge for sign-up in BTC, XMR or SOL - [Security](/guide/security) — Understand the security architecture - [Zero-Knowledge Auth](/guide/zero-knowledge-auth) — How authentication works diff --git a/doc/guide/paid-registration.md b/doc/guide/paid-registration.md new file mode 100644 index 00000000..7f273a22 --- /dev/null +++ b/doc/guide/paid-registration.md @@ -0,0 +1,92 @@ +# 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 spend key on the server**: nothing on it can move your funds. Bitcoin and Solana only need public keys. Monero needs your **private view key**, sent once to the server to create a view-only wallet: it reveals incoming payments, not the ability to spend. + +| 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 + +Bitcoin and Solana need nothing on the server: everything is set in the admin panel, and the database schema is updated when the app starts. Monero runs one extra container, a view-only wallet, which the [installer](#installer) sets up. + +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 sees what arrives and **cannot spend**. + +1. **Start the wallet service.** Re-run the installer and answer *yes* to *Accept Monero for paid registration?* (see [Installer](#installer)). It asks for a Monero node (`host:port`), then starts `monero-wallet-rpc` (the `monero` profile of `docker-compose.prod.yml`) inside Docker, without any published port. +2. **Create the wallet.** In *Admin → Payments → Monero*, paste your **primary address** (starts with `4`), your **private view key** (Feather: *Wallet → Keys*; Cake Wallet and the GUI show it too) and a **restore height**: the current block height for a new wallet, or the height of its creation (scanning from 0 takes hours). The server creates the wallet file with a password generated by the installer (`MONERO_WALLET_PASSWORD`). The view key is written to that file only: it is not stored in the database nor shown again. +3. Wait for the status to show the wallet ready, then switch Monero on and set its price. Saving refuses Monero while the wallet is not ready. + +After a restart of the wallet service, the app reopens the wallet by itself. + +::: tip Choosing a node +The wallet only reads the chain through the node: a public remote node works (it sees which blocks the wallet asks for, not your keys), your own `monerod` is better for privacy. `Confirmations required` defaults to 10 (the Monero unlock time). +::: + +::: details Using your own monero-wallet-rpc +Point `MONERO_WALLET_RPC_URL` at it (and leave Monero off in the installer). The app talks to it without a login, so keep it on a private network. Create the wallet from the admin page as above: it is named `trackarr-view` and protected by `MONERO_WALLET_PASSWORD`, so the app reopens it whenever the RPC restarts. A wallet you opened yourself under another name works until the next restart of the RPC; after that, invoice checks fail until something opens it again (or start the RPC with `--wallet-file` so it always does). +::: + +### 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. An account whose check failed (chain API or wallet down) is never deleted in that cycle. 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. + +## Installer + +`scripts/install.sh` asks once whether to accept Monero, and keeps the answer and every payment setting below when it is run again (updates included). Unattended: + +```bash +sudo DOMAIN=example.com ACME_EMAIL=admin@example.com \ + MONERO_ENABLED=true MONERO_DAEMON_ADDRESS=node.example.com:18089 \ + ./install.sh --yes +``` + +The wallet file lives in the `monero_wallet` Docker volume. Back it up with `.env`: without `MONERO_WALLET_PASSWORD` the file cannot be opened (you can always delete it and create the wallet again from the admin page, past payments stay in the database). + +## Environment + +| Variable | Description | Default | +| ------------------------ | ----------------------------------------------------------------------------- | ------- | +| `MONERO_WALLET_RPC_URL` | Wallet RPC the app uses (empty: Monero is not offered); set by the installer | - | +| `MONERO_WALLET_PASSWORD` | Password of the view-only wallet file; generated by the installer | - | +| `MONERO_DAEMON_ADDRESS` | Monero node of the bundled wallet service (`host:port`) | - | +| `COMPOSE_PROFILES` | Contains `monero` to start the bundled wallet service | - | +| `PAYMENT_CHECK_INTERVAL` | Payment watcher interval in ms (minimum 5000) | `60000` | diff --git a/doc/reference/env.md b/doc/reference/env.md index 06bf4ee1..09485f86 100644 --- a/doc/reference/env.md +++ b/doc/reference/env.md @@ -96,6 +96,19 @@ 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` | Wallet RPC the app uses (empty: Monero is not offered) | - | +| `MONERO_WALLET_PASSWORD` | Password of the view-only wallet file | - | +| `MONERO_DAEMON_ADDRESS` | Monero node of the bundled wallet service (`host:port`) | - | +| `PAYMENT_CHECK_INTERVAL` | Payment watcher interval in ms (minimum 5000) | `60000` | + +The installer sets the Monero variables and adds `monero` to `COMPOSE_PROFILES` when you accept Monero. + ## 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..3fb423cb 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -85,6 +85,10 @@ 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:-} + MONERO_WALLET_PASSWORD: ${MONERO_WALLET_PASSWORD:-} + PAYMENT_CHECK_INTERVAL: ${PAYMENT_CHECK_INTERVAL:-60000} expose: - "3000" volumes: @@ -391,6 +395,31 @@ services: limits: memory: 64M + # Paid registration in Monero: view-only wallet (sees incoming payments, + # cannot spend), created from Admin > Payments. COMPOSE_PROFILES=monero + monero-wallet-rpc: + image: ghcr.io/sethforprivacy/simple-monero-wallet-rpc:v0.18.5.4 + container_name: trackarr-monero-wallet-rpc + profiles: [monero] + restart: unless-stopped + command: + - --wallet-dir=/home/monero/wallet + - --rpc-bind-port=18083 + - --disable-rpc-login + - --daemon-address=${MONERO_DAEMON_ADDRESS:-} + environment: + # No login: reachable only by the app, inside Docker (no published port) + RPC_EXPOSE_UNAUTHENTICATED: "1" + volumes: + - monero_wallet:/home/monero/wallet + networks: + - trackarr-internal # reached by the app + - trackarr-public # reaches the Monero node (trackarr-internal has no internet) + deploy: + resources: + limits: + memory: 512M + # Filtered Docker API for the agent: container stats only, read-only docker-proxy: image: lscr.io/linuxserver/socket-proxy:3.4.7-r0-ls103 @@ -455,6 +484,8 @@ volumes: driver: local redis_data: driver: local + monero_wallet: + driver: local caddy_data: driver: local caddy_config: 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..d6ffa953 --- /dev/null +++ b/i18n/locales/de/payments.json @@ -0,0 +1,68 @@ +{ + "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 bereit, synchronisiert bis Block {height}", + "moneroMissing": "Monero braucht den mitgelieferten Wallet-Dienst: Installer erneut ausführen und Monero annehmen, oder MONERO_WALLET_RPC_URL setzen", + "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", + "moneroCreateIntro": "Der Wallet-Dienst läuft, hat aber noch keine Wallet. Erstelle eine View-only-Wallet: Sie sieht eingehende Zahlungen und kann nichts ausgeben. Der View-Key wird nur in die Wallet-Datei geschrieben.", + "moneroAddress": "Hauptadresse (beginnt mit 4)", + "moneroViewKey": "Privater View-Key", + "moneroRestoreHeight": "Wiederherstellungshöhe", + "moneroRestoreHeightHint": "Block, ab dem gescannt wird: die aktuelle Höhe für eine neue Wallet (ab 0 dauert es Stunden)", + "moneroCreate": "View-only-Wallet erstellen" + } + } +} diff --git a/i18n/locales/en/payments.json b/i18n/locales/en/payments.json new file mode 100644 index 00000000..17f99496 --- /dev/null +++ b/i18n/locales/en/payments.json @@ -0,0 +1,68 @@ +{ + "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 ready, scanned up to block {height}", + "moneroMissing": "Monero needs the bundled wallet service: re-run the installer and accept Monero, or set MONERO_WALLET_RPC_URL", + "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", + "moneroCreateIntro": "The wallet service is running but has no wallet yet. Create a view-only wallet: it sees incoming payments and cannot spend. The view key is written to the wallet file only.", + "moneroAddress": "Primary address (starts with 4)", + "moneroViewKey": "Private view key", + "moneroRestoreHeight": "Restore height", + "moneroRestoreHeightHint": "Block to start scanning from: the current height for a new wallet (scanning from 0 takes hours)", + "moneroCreate": "Create the view-only wallet" + } + } +} diff --git a/i18n/locales/es/payments.json b/i18n/locales/es/payments.json new file mode 100644 index 00000000..bf8e0c93 --- /dev/null +++ b/i18n/locales/es/payments.json @@ -0,0 +1,68 @@ +{ + "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": "Monedero listo, sincronizado hasta el bloque {height}", + "moneroMissing": "Monero necesita el servicio de monedero incluido: vuelve a ejecutar el instalador aceptando Monero, o define MONERO_WALLET_RPC_URL", + "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", + "moneroCreateIntro": "El servicio de monedero está activo pero aún no tiene monedero. Crea un monedero de solo lectura: ve los pagos entrantes y no puede gastar. La clave de vista solo se escribe en el archivo del monedero.", + "moneroAddress": "Dirección principal (empieza por 4)", + "moneroViewKey": "Clave de vista privada", + "moneroRestoreHeight": "Altura de restauración", + "moneroRestoreHeightHint": "Bloque desde el que escanear: la altura actual para un monedero nuevo (escanear desde 0 tarda horas)", + "moneroCreate": "Crear el monedero de solo lectura" + } + } +} diff --git a/i18n/locales/fr/payments.json b/i18n/locales/fr/payments.json new file mode 100644 index 00000000..8110bdd4 --- /dev/null +++ b/i18n/locales/fr/payments.json @@ -0,0 +1,68 @@ +{ + "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 prêt, synchronisé jusqu'au bloc {height}", + "moneroMissing": "Monero nécessite le service wallet fourni : relancez l'installateur en acceptant Monero, ou définissez MONERO_WALLET_RPC_URL", + "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", + "moneroCreateIntro": "Le service wallet tourne mais n'a pas encore de wallet. Créez un wallet en lecture seule : il voit les paiements entrants et ne peut rien dépenser. La clé de vue est écrite uniquement dans le fichier du wallet.", + "moneroAddress": "Adresse principale (commence par 4)", + "moneroViewKey": "Clé de vue privée", + "moneroRestoreHeight": "Hauteur de restauration", + "moneroRestoreHeightHint": "Bloc à partir duquel scanner : la hauteur actuelle pour un nouveau wallet (scanner depuis 0 prend des heures)", + "moneroCreate": "Créer le wallet en lecture seule" + } + } +} diff --git a/i18n/locales/it/payments.json b/i18n/locales/it/payments.json new file mode 100644 index 00000000..00756dca --- /dev/null +++ b/i18n/locales/it/payments.json @@ -0,0 +1,68 @@ +{ + "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 pronto, sincronizzato fino al blocco {height}", + "moneroMissing": "Monero richiede il servizio wallet incluso: riesegui l'installer accettando Monero, oppure imposta MONERO_WALLET_RPC_URL", + "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", + "moneroCreateIntro": "Il servizio wallet è attivo ma non ha ancora un wallet. Crea un wallet in sola lettura: vede i pagamenti in arrivo e non può spendere. La chiave di visualizzazione viene scritta solo nel file del wallet.", + "moneroAddress": "Indirizzo principale (inizia con 4)", + "moneroViewKey": "Chiave di visualizzazione privata", + "moneroRestoreHeight": "Altezza di ripristino", + "moneroRestoreHeightHint": "Blocco da cui iniziare la scansione: l'altezza attuale per un nuovo wallet (da 0 servono ore)", + "moneroCreate": "Crea il wallet in sola lettura" + } + } +} diff --git a/i18n/locales/ja/payments.json b/i18n/locales/ja/payments.json new file mode 100644 index 00000000..2e44af2a --- /dev/null +++ b/i18n/locales/ja/payments.json @@ -0,0 +1,68 @@ +{ + "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": "ウォレット準備完了、ブロック {height} まで同期済み", + "moneroMissing": "Monero には付属のウォレットサービスが必要です。インストーラーを再実行して Monero を有効にするか、MONERO_WALLET_RPC_URL を設定してください", + "solAddress": "受取アドレス", + "solRpcUrl": "Solana RPC", + "saveFailed": "設定を保存できませんでした", + "pendingTitle": "ロック中のアカウント", + "noPending": "支払い待ちのアカウントはありません", + "approve": "ロック解除", + "paidTitle": "最近の支払い", + "noPaid": "まだ支払いはありません", + "manual": "手動", + "moneroCreateIntro": "ウォレットサービスは起動していますが、まだウォレットがありません。閲覧専用ウォレットを作成してください。入金は確認できますが送金はできません。閲覧鍵はウォレットファイルにのみ書き込まれます。", + "moneroAddress": "プライマリアドレス (4 で始まる)", + "moneroViewKey": "秘密閲覧鍵", + "moneroRestoreHeight": "復元ブロック高", + "moneroRestoreHeightHint": "スキャン開始ブロック。新しいウォレットなら現在のブロック高 (0 からだと数時間かかります)", + "moneroCreate": "閲覧専用ウォレットを作成" + } + } +} diff --git a/i18n/locales/ru/payments.json b/i18n/locales/ru/payments.json new file mode 100644 index 00000000..fd271a85 --- /dev/null +++ b/i18n/locales/ru/payments.json @@ -0,0 +1,68 @@ +{ + "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": "Кошелёк готов, синхронизирован до блока {height}", + "moneroMissing": "Для Monero нужен встроенный сервис кошелька: перезапустите установщик и включите Monero или задайте MONERO_WALLET_RPC_URL", + "solAddress": "Адрес получения", + "solRpcUrl": "RPC Solana", + "saveFailed": "Не удалось сохранить настройки", + "pendingTitle": "Заблокированные аккаунты", + "noPending": "Нет аккаунтов, ожидающих оплаты", + "approve": "Разблокировать", + "paidTitle": "Последние платежи", + "noPaid": "Платежей пока нет", + "manual": "вручную", + "moneroCreateIntro": "Сервис кошелька запущен, но кошелька ещё нет. Создайте кошелёк только для просмотра: он видит входящие платежи и не может тратить. Ключ просмотра записывается только в файл кошелька.", + "moneroAddress": "Основной адрес (начинается с 4)", + "moneroViewKey": "Приватный ключ просмотра", + "moneroRestoreHeight": "Высота восстановления", + "moneroRestoreHeightHint": "Блок, с которого начать сканирование: текущая высота для нового кошелька (с 0 это займёт часы)", + "moneroCreate": "Создать кошелёк для просмотра" + } + } +} diff --git a/i18n/locales/zh/payments.json b/i18n/locales/zh/payments.json new file mode 100644 index 00000000..14dff502 --- /dev/null +++ b/i18n/locales/zh/payments.json @@ -0,0 +1,68 @@ +{ + "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": "钱包已就绪,已同步至区块 {height}", + "moneroMissing": "Monero 需要内置的钱包服务:重新运行安装程序并启用 Monero,或设置 MONERO_WALLET_RPC_URL", + "solAddress": "收款地址", + "solRpcUrl": "Solana RPC", + "saveFailed": "无法保存设置", + "pendingTitle": "锁定的账户", + "noPending": "没有等待付款的账户", + "approve": "解锁", + "paidTitle": "最近付款", + "noPaid": "暂无付款", + "manual": "手动", + "moneroCreateIntro": "钱包服务已运行,但还没有钱包。请创建只读钱包:它可以看到入账,但无法花费。查看密钥只写入钱包文件。", + "moneroAddress": "主地址(以 4 开头)", + "moneroViewKey": "私有查看密钥", + "moneroRestoreHeight": "恢复高度", + "moneroRestoreHeightHint": "开始扫描的区块:新钱包请填当前高度(从 0 开始需要数小时)", + "moneroCreate": "创建只读钱包" + } + } +} 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/scripts/install.sh b/scripts/install.sh index 17258f02..cad7c397 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -26,6 +26,13 @@ TRACKER_DOMAIN="${TRACKER_DOMAIN:-}" MONITORING_DOMAIN="${MONITORING_DOMAIN:-}" ACME_EMAIL="${ACME_EMAIL:-}" MONITORING_PASSWORD="${MONITORING_PASSWORD:-}" +# Paid registration (optional, see the Paid Registration guide) +MONERO_ENABLED="${MONERO_ENABLED:-}" +MONERO_DAEMON_ADDRESS="${MONERO_DAEMON_ADDRESS:-}" +MONERO_WALLET_RPC_URL="${MONERO_WALLET_RPC_URL:-}" +PAYMENT_CHECK_INTERVAL="${PAYMENT_CHECK_INTERVAL:-}" +# Bundled view-only wallet service (docker-compose.prod.yml, profile monero) +BUNDLED_MONERO_RPC="http://monero-wallet-rpc:18083" # Database defaults DB_NAME="${DB_NAME:-trackarr}" @@ -115,6 +122,10 @@ is_domain() { [[ $1 =~ ^([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,}$ ]] } +is_host_port() { + [[ $1 =~ ^[A-Za-z0-9.-]+:[0-9]{1,5}$ ]] +} + is_email() { [[ $1 =~ ^[^@[:space:]]+@[^@[:space:]]+\.[^@[:space:]]+$ ]] } @@ -250,6 +261,13 @@ prompt_configuration() { TRACKER_DOMAIN=${TRACKER_DOMAIN:-$(existing_value TRACKER_DOMAIN)} MONITORING_DOMAIN=${MONITORING_DOMAIN:-$(existing_value MONITORING_DOMAIN)} ACME_EMAIL=${ACME_EMAIL:-$(existing_value ACME_EMAIL)} + # Paid registration (optional): survives a re-run + MONERO_WALLET_RPC_URL=${MONERO_WALLET_RPC_URL:-$(existing_value MONERO_WALLET_RPC_URL)} + MONERO_DAEMON_ADDRESS=${MONERO_DAEMON_ADDRESS:-$(existing_value MONERO_DAEMON_ADDRESS)} + PAYMENT_CHECK_INTERVAL=${PAYMENT_CHECK_INTERVAL:-$(existing_value PAYMENT_CHECK_INTERVAL)} + if [[ -z $MONERO_ENABLED && ,$(existing_value COMPOSE_PROFILES), == *,monero,* ]]; then + MONERO_ENABLED=true + fi # Beszel only reads its login on the first start: keep it as is KEEP_MONITORING_PASSWORD=$(existing_value BESZEL_ADMIN_PASSWORD) BESZEL_ADMIN_EMAIL=$(existing_value BESZEL_ADMIN_EMAIL) @@ -270,6 +288,10 @@ prompt_configuration() { is_domain "$name" || die "Not a valid domain: $name" done is_email "$ACME_EMAIL" || die "Not a valid email: $ACME_EMAIL" + if [[ $MONERO_ENABLED == true ]]; then + is_host_port "$MONERO_DAEMON_ADDRESS" \ + || die "MONERO_DAEMON_ADDRESS (host:port of a Monero node) is required with MONERO_ENABLED=true" + fi else echo "" echo -e " ${DIM}These names must point to this server (DNS A/AAAA records): Caddy${NC}" @@ -279,6 +301,17 @@ prompt_configuration() { ask TRACKER_DOMAIN "Tracker (announce) domain" "${TRACKER_DOMAIN:-announce.$DOMAIN}" is_domain "Not a valid domain name" ask MONITORING_DOMAIN "Monitoring domain" "${MONITORING_DOMAIN:-monitoring.$DOMAIN}" is_domain "Not a valid domain name" ask ACME_EMAIL "Email for Let's Encrypt (expiry notices)" "$ACME_EMAIL" is_email "Not a valid email address" + echo "" + echo -e " ${DIM}Paid registration: Bitcoin and Solana need nothing here (set them in Admin >${NC}" + echo -e " ${DIM}Payments). Monero runs a view-only wallet that needs a Monero node.${NC}" + local monero_default=n + [[ $MONERO_ENABLED == true ]] && monero_default=y + if confirm "Accept Monero for paid registration?" "$monero_default"; then + MONERO_ENABLED=true + ask MONERO_DAEMON_ADDRESS "Monero node (host:port, e.g. node.example.com:18089)" "$MONERO_DAEMON_ADDRESS" is_host_port "Expected host:port" + else + MONERO_ENABLED=false + fi if [[ -z $MONITORING_PASSWORD && -z $KEEP_MONITORING_PASSWORD && $LEGACY_MONITORING != true ]]; then while true; do read -rsp " Monitoring login password, 8+ characters (Enter to generate one): " MONITORING_PASSWORD @@ -302,6 +335,22 @@ prompt_configuration() { die "MONITORING_PASSWORD needs at least 8 characters" fi + # Bundled wallet service: the app talks to it; switched off, forget it + if [[ $MONERO_ENABLED == true ]]; then + MONERO_WALLET_RPC_URL=${MONERO_WALLET_RPC_URL:-$BUNDLED_MONERO_RPC} + elif [[ $MONERO_WALLET_RPC_URL == "$BUNDLED_MONERO_RPC" ]]; then + MONERO_WALLET_RPC_URL="" + fi + if [[ -n $MONERO_WALLET_RPC_URL && ! $MONERO_WALLET_RPC_URL =~ ^https?://[^[:space:]]+$ ]]; then + die "MONERO_WALLET_RPC_URL must be an http(s) URL: $MONERO_WALLET_RPC_URL" + fi + # 5 s to the Node.js timer limit (larger delays become 1 ms); at most 10 + # digits, so the arithmetic below cannot overflow + if [[ -n $PAYMENT_CHECK_INTERVAL ]] && { [[ ! $PAYMENT_CHECK_INTERVAL =~ ^[0-9]{1,10}$ ]] \ + || (( 10#$PAYMENT_CHECK_INTERVAL < 5000 || 10#$PAYMENT_CHECK_INTERVAL > 2147483647 )); }; then + die "PAYMENT_CHECK_INTERVAL must be milliseconds between 5000 and 2147483647" + fi + check_dns } @@ -351,6 +400,9 @@ confirm_plan() { echo -e " Directory ${INSTALL_DIR}" echo -e " Secrets ${secrets}" echo -e " Firewall allow SSH (port ${SSH_PORT}), HTTP and HTTPS only" + if [[ $MONERO_ENABLED == true ]]; then + echo -e " Monero payments view-only wallet, node ${MONERO_DAEMON_ADDRESS}" + fi echo "" log_info "Next: install Docker if needed, build Trackarr (5-10 minutes) and start it." confirm "Proceed?" y || { log_info "Nothing was changed"; exit 0; } @@ -527,6 +579,11 @@ generate_secrets() { [[ -z "$NUXT_SESSION_SECRET" ]] && NUXT_SESSION_SECRET=$(openssl rand -base64 64 | tr -d '/+=' | head -c 64) echo -n "$NUXT_SESSION_SECRET" > "$SECRETS_DIR/nuxt_session_secret.txt" + # Monero view-only wallet password: kept once generated, or the wallet + # file created with it could not be opened again + MONERO_WALLET_PASSWORD=$(existing_value MONERO_WALLET_PASSWORD) + [[ -z "$MONERO_WALLET_PASSWORD" ]] && MONERO_WALLET_PASSWORD=$(openssl rand -hex 32) + chmod 600 "$SECRETS_DIR"/* if [[ $REUSED_SECRETS == true ]]; then log_success "Secrets kept from the existing .env" @@ -547,9 +604,34 @@ create_env_file() { MONITORING_PAIRED=false if [[ -n "$key" ]]; then MONITORING_PAIRED=true - BESZEL_ENV=$(printf 'BESZEL_AGENT_KEY=%s\nBESZEL_AGENT_TOKEN=%s\nCOMPOSE_PROFILES=monitoring' "$key" "$token") + BESZEL_ENV=$(printf 'BESZEL_AGENT_KEY=%s\nBESZEL_AGENT_TOKEN=%s' "$key" "$token") + else + BESZEL_ENV=$(printf '# BESZEL_AGENT_KEY=\n# BESZEL_AGENT_TOKEN=') + fi + + # Optional services: monitoring once paired, Monero wallet if accepted + local profiles="" + [[ $MONITORING_PAIRED == true ]] && profiles="monitoring" + [[ $MONERO_ENABLED == true ]] && profiles="${profiles:+$profiles,}monero" + if [[ -n $profiles ]]; then + PROFILES_ENV="COMPOSE_PROFILES=$profiles" + else + PROFILES_ENV="# COMPOSE_PROFILES=monitoring" + fi + + # Paid registration: Monero needs a view-only monero-wallet-rpc (see the + # Paid Registration guide); unset, Monero is not offered + if [[ -n $MONERO_WALLET_RPC_URL ]]; then + PAYMENT_ENV="MONERO_WALLET_RPC_URL=$MONERO_WALLET_RPC_URL" else - BESZEL_ENV=$(printf '# BESZEL_AGENT_KEY=\n# BESZEL_AGENT_TOKEN=\n# COMPOSE_PROFILES=monitoring') + PAYMENT_ENV="# MONERO_WALLET_RPC_URL=$BUNDLED_MONERO_RPC" + fi + PAYMENT_ENV+=$'\n'"MONERO_WALLET_PASSWORD=$MONERO_WALLET_PASSWORD" + if [[ -n $MONERO_DAEMON_ADDRESS ]]; then + PAYMENT_ENV+=$'\n'"MONERO_DAEMON_ADDRESS=$MONERO_DAEMON_ADDRESS" + fi + if [[ -n $PAYMENT_CHECK_INTERVAL ]]; then + PAYMENT_ENV+=$'\n'"PAYMENT_CHECK_INTERVAL=$PAYMENT_CHECK_INTERVAL" fi # Beszel login, created on its first start only @@ -597,6 +679,14 @@ ACME_EMAIL=${ACME_EMAIL} # Tracker Announce URLs (displayed in admin dashboard) TRACKER_HTTP_URL=https://${TRACKER_DOMAIN}/announce +# Paid registration (Admin > Payments): prices and BTC/SOL keys are set in +# the admin panel; Monero also needs the view-only wallet service (profile +# monero), its wallet is created from the admin panel +${PAYMENT_ENV} + +# Optional services (docker compose profiles) +${PROFILES_ENV} + # Monitoring (Beszel): login read on its first start only, change the # password in Beszel afterwards ${BESZEL_LOGIN} @@ -757,6 +847,10 @@ print_summary() { echo -e " ${CYAN}sudo ${INSTALL_DIR}/scripts/pair-monitoring.sh${NC} and paste the key and token." echo -e " Step by step: ${CYAN}${DOCS_URL}/guide/monitoring#pairing-the-agent${NC}" fi + if [[ $MONERO_ENABLED == true ]]; then + echo -e " 3. Monero: in ${CYAN}Admin > Payments${NC}, create the view-only wallet (primary" + echo -e " address, private view key, restore height), then switch Monero on." + fi echo "" echo -e " ${BOLD}Useful commands${NC} (in ${INSTALL_DIR})" echo -e " Logs ${CYAN}docker compose -f docker-compose.prod.yml logs -f${NC}" @@ -867,14 +961,21 @@ Environment variables: ACME_EMAIL Email for Let's Encrypt MONITORING_PASSWORD Monitoring (Beszel) password for a new install, 8+ characters (default: generated) + MONERO_ENABLED true: run the view-only Monero wallet for paid + registration (kept on re-runs) + MONERO_DAEMON_ADDRESS Monero node for that wallet (host:port) + MONERO_WALLET_RPC_URL External monero-wallet-rpc instead of the bundled + one (optional, kept on re-runs) + PAYMENT_CHECK_INTERVAL Paid registration check interval in ms + (default: 60000) INSTALL_DIR Installation directory (default: /opt/trackarr) REPO_URL Git repository URL BRANCH Git branch to check out (default: main) LOG_FILE Detailed log (default: /var/log/trackarr-install.log) NO_COLOR Disable colors -Running it again updates the install and keeps its secrets, data and -monitoring pairing. Docs: ${DOCS_URL} +Running it again updates the install and keeps its secrets, data, +monitoring pairing and paid registration settings. Docs: ${DOCS_URL} EOF } 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/monero.get.ts b/server/api/admin/payments/monero.get.ts new file mode 100644 index 00000000..4ceb6055 --- /dev/null +++ b/server/api/admin/payments/monero.get.ts @@ -0,0 +1,11 @@ +import { requireAdminSession } from '../../../utils/adminAuth'; +import { moneroWalletStatus } from '../../../utils/payments/xmr'; + +/** + * GET /api/admin/payments/monero + * Whether the view-only wallet is set up and answering + */ +export default defineEventHandler(async (event) => { + await requireAdminSession(event); + return moneroWalletStatus(); +}); diff --git a/server/api/admin/payments/monero.post.ts b/server/api/admin/payments/monero.post.ts new file mode 100644 index 00000000..2f33a6d5 --- /dev/null +++ b/server/api/admin/payments/monero.post.ts @@ -0,0 +1,48 @@ +import { z } from 'zod'; +import { requireAdminSession } from '../../../utils/adminAuth'; +import { validateBody } from '../../../utils/schemas'; +import { + createViewWallet, + isMoneroPrimaryAddress, + isMoneroViewKey, + moneroRpcUrl, + moneroWalletStatus, +} from '../../../utils/payments/xmr'; + +const bodySchema = z.object({ + address: z.string().trim().refine(isMoneroPrimaryAddress, 'Expected the primary address (starts with 4)'), + viewKey: z + .string() + .trim() + .toLowerCase() + .refine(isMoneroViewKey, 'Expected the private view key: 64 hexadecimal characters'), + restoreHeight: z.number().int().min(0).max(100_000_000), +}); + +/** + * POST /api/admin/payments/monero + * Create the view-only wallet in monero-wallet-rpc. The view key goes to the + * wallet file only: it is not stored in the database, nor logged. + */ +export default defineEventHandler(async (event) => { + await requireAdminSession(event); + if (!moneroRpcUrl()) { + throw createError({ statusCode: 400, message: 'MONERO_WALLET_RPC_URL is not set' }); + } + const body = await validateBody(event, bodySchema); + + const current = await moneroWalletStatus(); + if (current.ready) { + throw createError({ statusCode: 409, message: 'A Monero wallet is already set up' }); + } + + try { + await createViewWallet(body); + } catch (err) { + throw createError({ + statusCode: 502, + message: `Wallet RPC refused: ${(err as Error).message}`, + }); + } + return moneroWalletStatus(); +}); diff --git a/server/api/admin/payments/settings.get.ts b/server/api/admin/payments/settings.get.ts new file mode 100644 index 00000000..ea17cddc --- /dev/null +++ b/server/api/admin/payments/settings.get.ts @@ -0,0 +1,10 @@ +import { requireAdminSession } from '../../../utils/adminAuth'; +import { getPaidRegistration } from '../../../utils/settings'; + +/** + * GET /api/admin/payments/settings + */ +export default defineEventHandler(async (event) => { + await requireAdminSession(event); + return { config: await getPaidRegistration() }; +}); diff --git a/server/api/admin/payments/settings.put.ts b/server/api/admin/payments/settings.put.ts new file mode 100644 index 00000000..f1736a4d --- /dev/null +++ b/server/api/admin/payments/settings.put.ts @@ -0,0 +1,78 @@ +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 { moneroWalletStatus } from '../../../utils/payments/xmr'; +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'); + } + + if (body.xmr.enabled) { + const wallet = await moneroWalletStatus(); + if (!wallet.configured) invalid('Monero: MONERO_WALLET_RPC_URL is not set'); + if (!wallet.ready) invalid('Monero: create the view-only wallet first'); + } + + 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..052bfffd --- /dev/null +++ b/server/plugins/payment-watcher.ts @@ -0,0 +1,53 @@ +import { redis } from '../redis/client'; +import { claim } from '../redis/claim'; +import { getPaidRegistration } from '../utils/settings'; +import { parseCheckInterval } from '../../shared/utils/paidRegistration'; +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 = parseCheckInterval(process.env.PAYMENT_CHECK_INTERVAL); + 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 + 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 + ); + } + } + + 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); + } 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..d21d16ec --- /dev/null +++ b/server/utils/payments/http.ts @@ -0,0 +1,51 @@ +/** + * 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 } } | 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; +} + +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..9beed2f5 --- /dev/null +++ b/server/utils/payments/invoices.ts @@ -0,0 +1,293 @@ +/** + * 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, notInArray, 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. + * + * `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, + skipUserIds: string[] = [] +): 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), + skipUserIds.length > 0 ? notInArray(users.id, skipUserIds) : undefined, + 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 { + 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)); + await tx.delete(users).where(eq(users.id, id)); + return true; + }); + if (deleted) 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..eb7a0d86 --- /dev/null +++ b/server/utils/payments/xmr.ts @@ -0,0 +1,161 @@ +/** + * 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). + * + * The wallet file is created by the server from the admin's primary address + * and private view key (createViewWallet), protected by MONERO_WALLET_PASSWORD, + * and reopened on demand after the RPC restarts. + */ + +import { jsonRpc, trimSlash, type ChainReceipt } from './http'; + +export const MONERO_WALLET_FILE = 'trackarr-view'; + +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; +} + +function walletPassword(): string { + return process.env.MONERO_WALLET_PASSWORD ?? ''; +} + +const isNoWallet = (err: unknown) => /no wallet file/i.test((err as Error).message); + +/** + * RPC call on the wallet; after a restart the RPC has no wallet open: open + * ours and retry once + */ +async function walletRpc(method: string, params: unknown): Promise { + const url = requireRpc(); + try { + return await jsonRpc(url, method, params); + } catch (err) { + if (!isNoWallet(err)) throw err; + try { + await jsonRpc(url, 'open_wallet', { filename: MONERO_WALLET_FILE, password: walletPassword() }); + } catch (openErr) { + throw new Error(`Monero wallet not available: ${(openErr as Error).message}`); + } + return jsonRpc(url, method, params); + } +} + +// Primary addresses: mainnet 4, testnet 9/A, stagenet 5 (subaddresses start with 8) +const PRIMARY_ADDRESS = /^[459A][1-9A-HJ-NP-Za-km-z]{94}$/; +const VIEW_KEY = /^[0-9a-f]{64}$/; + +export function isMoneroPrimaryAddress(value: string): boolean { + return PRIMARY_ADDRESS.test(value); +} + +export function isMoneroViewKey(value: string): boolean { + return VIEW_KEY.test(value); +} + +/** Create the view-only wallet file (fails if it already exists) */ +export async function createViewWallet(input: { + address: string; + viewKey: string; + restoreHeight: number; +}): Promise { + await jsonRpc(requireRpc(), 'generate_from_keys', { + filename: MONERO_WALLET_FILE, + address: input.address, + viewkey: input.viewKey, + password: walletPassword(), + restore_height: input.restoreHeight, + autosave_current: true, + }); +} + +export interface MoneroWalletStatus { + /** MONERO_WALLET_RPC_URL is set */ + configured: boolean; + /** Wallet open (or reopened) and answering */ + ready: boolean; + address: string | null; + /** Last block scanned by the wallet */ + height: number | null; + error: string | null; +} + +export async function moneroWalletStatus(): Promise { + if (!moneroRpcUrl()) { + return { configured: false, ready: false, address: null, height: null, error: null }; + } + try { + const { address } = await walletRpc<{ address: string }>('get_address', { account_index: 0 }); + const { height } = await walletRpc<{ height: number }>('get_height', {}); + return { configured: true, ready: true, address, height, error: null }; + } catch (err) { + return { configured: true, ready: false, address: null, height: null, error: (err as Error).message }; + } +} + +export async function createXmrSubaddress(label: string): Promise<{ address: string; index: number }> { + const result = await walletRpc<{ address: string; address_index: number }>('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 walletRpc<{ in?: MoneroTransfer[]; pool?: MoneroTransfer[] }>('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..3d875178 --- /dev/null +++ b/shared/utils/paidRegistration.ts @@ -0,0 +1,221 @@ +/** + * 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}` : ''}`; + } +} + +export const DEFAULT_CHECK_INTERVAL_MS = 60_000; +export const MIN_CHECK_INTERVAL_MS = 5_000; +// setInterval turns larger delays into 1 ms +export const MAX_CHECK_INTERVAL_MS = 2_147_483_647; + +/** + * PAYMENT_CHECK_INTERVAL in ms: digits only, from 5 s to the setInterval + * limit, otherwise the default ("60s", "0" or a huge value 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 <= MAX_CHECK_INTERVAL_MS + ? value + : DEFAULT_CHECK_INTERVAL_MS; +} diff --git a/tests/paid-registration.test.ts b/tests/paid-registration.test.ts new file mode 100644 index 00000000..9a3ab4a2 --- /dev/null +++ b/tests/paid-registration.test.ts @@ -0,0 +1,237 @@ +import { afterEach, describe, it, expect, vi } 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'] }); + }); +}); + +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); + expect(parseCheckInterval('2147483647')).toBe(2_147_483_647); + expect(parseCheckInterval('2147483648')).toBe(60_000); + expect(parseCheckInterval('9'.repeat(400))).toBe(60_000); // Infinity + }); +}); + +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); + }); +}); 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 {};